diff --git a/.gitignore b/.gitignore index 36508301c3..0d84b7a8bc 100644 --- a/.gitignore +++ b/.gitignore @@ -87,3 +87,5 @@ docs/_cache/ .devcontainer/devcontainer-lock.json *.lscache +# BenchmarkDotNet output +BenchmarkDotNet.Artifacts/ diff --git a/Exceptionless.slnx b/Exceptionless.slnx index a7466782db..04a7c0da84 100644 --- a/Exceptionless.slnx +++ b/Exceptionless.slnx @@ -23,6 +23,8 @@ + + diff --git a/benchmarks/Directory.Build.props b/benchmarks/Directory.Build.props new file mode 100644 index 0000000000..993e0a1792 --- /dev/null +++ b/benchmarks/Directory.Build.props @@ -0,0 +1,7 @@ + + + + + false + + diff --git a/benchmarks/Exceptionless.Benchmarks/Exceptionless.Benchmarks.csproj b/benchmarks/Exceptionless.Benchmarks/Exceptionless.Benchmarks.csproj new file mode 100644 index 0000000000..e5b85aad23 --- /dev/null +++ b/benchmarks/Exceptionless.Benchmarks/Exceptionless.Benchmarks.csproj @@ -0,0 +1,19 @@ + + + Exe + + + + + + + + + + + + + + + diff --git a/benchmarks/Exceptionless.Benchmarks/Processing/EventIngestionProcessingBenchmarks.cs b/benchmarks/Exceptionless.Benchmarks/Processing/EventIngestionProcessingBenchmarks.cs new file mode 100644 index 0000000000..d05032e109 --- /dev/null +++ b/benchmarks/Exceptionless.Benchmarks/Processing/EventIngestionProcessingBenchmarks.cs @@ -0,0 +1,72 @@ +using BenchmarkDotNet.Attributes; +using Exceptionless.Core.Models; +using Exceptionless.Core.Models.Ingestion; +using Exceptionless.Core.Services; + +namespace Exceptionless.Benchmarks.Processing; + +[MemoryDiagnoser] +public class EventIngestionProcessingBenchmarks +{ + private const string StackTrace = """ + at System.Threading.Tasks.Task.Run() + at Example.Services.OrderService.Save() in /src/OrderService.cs:line 42 + at Example.Api.OrdersController.Post() in /src/OrdersController.cs:line 18 + """; + + private readonly StackTraceParser _parser = new(); + private StackFingerprintService _fingerprintService = null!; + private EventMaterializer _materializer = null!; + private EventIngestionV3Event _source = null!; + private Organization _organization = null!; + private Project _project = null!; + private StackFingerprint _fingerprint = null!; + + [GlobalSetup] + public void Setup() + { + _fingerprintService = new StackFingerprintService(_parser); + _materializer = new EventMaterializer(_parser, TimeProvider.System); + _organization = new Organization { Id = "507f191e810c19729de860ea", Name = "Benchmark" }; + _project = new Project + { + Id = "507f191e810c19729de860eb", + OrganizationId = _organization.Id, + Name = "Benchmark" + }; + _source = new EventIngestionV3Event + { + Id = "benchmark-event-1", + Type = Event.KnownTypes.Error, + Message = "Operation failed", + ExceptionType = "System.InvalidOperationException", + StackTrace = StackTrace, + Tags = ["benchmark", "v3"] + }; + _fingerprint = _fingerprintService.Create(_source, _organization, _project); + } + + [Benchmark] + public bool FingerprintRawStack() + { + return _parser.TryFindFrame(StackTrace, static frame => frame.DeclaringNamespace?.StartsWith("Example") is true, out _, out _, out _); + } + + [Benchmark] + public StackFingerprint CreateFingerprint() + { + return _fingerprintService.Create(_source, _organization, _project); + } + + [Benchmark] + public int ParseStructuredFrames() + { + return _parser.Parse(StackTrace).Count; + } + + [Benchmark] + public PersistentEvent MaterializeSurvivor() + { + return _materializer.Materialize(_source, _fingerprint, _organization, _project); + } +} diff --git a/benchmarks/Exceptionless.Benchmarks/Program.cs b/benchmarks/Exceptionless.Benchmarks/Program.cs new file mode 100644 index 0000000000..528ba5c55d --- /dev/null +++ b/benchmarks/Exceptionless.Benchmarks/Program.cs @@ -0,0 +1,11 @@ +using BenchmarkDotNet.Running; + +namespace Exceptionless.Benchmarks; + +public static class Program +{ + public static void Main(string[] args) + { + BenchmarkSwitcher.FromAssembly(typeof(Program).Assembly).Run(args); + } +} diff --git a/benchmarks/Exceptionless.Benchmarks/Serialization/EventIngestionDeserializationBenchmarks.cs b/benchmarks/Exceptionless.Benchmarks/Serialization/EventIngestionDeserializationBenchmarks.cs new file mode 100644 index 0000000000..739ba8fc13 --- /dev/null +++ b/benchmarks/Exceptionless.Benchmarks/Serialization/EventIngestionDeserializationBenchmarks.cs @@ -0,0 +1,222 @@ +using System.IO.Pipelines; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using BenchmarkDotNet.Attributes; +using Exceptionless.Core.Extensions; +using Exceptionless.Core.Models; +using Exceptionless.Core.Models.Ingestion; +using Exceptionless.Core.Serialization; +using Exceptionless.Web.Utility; + +namespace Exceptionless.Benchmarks.Serialization; + +[MemoryDiagnoser] +public class EventIngestionDeserializationBenchmarks +{ + private const int MaximumEventSize = 512 * 1024; + private byte[] _v2Payload = null!; + private byte[] _v3StreamPayload = null!; + private JsonSerializerOptions _v2JsonOptions = null!; + + [Params(1, 100, 1000)] + public int EventCount { get; set; } + + [GlobalSetup] + public void Setup() + { + _v2JsonOptions = new JsonSerializerOptions().ConfigureExceptionlessDefaults(); + _v2JsonOptions.RespectNullableAnnotations = false; + var v2Events = new V2BenchmarkEvent[EventCount]; + var v3Events = new EventIngestionV3Event[EventCount]; + for (int index = 0; index < v3Events.Length; index++) + { + const string message = "Operation failed"; + const string exceptionType = "System.InvalidOperationException"; + const string stackTrace = " at Example.Service.Run() in Service.cs:line 42"; + v2Events[index] = new V2BenchmarkEvent( + Event.KnownTypes.Error, + DateTimeOffset.UnixEpoch.AddSeconds(index), + message, + new V2BenchmarkData(new V2BenchmarkError(exceptionType, message, stackTrace))); + v3Events[index] = new EventIngestionV3Event + { + Id = $"01J0000000000000000000{index:D4}", + Type = "error", + Date = DateTimeOffset.UnixEpoch.AddSeconds(index), + Message = message, + ExceptionType = exceptionType, + StackTrace = stackTrace + }; + } + + _v2Payload = EventCount == 1 + ? JsonSerializer.SerializeToUtf8Bytes(v2Events[0]) + : JsonSerializer.SerializeToUtf8Bytes(v2Events); + + using var stream = new MemoryStream(_v2Payload.Length); + for (int index = 0; index < v3Events.Length; index++) + { + JsonSerializer.Serialize( + stream, + v3Events[index], + EventIngestionJsonContext.Default.EventIngestionV3Event); + stream.WriteByte((byte)'\n'); + } + + _v3StreamPayload = stream.ToArray(); + } + + [Benchmark(Baseline = true)] + public int DeserializeV2Payload() + { + string input = Encoding.UTF8.GetString(_v2Payload); + return input.GetJsonType() switch + { + JsonType.Object => JsonSerializer.Deserialize(input, _v2JsonOptions) is null ? 0 : 1, + JsonType.Array => JsonSerializer.Deserialize(input, _v2JsonOptions)?.Length ?? 0, + _ => 0 + }; + } + + [Benchmark] + public Task FrameAndRouteV3NdjsonAsync() => ReadV3NdjsonAsync(materializeSurvivors: false); + + [Benchmark] + public Task FrameRouteAndMaterializeV3SurvivorsAsync() => ReadV3NdjsonAsync(materializeSurvivors: true); + + private async Task ReadV3NdjsonAsync(bool materializeSurvivors) + { + using var stream = new MemoryStream(_v3StreamPayload, writable: false); + var reader = PipeReader.Create(stream); + int count = 0; + try + { + while (await EventIngestionV3StreamReader.ReadAsync(reader, MaximumEventSize, CancellationToken.None) is { } record) + { + try + { + EventIngestionV3Event parsed = materializeSurvivors + ? record.BufferedRecord.Materialize() + : record.Event; + if (!String.IsNullOrEmpty(parsed.Id)) + count++; + } + finally + { + record.BufferedRecord.Dispose(); + } + } + } + finally + { + await reader.CompleteAsync(); + } + + return count; + } + + private sealed record V2BenchmarkEvent( + [property: JsonPropertyName("type")] string Type, + [property: JsonPropertyName("date")] DateTimeOffset Date, + [property: JsonPropertyName("message")] string Message, + [property: JsonPropertyName("data")] V2BenchmarkData Data); + + private sealed record V2BenchmarkData( + [property: JsonPropertyName("@simple_error")] V2BenchmarkError SimpleError); + + private sealed record V2BenchmarkError( + [property: JsonPropertyName("type")] string Type, + [property: JsonPropertyName("message")] string Message, + [property: JsonPropertyName("stack_trace")] string StackTrace); +} + +/// +/// Keeps large raw stacks visible in allocation results. One accepted error is enough to expose +/// duplicate LOH strings without multiplying the benchmark process's retained payload by a batch. +/// +[MemoryDiagnoser] +public class LargeStackEventIngestionDeserializationBenchmarks +{ + private const int MaximumEventSize = 512 * 1024; + private byte[] _v2Payload = null!; + private byte[] _v3Payload = null!; + private JsonSerializerOptions _v2JsonOptions = null!; + + [Params(16 * 1024, 128 * 1024)] + public int StackTraceLength { get; set; } + + [GlobalSetup] + public void Setup() + { + string stackTrace = new('x', StackTraceLength); + _v2JsonOptions = new JsonSerializerOptions().ConfigureExceptionlessDefaults(); + _v2JsonOptions.RespectNullableAnnotations = false; + _v2Payload = JsonSerializer.SerializeToUtf8Bytes(new Dictionary + { + ["type"] = "error", + ["message"] = "Operation failed", + ["data"] = new Dictionary + { + ["@simple_error"] = new + { + type = "Example.Exception", + message = "Operation failed", + stack_trace = stackTrace + } + } + }); + _v3Payload = JsonSerializer.SerializeToUtf8Bytes(new + { + id = "large-stack-event", + type = "error", + message = "Operation failed", + exception_type = "Example.Exception", + stack_trace = stackTrace + }); + } + + [Benchmark(Baseline = true)] + public PersistentEvent? DeserializeV2Payload() + { + string input = Encoding.UTF8.GetString(_v2Payload); + return JsonSerializer.Deserialize(input, _v2JsonOptions); + } + + [Benchmark] + public Task FrameAndRouteV3NdjsonAsync() => ReadV3NdjsonAsync(materializeSurvivor: false); + + [Benchmark] + public Task FrameRouteAndMaterializeV3SurvivorAsync() => ReadV3NdjsonAsync(materializeSurvivor: true); + + private async Task ReadV3NdjsonAsync(bool materializeSurvivor) + { + using var stream = new MemoryStream(_v3Payload, writable: false); + var reader = PipeReader.Create(stream); + try + { + EventIngestionV3StreamRecord? record = await EventIngestionV3StreamReader.ReadAsync( + reader, + MaximumEventSize, + CancellationToken.None); + if (record is null) + return 0; + + try + { + EventIngestionV3Event parsed = materializeSurvivor + ? record.Value.BufferedRecord.Materialize() + : record.Value.Event; + return parsed.StackTrace?.Length ?? 0; + } + finally + { + record.Value.BufferedRecord.Dispose(); + } + } + finally + { + await reader.CompleteAsync(); + } + } +} diff --git a/benchmarks/Exceptionless.Ingestion.Load/Exceptionless.Ingestion.Load.csproj b/benchmarks/Exceptionless.Ingestion.Load/Exceptionless.Ingestion.Load.csproj new file mode 100644 index 0000000000..f780803dce --- /dev/null +++ b/benchmarks/Exceptionless.Ingestion.Load/Exceptionless.Ingestion.Load.csproj @@ -0,0 +1,13 @@ + + + Exe + + + + + + + + + + diff --git a/benchmarks/Exceptionless.Ingestion.Load/IngestionLoadRunner.cs b/benchmarks/Exceptionless.Ingestion.Load/IngestionLoadRunner.cs new file mode 100644 index 0000000000..f010c6ad3b --- /dev/null +++ b/benchmarks/Exceptionless.Ingestion.Load/IngestionLoadRunner.cs @@ -0,0 +1,609 @@ +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Net; +using System.Net.Http.Headers; +using System.Reflection; +using System.Runtime.InteropServices; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using Exceptionless.Core.Models.Ingestion; +using Exceptionless.Core.Serialization; + +namespace Exceptionless.Ingestion.Load; + +internal sealed class IngestionLoadRunner +{ + private const string EventPostIdHeader = "X-Exceptionless-Event-Post-Id"; + private const string TrackEventPostHeader = "X-Exceptionless-Track-Event-Post"; + private static readonly JsonSerializerOptions _protocolJsonOptions = new(JsonSerializerDefaults.Web); + private readonly LoadOptions _options; + private readonly HttpClient _client; + + public IngestionLoadRunner(LoadOptions options, HttpMessageHandler? handler = null) + { + _options = options; + _client = handler is null ? new HttpClient() : new HttpClient(handler, disposeHandler: false); + _client.Timeout = Timeout.InfiniteTimeSpan; + _client.DefaultRequestVersion = HttpVersion.Version20; + _client.DefaultVersionPolicy = HttpVersionPolicy.RequestVersionOrLower; + } + + public async Task RunAsync() + { + Console.WriteLine($"Comparing {String.Join(" and ", _options.Protocols)}: event_type={_options.EventType.ToString().ToLowerInvariant()} stack_scenario={_options.StackScenario.ToString().ToLowerInvariant()} events={_options.EventCount} expected_persisted={_options.ExpectedPersisted} batch_size={_options.BatchSize} requests={(int)Math.Ceiling((double)_options.EventCount / _options.BatchSize)} concurrency={_options.Concurrency} trials={_options.Trials} compression={_options.Compression} completion_poll_concurrency={_options.CompletionPollConcurrency}"); + Console.WriteLine("Submission headers and query visibility use common boundaries. Instrumented terminal processing includes observer counters because V2 tracks requests while V3 tracks persisted events; V3 durable acknowledgement is reported separately."); + + if (_options.WarmupEvents > 0) + { + foreach (IngestionProtocol protocol in _options.Protocols) + { + Console.WriteLine($"Warming {protocol} with {_options.WarmupEvents} events..."); + await ExecuteAsync(protocol, trial: -1, _options.WarmupEvents, GetExpectedPersistedCount(_options.WarmupEvents), isWarmup: true); + } + } + + var results = new List(); + for (int trial = 0; trial < _options.Trials; trial++) + { + IEnumerable order = trial % 2 == 0 ? _options.Protocols : _options.Protocols.Reverse(); + foreach (IngestionProtocol protocol in order) + { + LoadRunResult result = await ExecuteAsync(protocol, trial, _options.EventCount, _options.ExpectedPersisted, isWarmup: false); + results.Add(result); + WriteResult(result); + } + } + + WriteSummary(results); + if (!String.IsNullOrWhiteSpace(_options.ResultsPath)) + await WriteEvidenceAsync(results, _options.ResultsPath); + + _client.Dispose(); + return 0; + } + + private async Task ExecuteAsync(IngestionProtocol protocol, int trial, int eventCount, int expectedPersisted, bool isWarmup) + { + using var cancellation = new CancellationTokenSource(_options.Timeout); + string phase = isWarmup ? "warm" : $"t{trial + 1}"; + string runMarker = $"load-{_options.Seed}-{protocol.ToString().ToLowerInvariant()}-{phase}-{Guid.NewGuid():N}"; + string signatureNamespace = GetSignatureNamespace(protocol, phase, isWarmup); + string[] expectedV3ClientIds = protocol is IngestionProtocol.V3 && expectedPersisted > 0 + ? GetExpectedV3ClientIds(runMarker, eventCount, expectedPersisted) + : []; + DateTimeOffset eventDate = DateTimeOffset.UtcNow; + int requestCount = (eventCount + _options.BatchSize - 1) / _options.BatchSize; + int nextRequest = -1; + var requestLatencies = new ConcurrentBag(); + var processingCorrelationIds = new ConcurrentBag(); + var totals = new LoadTotals(); + long runStarted = Stopwatch.GetTimestamp(); + long lastSubmissionResponse = runStarted; + long lastDurableAcknowledgement = runStarted; + + Task[] workers = Enumerable.Range(0, Math.Min(_options.Concurrency, requestCount)).Select(_ => Task.Run(async () => + { + while (true) + { + int requestIndex = Interlocked.Increment(ref nextRequest); + if (requestIndex >= requestCount) + return; + + int start = requestIndex * _options.BatchSize; + int count = Math.Min(_options.BatchSize, eventCount - start); + using var content = new StreamingEventContent(_options, protocol, runMarker, signatureNamespace, eventDate, start, count); + using var request = new HttpRequestMessage(HttpMethod.Post, GetIngestionUrl(protocol)) { Content = content }; + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _options.SubmissionToken); + if (protocol is IngestionProtocol.V2) + request.Headers.Add(TrackEventPostHeader, "true"); + + long started = Stopwatch.GetTimestamp(); + using HttpResponseMessage response = await _client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellation.Token); + long responseReceived = Stopwatch.GetTimestamp(); + UpdateMaximum(ref lastSubmissionResponse, responseReceived); + requestLatencies.Add(Stopwatch.GetElapsedTime(started, responseReceived).TotalMilliseconds); + if (!response.IsSuccessStatusCode) + { + string detail = await response.Content.ReadAsStringAsync(cancellation.Token); + throw new InvalidOperationException($"{protocol} request {requestIndex + 1} returned {(int)response.StatusCode}: {Limit(detail)}"); + } + + if (protocol is IngestionProtocol.V2) + { + if (!response.Headers.TryGetValues(EventPostIdHeader, out var values) || values.SingleOrDefault() is not { Length: > 0 } processingCorrelationId) + throw new InvalidOperationException($"V2 request {requestIndex + 1} did not return the required {EventPostIdHeader} tracking header."); + processingCorrelationIds.Add(processingCorrelationId); + } + else + { + await using Stream body = await response.Content.ReadAsStreamAsync(cancellation.Token); + EventIngestionV3Response? terminal = await JsonSerializer.DeserializeAsync(body, EventIngestionJsonContext.Default.EventIngestionV3Response, cancellation.Token); + if (terminal is null) + throw new InvalidOperationException($"V3 request {requestIndex + 1} returned an empty terminal response."); + totals.Add(terminal); + UpdateMaximum(ref lastDurableAcknowledgement, Stopwatch.GetTimestamp()); + } + + Interlocked.Add(ref totals.UncompressedBytes, content.UncompressedBytes); + Interlocked.Add(ref totals.TransferredBytes, content.TransferredBytes); + Interlocked.Increment(ref totals.SuccessfulRequests); + } + }, cancellation.Token)).ToArray(); + + await Task.WhenAll(workers); + TimeSpan submissionElapsed = Stopwatch.GetElapsedTime(runStarted, Volatile.Read(ref lastSubmissionResponse)); + + if (protocol is IngestionProtocol.V3) + { + if (totals.Received != eventCount) + throw new InvalidOperationException($"V3 terminal responses reported {totals.Received} received events; expected {eventCount}."); + if (!isWarmup && totals.Persisted != expectedPersisted) + throw new InvalidOperationException($"V3 terminal responses reported {totals.Persisted} persisted events; expected {expectedPersisted}."); + } + + TimeSpan? durableAcknowledgementElapsed = protocol is IngestionProtocol.V3 + ? Stopwatch.GetElapsedTime(runStarted, Volatile.Read(ref lastDurableAcknowledgement)) + : null; + Task fullProcessingTask = protocol is IngestionProtocol.V2 + ? WaitForV2PipelineCompletionAsync(processingCorrelationIds.ToArray(), requestCount, runStarted, cancellation.Token) + : expectedPersisted > 0 + ? WaitForV3FullProcessingAsync(expectedV3ClientIds, runStarted, cancellation.Token) + : Task.FromResult(new CompletionObservation(durableAcknowledgementElapsed!.Value, 0, 0, 0, 0)); + Task queryVisibilityTask = !isWarmup && expectedPersisted > 0 + ? WaitForQueryVisibilityAsync(runMarker, expectedPersisted, runStarted, cancellation.Token) + : Task.FromResult(null); + + CompletionObservation fullProcessing = await fullProcessingTask; + QueryVisibilityObservation? queryVisibility = await queryVisibilityTask; + + return new LoadRunResult( + protocol, + trial, + runMarker, + signatureNamespace, + eventCount, + expectedPersisted, + requestCount, + totals.SuccessfulRequests, + totals.UncompressedBytes, + totals.TransferredBytes, + submissionElapsed, + fullProcessing.Elapsed, + durableAcknowledgementElapsed, + queryVisibility?.Elapsed, + queryVisibility?.ObservedPersisted ?? 0, + protocol is IngestionProtocol.V2 + ? CompletionIdentifierKind.EventPost + : expectedPersisted > 0 + ? CompletionIdentifierKind.PersistedEvent + : CompletionIdentifierKind.None, + fullProcessing.TrackedIdentifiers, + fullProcessing.StatusRequests, + fullProcessing.IdentifierReads, + fullProcessing.Sweeps, + queryVisibility?.Requests ?? 0, + Percentile(requestLatencies, 0.50), + Percentile(requestLatencies, 0.95), + Percentile(requestLatencies, 0.99), + totals.Received, + totals.Persisted, + totals.Discarded, + totals.Duplicate, + totals.Blocked, + totals.Invalid); + } + + private Task WaitForV3FullProcessingAsync(string[] clientIds, long runStarted, CancellationToken cancellationToken) + { + string[][] chunks = clientIds.Chunk(1000).Select(chunk => chunk.ToArray()).ToArray(); + return WaitForCompletionAsync( + chunks, + runStarted, + GetV3ProcessingSummaryAsync, + static (chunk, summary) => summary.Requested == chunk.Length && summary.Completed == chunk.Length, + cancellationToken); + } + + private async Task GetV3ProcessingSummaryAsync(string[] clientIds, CancellationToken cancellationToken) + { + Uri url = new(_options.BaseUrl, $"api/v3/projects/{_options.ProjectId}/events/processing/status"); + using var request = new HttpRequestMessage(HttpMethod.Post, url); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _options.SubmissionToken); + byte[] payload = JsonSerializer.SerializeToUtf8Bytes(new EventIngestionV3ProcessingStatusRequest(clientIds), _protocolJsonOptions); + request.Content = new ByteArrayContent(payload); + request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json"); + + using HttpResponseMessage response = await _client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken); + if (!response.IsSuccessStatusCode) + { + string detail = await response.Content.ReadAsStringAsync(cancellationToken); + throw new InvalidOperationException($"V3 full-processing status returned {(int)response.StatusCode}: {Limit(detail)}"); + } + + await using Stream body = await response.Content.ReadAsStreamAsync(cancellationToken); + return await JsonSerializer.DeserializeAsync(body, _protocolJsonOptions, cancellationToken) + ?? throw new InvalidOperationException("V3 full-processing status returned an empty response."); + } + + private Task WaitForV2PipelineCompletionAsync(string[] processingCorrelationIds, int requestCount, long runStarted, CancellationToken cancellationToken) + { + if (processingCorrelationIds.Length != requestCount) + throw new InvalidOperationException($"V2 returned {processingCorrelationIds.Length} tracked event-post ids for {requestCount} successful requests."); + + string[][] chunks = processingCorrelationIds.Chunk(1000).Select(chunk => chunk.ToArray()).ToArray(); + return WaitForCompletionAsync( + chunks, + runStarted, + GetV2ProcessingSummaryAsync, + static (chunk, summary) => summary.Requested == chunk.Length && summary.Completed == chunk.Length, + cancellationToken); + } + + private async Task WaitForCompletionAsync( + string[][] chunks, + long runStarted, + Func> getSummaryAsync, + Func isComplete, + CancellationToken cancellationToken) + { + var pending = chunks.ToList(); + int statusRequests = 0; + long identifierReads = 0; + int sweeps = 0; + while (pending.Count > 0) + { + sweeps++; + var nextPending = new List(); + foreach (string[][] page in pending.Chunk(_options.CompletionPollConcurrency)) + { + TSummary[] summaries = await Task.WhenAll(page.Select(chunk => getSummaryAsync(chunk, cancellationToken))); + statusRequests += page.Length; + identifierReads += page.Sum(chunk => (long)chunk.Length); + for (int index = 0; index < page.Length; index++) + { + if (!isComplete(page[index], summaries[index])) + nextPending.Add(page[index]); + } + } + + if (nextPending.Count == 0) + { + return new CompletionObservation( + Stopwatch.GetElapsedTime(runStarted), + chunks.Sum(chunk => (long)chunk.Length), + statusRequests, + identifierReads, + sweeps); + } + + pending = nextPending; + await Task.Delay(_options.PollInterval, cancellationToken); + } + + throw new InvalidOperationException("At least one completion identifier is required."); + } + + private async Task GetV2ProcessingSummaryAsync(string[] processingCorrelationIds, CancellationToken cancellationToken) + { + Uri url = new(_options.BaseUrl, $"api/v2/projects/{_options.ProjectId}/events/posts/status"); + using var request = new HttpRequestMessage(HttpMethod.Post, url); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _options.SubmissionToken); + byte[] payload = JsonSerializer.SerializeToUtf8Bytes(new EventPostProcessingStatusRequest(processingCorrelationIds), _protocolJsonOptions); + request.Content = new ByteArrayContent(payload); + request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json"); + + using HttpResponseMessage response = await _client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken); + if (!response.IsSuccessStatusCode) + { + string detail = await response.Content.ReadAsStringAsync(cancellationToken); + throw new InvalidOperationException($"V2 processing status returned {(int)response.StatusCode}: {Limit(detail)}"); + } + + await using Stream body = await response.Content.ReadAsStreamAsync(cancellationToken); + return await JsonSerializer.DeserializeAsync(body, _protocolJsonOptions, cancellationToken) + ?? throw new InvalidOperationException("V2 processing status returned an empty response."); + } + + private async Task WaitForQueryVisibilityAsync(string runMarker, int expectedPersisted, long runStarted, CancellationToken cancellationToken) + { + Uri url = new(_options.BaseUrl, $"api/v2/projects/{_options.ProjectId}/events/count?filter={Uri.EscapeDataString($"tag:{runMarker}")}"); + int requests = 0; + while (true) + { + using var request = new HttpRequestMessage(HttpMethod.Get, url); + SetReadAuthorization(request); + using HttpResponseMessage response = await _client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken); + requests++; + if (!response.IsSuccessStatusCode) + { + string detail = await response.Content.ReadAsStringAsync(cancellationToken); + throw new InvalidOperationException($"Query-visible count returned {(int)response.StatusCode}: {Limit(detail)}"); + } + + await using Stream body = await response.Content.ReadAsStreamAsync(cancellationToken); + using JsonDocument json = await JsonDocument.ParseAsync(body, cancellationToken: cancellationToken); + long total = json.RootElement.GetProperty("total").GetInt64(); + if (total >= expectedPersisted) + return new QueryVisibilityObservation(Stopwatch.GetElapsedTime(runStarted), total, requests); + await Task.Delay(_options.PollInterval, cancellationToken); + } + } + + private string GetSignatureNamespace(IngestionProtocol protocol, string phase, bool isWarmup) + { + string prefix = $"{_options.Seed}-{protocol.ToString().ToLowerInvariant()}"; + if (_options.StackScenario is StackScenario.Hot) + return $"{prefix}-hot"; + + return $"{prefix}-{phase}-{(isWarmup ? "warm" : "new")}-{Guid.NewGuid():N}"; + } + + private string[] GetExpectedV3ClientIds(string runMarker, int eventCount, int expectedPersisted) + { + IEnumerable expectedIndexes = Enumerable.Range(0, eventCount); + if (expectedPersisted != eventCount) + expectedIndexes = expectedIndexes.Where(index => index % 100 >= _options.DiscardPercent); + + int[] indexes = expectedIndexes.ToArray(); + if (indexes.Length != expectedPersisted) + { + throw new InvalidOperationException( + $"Cannot identify the {expectedPersisted} V3 events expected to persist from this corpus. " + + "Use the discard-derived default, zero, or the full event count for --expected-persisted."); + } + + return indexes.Select(index => StreamingEventContent.GetV3ClientId(runMarker, index)).ToArray(); + } + + private int GetExpectedPersistedCount(int eventCount) + { + if (_options.ExpectedPersisted == _options.EventCount) + return eventCount; + if (_options.ExpectedPersisted == 0) + return 0; + + int discarded = eventCount / 100 * _options.DiscardPercent + Math.Min(eventCount % 100, _options.DiscardPercent); + return eventCount - discarded; + } + + private Uri GetIngestionUrl(IngestionProtocol protocol) => new(_options.BaseUrl, $"api/{protocol.ToString().ToLowerInvariant()}/projects/{_options.ProjectId}/events"); + + private void SetReadAuthorization(HttpRequestMessage request) + { + if (!String.IsNullOrWhiteSpace(_options.ReadToken)) + { + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _options.ReadToken); + return; + } + + string credentials = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{_options.ReadUser}:{_options.ReadPassword}")); + request.Headers.Authorization = new AuthenticationHeaderValue("Basic", credentials); + } + + private async Task WriteEvidenceAsync(IReadOnlyList results, string resultsPath) + { + string fullPath = Path.GetFullPath(resultsPath); + string? directory = Path.GetDirectoryName(fullPath); + if (!String.IsNullOrEmpty(directory)) + Directory.CreateDirectory(directory); + + var evidence = new LoadEvidence( + "3", + DateTimeOffset.UtcNow, + new LoadEnvironment( + _options.EnvironmentLabel, + RuntimeInformation.FrameworkDescription, + RuntimeInformation.OSDescription, + RuntimeInformation.ProcessArchitecture.ToString(), + System.Environment.ProcessorCount, + typeof(Program).Assembly.GetCustomAttribute()?.InformationalVersion), + new LoadConfiguration( + _options.BaseUrl.ToString(), + _options.ProjectId, + _options.Protocols, + _options.EventType, + _options.StackScenario, + _options.EventCount, + _options.ExpectedPersisted, + _options.Concurrency, + _options.BatchSize, + _options.Trials, + _options.WarmupEvents, + _options.SignatureCardinality, + _options.DiscardPercent, + _options.Compression, + _options.Seed, + _options.Message.Length, + _options.Timeout.TotalSeconds, + _options.PollInterval.TotalMilliseconds, + _options.CompletionPollConcurrency), + results); + var serializerOptions = new JsonSerializerOptions + { + WriteIndented = true, + PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower + }; + serializerOptions.Converters.Add(new JsonStringEnumConverter(JsonNamingPolicy.SnakeCaseLower)); + await using FileStream output = File.Create(fullPath); + await JsonSerializer.SerializeAsync(output, evidence, serializerOptions); + Console.WriteLine($"Evidence written to {fullPath}"); + } + + private static double Percentile(IEnumerable values, double percentile) + { + double[] ordered = values.Order().ToArray(); + if (ordered.Length == 0) + return 0; + int index = (int)Math.Ceiling(percentile * ordered.Length) - 1; + return ordered[Math.Clamp(index, 0, ordered.Length - 1)]; + } + + private static string Limit(string value) => value.Length <= 500 ? value : value[..500]; + + private static void UpdateMaximum(ref long target, long value) + { + long current = Volatile.Read(ref target); + while (value > current) + { + long observed = Interlocked.CompareExchange(ref target, value, current); + if (observed == current) + return; + current = observed; + } + } + + private static void WriteResult(LoadRunResult result) + { + double submissionRate = Rate(result.EventCount, result.SubmissionElapsed); + string durableAcknowledgement = result.V3DurableAcknowledgementElapsed.HasValue + ? $" durable_ack={Rate(result.EventCount, result.V3DurableAcknowledgementElapsed.Value):F0} events/s {result.V3DurableAcknowledgementElapsed.Value.TotalSeconds:F3}s" + : String.Empty; + string queryVisible = result.QueryVisibleElapsed.HasValue + ? $"{Rate(result.ExpectedPersisted, result.QueryVisibleElapsed.Value):F0} persisted/s {result.QueryVisibleElapsed.Value.TotalSeconds:F3}s" + : "n/a"; + Console.WriteLine($"{result.Protocol} trial={result.Trial + 1} requests={result.SuccessfulRequests}/{result.RequestCount} submission={submissionRate:F0} events/s full_processing_observed={Rate(result.EventCount, result.ObservedFullProcessingElapsed):F0} events/s {result.ObservedFullProcessingElapsed.TotalSeconds:F3}s{durableAcknowledgement} query_visible={queryVisible} observed_persisted={result.ObservedPersisted} latency_ms_p50/p95/p99={result.P50Milliseconds:F1}/{result.P95Milliseconds:F1}/{result.P99Milliseconds:F1} bytes={result.TransferredBytes} raw_bytes={result.UncompressedBytes}"); + string completionIdentifierKind = JsonNamingPolicy.SnakeCaseLower.ConvertName(result.CompletionIdentifierKind.ToString()); + Console.WriteLine($" completion_observer identifier_kind={completionIdentifierKind} tracked_ids={result.CompletionTrackedIdentifiers} status_requests={result.CompletionStatusRequests} identifier_reads={result.CompletionIdentifierReads} sweeps={result.CompletionSweeps} query_requests={result.QueryVisibilityRequests}"); + if (result.Protocol is IngestionProtocol.V3) + Console.WriteLine($" terminal received={result.Received} persisted={result.Persisted} discarded={result.Discarded} duplicate={result.Duplicate} blocked={result.Blocked} invalid={result.Invalid}"); + } + + private static void WriteSummary(IReadOnlyList results) + { + Console.WriteLine("Median measured results:"); + foreach (IGrouping group in results.GroupBy(r => r.Protocol).OrderBy(g => g.Key)) + { + double submissionRate = Median(group.Select(r => Rate(r.EventCount, r.SubmissionElapsed))); + double? queryVisibleRate = group.All(r => r.QueryVisibleElapsed.HasValue) + ? Median(group.Select(r => Rate(r.ExpectedPersisted, r.QueryVisibleElapsed!.Value))) + : null; + double fullProcessingRate = Median(group.Select(r => Rate(r.EventCount, r.ObservedFullProcessingElapsed))); + string durableAcknowledgement = group.Key is IngestionProtocol.V3 + ? $" durable_ack={Median(group.Select(r => Rate(r.EventCount, r.V3DurableAcknowledgementElapsed!.Value))):F0} events/s" + : String.Empty; + Console.WriteLine($" {group.Key}: submission={submissionRate:F0} events/s full_processing_observed={fullProcessingRate:F0} events/s{durableAcknowledgement} query_visible={(queryVisibleRate.HasValue ? $"{queryVisibleRate:F0} persisted/s" : "n/a")}"); + } + Console.WriteLine("Full-processing observations include protocol-specific tracking and polling overhead; use the recorded observer counters and server telemetry before attributing a difference to pipeline efficiency."); + } + + private static double Rate(long count, TimeSpan elapsed) => count / Math.Max(elapsed.TotalSeconds, 0.001); + + private static double Median(IEnumerable source) + { + double[] values = source.Order().ToArray(); + int middle = values.Length / 2; + return values.Length % 2 == 0 ? (values[middle - 1] + values[middle]) / 2 : values[middle]; + } + + private sealed class LoadTotals + { + public long SuccessfulRequests; + public long UncompressedBytes; + public long TransferredBytes; + public long Received; + public long Persisted; + public long Discarded; + public long Duplicate; + public long Blocked; + public long Invalid; + + public void Add(EventIngestionV3Response value) + { + Interlocked.Add(ref Received, value.Received); + Interlocked.Add(ref Persisted, value.Persisted); + Interlocked.Add(ref Discarded, value.Discarded); + Interlocked.Add(ref Duplicate, value.Duplicate); + Interlocked.Add(ref Blocked, value.Blocked); + Interlocked.Add(ref Invalid, value.Invalid); + } + } +} + +internal sealed record EventPostProcessingStatusRequest(string[] Ids); +internal sealed record EventPostProcessingSummary(int Requested, int Queued, int Completed, int Unknown); +internal sealed record EventIngestionV3ProcessingStatusRequest( + [property: JsonPropertyName("client_ids")] string[] ClientIds); +internal sealed record EventIngestionV3ProcessingSummary(int Requested, int Pending, int Completed); +internal sealed record CompletionObservation( + TimeSpan Elapsed, + long TrackedIdentifiers, + int StatusRequests, + long IdentifierReads, + int Sweeps); +internal sealed record QueryVisibilityObservation(TimeSpan Elapsed, long ObservedPersisted, int Requests); + +internal enum CompletionIdentifierKind +{ + None, + EventPost, + PersistedEvent +} + +internal sealed record LoadRunResult( + IngestionProtocol Protocol, + int Trial, + string RunMarker, + string SignatureNamespace, + int EventCount, + int ExpectedPersisted, + int RequestCount, + long SuccessfulRequests, + long UncompressedBytes, + long TransferredBytes, + TimeSpan SubmissionElapsed, + TimeSpan ObservedFullProcessingElapsed, + TimeSpan? V3DurableAcknowledgementElapsed, + TimeSpan? QueryVisibleElapsed, + long ObservedPersisted, + CompletionIdentifierKind CompletionIdentifierKind, + long CompletionTrackedIdentifiers, + int CompletionStatusRequests, + long CompletionIdentifierReads, + int CompletionSweeps, + int QueryVisibilityRequests, + double P50Milliseconds, + double P95Milliseconds, + double P99Milliseconds, + long Received, + long Persisted, + long Discarded, + long Duplicate, + long Blocked, + long Invalid); + +internal sealed record LoadEvidence( + string SchemaVersion, + DateTimeOffset CapturedUtc, + LoadEnvironment Environment, + LoadConfiguration Configuration, + IReadOnlyList Results); + +internal sealed record LoadEnvironment( + string? Label, + string Runtime, + string OperatingSystem, + string ProcessArchitecture, + int ProcessorCount, + string? BuildVersion); + +internal sealed record LoadConfiguration( + string BaseUrl, + string ProjectId, + IReadOnlyList Protocols, + LoadEventType EventType, + StackScenario StackScenario, + int EventCount, + int ExpectedPersisted, + int Concurrency, + int BatchSize, + int Trials, + int WarmupEvents, + int SignatureCardinality, + int DiscardPercent, + string Compression, + string Seed, + int MessageBytes, + double TimeoutSeconds, + double PollIntervalMilliseconds, + int CompletionPollConcurrency); diff --git a/benchmarks/Exceptionless.Ingestion.Load/LoadOptions.cs b/benchmarks/Exceptionless.Ingestion.Load/LoadOptions.cs new file mode 100644 index 0000000000..65cf77a649 --- /dev/null +++ b/benchmarks/Exceptionless.Ingestion.Load/LoadOptions.cs @@ -0,0 +1,182 @@ +using Exceptionless.Core.Models.Ingestion; + +namespace Exceptionless.Ingestion.Load; + +internal enum IngestionProtocol +{ + V2, + V3 +} + +internal enum LoadEventType +{ + Log, + Error +} + +internal enum StackScenario +{ + Hot, + New +} + +internal sealed record LoadOptions( + Uri BaseUrl, + string ProjectId, + string SubmissionToken, + string? ReadToken, + string? ReadUser, + string? ReadPassword, + IReadOnlyList Protocols, + LoadEventType EventType, + StackScenario StackScenario, + int EventCount, + int ExpectedPersisted, + int Concurrency, + int BatchSize, + int Trials, + int WarmupEvents, + int SignatureCardinality, + int DiscardPercent, + string Compression, + string Seed, + string? ResultsPath, + string? EnvironmentLabel, + string Message, + TimeSpan Timeout, + TimeSpan PollInterval, + int CompletionPollConcurrency) +{ + public static LoadOptions Parse(string[] args) + { + var values = new Dictionary(StringComparer.OrdinalIgnoreCase); + for (int index = 0; index < args.Length; index += 2) + { + if (index + 1 >= args.Length || !args[index].StartsWith("--", StringComparison.Ordinal)) + throw new ArgumentException("Every option must use '--name value'."); + values[args[index][2..]] = args[index + 1]; + } + + string? rawBaseUrl = values.GetValueOrDefault("base-url"); + if (String.IsNullOrWhiteSpace(rawBaseUrl) || !Uri.TryCreate(rawBaseUrl, UriKind.Absolute, out var baseUrl)) + throw new ArgumentException("--base-url must be an absolute API origin."); + + string projectId = values.GetValueOrDefault("project-id") + ?? Environment.GetEnvironmentVariable("EXCEPTIONLESS_PROJECT_ID") + ?? throw new ArgumentException("Set --project-id or EXCEPTIONLESS_PROJECT_ID."); + string submissionToken = values.GetValueOrDefault("submission-token") + ?? Environment.GetEnvironmentVariable("EXCEPTIONLESS_API_KEY") + ?? throw new ArgumentException("Set --submission-token or EXCEPTIONLESS_API_KEY."); + string? readToken = values.GetValueOrDefault("read-token") + ?? Environment.GetEnvironmentVariable("EXCEPTIONLESS_READ_TOKEN"); + string? readUser = values.GetValueOrDefault("read-user") + ?? Environment.GetEnvironmentVariable("EXCEPTIONLESS_READ_USER"); + string? readPassword = values.GetValueOrDefault("read-password") + ?? Environment.GetEnvironmentVariable("EXCEPTIONLESS_READ_PASSWORD"); + IReadOnlyList protocols = ParseProtocols(values.GetValueOrDefault("protocol", "both")); + LoadEventType eventType = values.GetValueOrDefault("event-type", "error").ToLowerInvariant() switch + { + "log" => LoadEventType.Log, + "error" => LoadEventType.Error, + _ => throw new ArgumentException("--event-type must be log or error.") + }; + StackScenario stackScenario = values.GetValueOrDefault("stack-scenario", "hot").ToLowerInvariant() switch + { + "hot" => StackScenario.Hot, + "new" => StackScenario.New, + _ => throw new ArgumentException("--stack-scenario must be hot or new.") + }; + int eventCount = GetInt(values, "events", 10_000, 1, 10_000_000); + int concurrency = GetInt(values, "concurrency", 4, 1, 1024); + int batchSize = GetInt(values, "batch-size", 100, 1, 10_000); + int trials = GetInt(values, "trials", 3, 1, 100); + int warmupEvents = GetInt(values, "warmup-events", 100, 0, 100_000); + int cardinality = GetInt(values, "signature-cardinality", 10, 1, 1_000_000); + int discardPercent = GetInt(values, "discard-percent", 0, 0, 100); + int expectedPersisted = GetInt(values, "expected-persisted", eventCount - GetDiscardCandidateCount(eventCount, discardPercent), 0, eventCount); + if (expectedPersisted > 0 && String.IsNullOrWhiteSpace(readToken) && (String.IsNullOrWhiteSpace(readUser) || String.IsNullOrWhiteSpace(readPassword))) + throw new ArgumentException("Set --read-token, or both --read-user and --read-password, so query-visible completion can be measured for both protocols."); + if (discardPercent > 0 && eventType is not LoadEventType.Error) + throw new ArgumentException("--discard-percent requires --event-type error."); + if (discardPercent > 0 && stackScenario is StackScenario.New) + throw new ArgumentException("Discard comparisons require --stack-scenario hot so the pre-discarded stack identities remain stable."); + int messageBytes = GetInt(values, "message-bytes", 64, 0, EventIngestionV3Limits.MaximumMessageLength); + int timeoutSeconds = GetInt(values, "timeout-seconds", 300, 1, 86_400); + int pollIntervalMilliseconds = GetInt(values, "poll-interval-ms", 250, 10, 60_000); + int completionPollConcurrency = GetInt(values, "completion-poll-concurrency", 4, 1, 64); + string compression = values.GetValueOrDefault("compression", "none").ToLowerInvariant(); + if (compression is not ("none" or "gzip")) + throw new ArgumentException("Apples-to-apples comparisons support the encodings common to both APIs: none or gzip."); + + return new LoadOptions( + EnsureTrailingSlash(baseUrl), + projectId, + submissionToken, + readToken, + readUser, + readPassword, + protocols, + eventType, + stackScenario, + eventCount, + expectedPersisted, + concurrency, + batchSize, + trials, + warmupEvents, + cardinality, + discardPercent, + compression, + SanitizeSeed(values.GetValueOrDefault("seed", "default")), + values.GetValueOrDefault("results"), + values.GetValueOrDefault("environment-label"), + new string('x', messageBytes), + TimeSpan.FromSeconds(timeoutSeconds), + TimeSpan.FromMilliseconds(pollIntervalMilliseconds), + completionPollConcurrency); + } + + public static void WriteUsage() + { + Console.Error.WriteLine("dotnet run -c Release --project benchmarks/Exceptionless.Ingestion.Load -- --base-url --project-id --protocol v2|v3|both [--submission-token ] [--read-token | --read-user --read-password ] [--events 10000] [--batch-size 1|1000] [--concurrency 4] [--trials 3] [--warmup-events 100] [--event-type log|error] [--stack-scenario hot|new] [--signature-cardinality 10] [--discard-percent 0] [--compression none|gzip] [--completion-poll-concurrency 4] [--results result.json] [--environment-label text]"); + } + + private static IReadOnlyList ParseProtocols(string value) + { + return value.ToLowerInvariant() switch + { + "v2" => [IngestionProtocol.V2], + "v3" => [IngestionProtocol.V3], + "both" => [IngestionProtocol.V2, IngestionProtocol.V3], + _ => throw new ArgumentException("--protocol must be v2, v3, or both.") + }; + } + + private static Uri EnsureTrailingSlash(Uri value) + { + var builder = new UriBuilder(value); + if (!builder.Path.EndsWith('/')) + builder.Path += "/"; + return builder.Uri; + } + + private static string SanitizeSeed(string value) + { + string sanitized = new(value.Where(c => Char.IsAsciiLetterOrDigit(c) || c == '-').Take(24).ToArray()); + return String.IsNullOrEmpty(sanitized) ? "default" : sanitized; + } + + private static int GetInt(IReadOnlyDictionary values, string key, int defaultValue, int minimum, int maximum) + { + if (!values.TryGetValue(key, out string? raw)) + return defaultValue; + if (!Int32.TryParse(raw, out int value) || value < minimum || value > maximum) + throw new ArgumentException($"--{key} must be between {minimum} and {maximum}."); + return value; + } + + private static int GetDiscardCandidateCount(int eventCount, int discardPercent) + { + return eventCount / 100 * discardPercent + Math.Min(eventCount % 100, discardPercent); + } +} diff --git a/benchmarks/Exceptionless.Ingestion.Load/Program.cs b/benchmarks/Exceptionless.Ingestion.Load/Program.cs new file mode 100644 index 0000000000..73ab6a9845 --- /dev/null +++ b/benchmarks/Exceptionless.Ingestion.Load/Program.cs @@ -0,0 +1,34 @@ +namespace Exceptionless.Ingestion.Load; + +public static class Program +{ + public static async Task Main(string[] args) + { + LoadOptions options; + try + { + options = LoadOptions.Parse(args); + } + catch (ArgumentException ex) + { + Console.Error.WriteLine(ex.Message); + LoadOptions.WriteUsage(); + return 2; + } + + try + { + return await new IngestionLoadRunner(options).RunAsync(); + } + catch (OperationCanceledException) + { + Console.Error.WriteLine($"The comparison exceeded the {options.Timeout:c} per-run timeout."); + return 1; + } + catch (Exception ex) + { + Console.Error.WriteLine(ex.Message); + return 1; + } + } +} diff --git a/benchmarks/Exceptionless.Ingestion.Load/StreamingEventContent.cs b/benchmarks/Exceptionless.Ingestion.Load/StreamingEventContent.cs new file mode 100644 index 0000000000..ef793f7fd0 --- /dev/null +++ b/benchmarks/Exceptionless.Ingestion.Load/StreamingEventContent.cs @@ -0,0 +1,169 @@ +using System.IO.Compression; +using System.Net; +using System.Net.Http.Headers; +using System.Text.Json; +using System.Text.Json.Serialization; +using Exceptionless.Core.Models; +using Exceptionless.Core.Models.Ingestion; +using Exceptionless.Core.Serialization; + +namespace Exceptionless.Ingestion.Load; + +internal sealed class StreamingEventContent : HttpContent +{ + private static readonly byte[] _arrayStart = [(byte)'[']; + private static readonly byte[] _arrayEnd = [(byte)']']; + private static readonly byte[] _comma = [(byte)',']; + private static readonly byte[] _newline = [(byte)'\n']; + private readonly LoadOptions _options; + private readonly IngestionProtocol _protocol; + private readonly string _runMarker; + private readonly string _corpusName; + private readonly DateTimeOffset _eventDate; + private readonly int _start; + private readonly int _count; + + public StreamingEventContent(LoadOptions options, IngestionProtocol protocol, string runMarker, string signatureNamespace, DateTimeOffset eventDate, int start, int count) + { + _options = options; + _protocol = protocol; + _runMarker = runMarker; + _corpusName = signatureNamespace.Replace("-", String.Empty, StringComparison.Ordinal); + _eventDate = eventDate; + _start = start; + _count = count; + Headers.ContentType = new MediaTypeHeaderValue(protocol is IngestionProtocol.V2 ? "application/json" : "application/x-ndjson"); + if (options.Compression is "gzip") + Headers.ContentEncoding.Add(options.Compression); + } + + public long UncompressedBytes { get; private set; } + public long TransferredBytes { get; private set; } + + protected override Task SerializeToStreamAsync(Stream stream, TransportContext? context) => WriteAsync(stream, CancellationToken.None); + + protected override Task SerializeToStreamAsync(Stream stream, TransportContext? context, CancellationToken cancellationToken) => WriteAsync(stream, cancellationToken); + + protected override bool TryComputeLength(out long length) + { + length = 0; + return false; + } + + private async Task WriteAsync(Stream output, CancellationToken cancellationToken) + { + var transferred = new CountingWriteStream(output); + await using Stream? compressor = _options.Compression is "gzip" + ? new GZipStream(transferred, CompressionLevel.Fastest, leaveOpen: true) + : null; + var uncompressed = new CountingWriteStream(compressor ?? transferred); + + if (_protocol is IngestionProtocol.V2 && _count > 1) + await uncompressed.WriteAsync(_arrayStart, cancellationToken); + + for (int offset = 0; offset < _count; offset++) + { + if (_protocol is IngestionProtocol.V2 && _count > 1 && offset > 0) + await uncompressed.WriteAsync(_comma, cancellationToken); + + int index = _start + offset; + string referenceId = $"{_runMarker}-{index:D8}"; + bool discardedCandidate = index % 100 < _options.DiscardPercent; + int signature = index % _options.SignatureCardinality; + string signatureKind = discardedCandidate ? "Discarded" : "Active"; + string? exceptionType = null; + string? stackTrace = null; + if (_options.EventType is LoadEventType.Error) + { + exceptionType = $"Load.{_corpusName}.{signatureKind}Exception{signature}"; + stackTrace = $"at Load.{_corpusName}.{signatureKind}Service{signature}.Run() in /src/Load.cs:line {signature + 1}"; + } + + if (_protocol is IngestionProtocol.V2) + { + var source = new V2LoadEvent( + _options.EventType is LoadEventType.Error ? Event.KnownTypes.Error : Event.KnownTypes.Log, + _eventDate, + _options.Message, + referenceId, + [_runMarker], + _options.EventType is LoadEventType.Error + ? new V2LoadEventData(new V2SimpleError(exceptionType!, _options.Message, stackTrace!)) + : null); + await JsonSerializer.SerializeAsync(uncompressed, source, LoadJsonContext.Default.V2LoadEvent, cancellationToken); + } + else + { + var source = new EventIngestionV3Event + { + Id = GetV3ClientId(_runMarker, index), + Type = _options.EventType is LoadEventType.Error ? Event.KnownTypes.Error : Event.KnownTypes.Log, + Date = _eventDate, + Message = _options.Message, + ReferenceId = referenceId, + Tags = [_runMarker], + ExceptionType = exceptionType, + StackTrace = stackTrace + }; + await JsonSerializer.SerializeAsync(uncompressed, source, EventIngestionJsonContext.Default.EventIngestionV3Event, cancellationToken); + await uncompressed.WriteAsync(_newline, cancellationToken); + } + } + + if (_protocol is IngestionProtocol.V2 && _count > 1) + await uncompressed.WriteAsync(_arrayEnd, cancellationToken); + + await uncompressed.FlushAsync(cancellationToken); + if (compressor is not null) + await compressor.DisposeAsync(); + await transferred.FlushAsync(cancellationToken); + UncompressedBytes = uncompressed.BytesWritten; + TransferredBytes = transferred.BytesWritten; + } + + internal static string GetV3ClientId(string runMarker, int index) => $"{runMarker}-{index:D8}-v3"; +} + +internal sealed record V2LoadEvent( + [property: JsonPropertyName("type")] string Type, + [property: JsonPropertyName("date")] DateTimeOffset Date, + [property: JsonPropertyName("message")] string Message, + [property: JsonPropertyName("reference_id")] string ReferenceId, + [property: JsonPropertyName("tags")] string[] Tags, + [property: JsonPropertyName("data"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] V2LoadEventData? Data); + +internal sealed record V2LoadEventData( + [property: JsonPropertyName("@simple_error")] V2SimpleError SimpleError); + +internal sealed record V2SimpleError( + [property: JsonPropertyName("type")] string Type, + [property: JsonPropertyName("message")] string Message, + [property: JsonPropertyName("stack_trace")] string StackTrace); + +[JsonSerializable(typeof(V2LoadEvent))] +internal sealed partial class LoadJsonContext : JsonSerializerContext; + +internal sealed class CountingWriteStream(Stream inner) : Stream +{ + public long BytesWritten { get; private set; } + public override bool CanRead => false; + public override bool CanSeek => false; + public override bool CanWrite => true; + public override long Length => throw new NotSupportedException(); + public override long Position { get => throw new NotSupportedException(); set => throw new NotSupportedException(); } + public override void Flush() => inner.Flush(); + public override Task FlushAsync(CancellationToken cancellationToken) => inner.FlushAsync(cancellationToken); + public override int Read(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); + public override void Write(byte[] buffer, int offset, int count) + { + inner.Write(buffer, offset, count); + BytesWritten += count; + } + public override async ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) + { + await inner.WriteAsync(buffer, cancellationToken); + BytesWritten += buffer.Length; + } +} diff --git a/benchmarks/README.md b/benchmarks/README.md new file mode 100644 index 0000000000..58862409c8 --- /dev/null +++ b/benchmarks/README.md @@ -0,0 +1,141 @@ +# Event ingestion benchmarks + +`Exceptionless.Benchmarks` contains allocation-aware microbenchmarks for the V2 +parser core (UTF-8 bytes to string, JSON-shape detection, and +`PersistentEvent`/`PersistentEvent[]` deserialization) and the exact production +V3 `EventIngestionV3StreamReader`. The V3 results separately measure bounded +NDJSON framing plus the routing-only projection used by early discard, and the +same path followed by full DTO materialization for survivors. These parsing +microbenchmarks do not include HTTP, decompression, validation, routing I/O, +stack creation, persistence, queues, or side effects; they cannot support an +end-to-end throughput or cost claim by themselves. + +Run a short comparison with: + +```bash +dotnet run -c Release --project benchmarks/Exceptionless.Benchmarks -- --job short +``` + +## Apples-to-apples V2 and V3 load comparison + +`Exceptionless.Ingestion.Load` submits the same conceptual event corpus through +the real V2 and V3 HTTP endpoints. It records four boundaries: + +- **Submission** starts before the first request and stops when the final + successful response headers arrive. V2's response is only a `202 Accepted`; + V3's response contains terminal primary-write counts. +- **Observed full processing** is the common semantic terminal boundary, but its + benchmark instrumentation is intentionally visible in every result. V2 + requests opt into tracking, capture the queue-entry correlation id returned in + `X-Exceptionless-Event-Post-Id`, and follow any per-event child retries through + the legacy pipeline and final queue/archive cleanup. V3 polls the existing + final `notifications` side-effect marker for every persisted survivor, after + archive, enrichment, statistics, and notification/webhook enqueueing have + succeeded. A 100%-discard V3 run has no survivor side effects, so its durable + acknowledgement is also its full processing boundary. +- **V3 durable acknowledgement** is V3-only. It is the successful response after + the primary event and durable side-effect intent are written. V2 has no + equivalent client-visible durable-event acknowledgement. +- **Query visibility** uses the same project event-count query and run tag for + both protocols. It is reported as persisted survivors per second and includes + the read/index visibility delay. It is `n/a` for a 100%-discard corpus because + there are intentionally no documents to query. + +The V2 request path returns the existing enqueue id and performs no synchronous +tracking-cache I/O, so the submission boundary does not include a tracking-cache +write. Its worker does perform opt-in Redis tracking writes while processing. +V3 adds no completion writes: the status endpoint reads the final idempotency +markers that normal side-effect execution already records. Thus the two +full-processing observers establish the same terminal meaning but do not have +equal instrumentation cost: V2 tracks one identifier per HTTP request while V3 +tracks one identifier per persisted event. The harness limits status polling to +`--completion-poll-concurrency` (default 4), removes completed 1,000-identifier +chunks from later sweeps, and records tracked identifiers, status requests, +identifier reads, and sweeps in every trial. A V3 result with many events per +request can still have substantially more observer work and observation lag than +V2; do not attribute an observed-full-processing difference to pipeline +efficiency without these counters and server telemetry. + +The completion endpoints are benchmark-oriented, require +`EventIngestionV3:EnableProcessingStatus=true`, and markers expire with +`EventIngestionV3:IdempotencyWindow`, so polling must occur as part of the run. +Submission, observed full processing, and query visibility are reported on the +same corpus. Submission and query visibility have the closest apples-to-apples +observers. Observed full processing is a useful terminal semantic check with the +instrumentation caveat above; V3 durable acknowledgement is reported separately +because V2 has no equivalent contract. + +Run separate one-event and large-batch scenarios. `--batch-size 1` sends one JSON +object per V2 request and one NDJSON line per V3 request. A larger batch sends +one V2 JSON array or a V3 stream with exactly one object per NDJSON line. The +load client writes both forms incrementally and does not construct a giant +in-memory array. + +Each generated event has a unique `reference_id`, matching production duplicate +detection semantics. A unique tag shared by the run is used only to poll durable +query visibility. + +```bash +export EXCEPTIONLESS_API_KEY=... +export EXCEPTIONLESS_PROJECT_ID=... +export EXCEPTIONLESS_READ_TOKEN=... + +# One event per request. +dotnet run -c Release --project benchmarks/Exceptionless.Ingestion.Load -- \ + --base-url https://api-ex.dev.localhost:7111/ --protocol both \ + --events 10000 --batch-size 1 --concurrency 32 --trials 5 \ + --event-type error --stack-scenario hot --signature-cardinality 100 \ + --compression gzip --seed single-error \ + --results artifacts/ingestion-single-error.json \ + --environment-label "1 API; 1 job; local Elasticsearch and Redis" + +# Large real-world client batches. +dotnet run -c Release --project benchmarks/Exceptionless.Ingestion.Load -- \ + --base-url https://api-ex.dev.localhost:7111/ --protocol both \ + --events 100000 --batch-size 1000 --concurrency 8 --trials 5 \ + --event-type error --stack-scenario hot --signature-cardinality 100 \ + --compression gzip --seed batch-error \ + --results artifacts/ingestion-batch-error.json \ + --environment-label "1 API; 1 job; local Elasticsearch and Redis" +``` + +Instead of `EXCEPTIONLESS_READ_TOKEN`, a local comparison can use +`--read-user --read-password `. The read credential is needed +only to poll the project count endpoint and is never printed. + +The default warmup sends 100 events through each selected protocol and waits for +observed full processing before measurement; odd trials reverse the protocol +order. `--stack-scenario hot` uses stable, protocol-specific signature +namespaces for warmup and every trial. +`--stack-scenario new` uses a fresh namespace for each protocol and trial while +still allowing a separate warmup namespace to warm JIT, HTTP, and datastore +paths. This prevents one protocol from creating or warming the other protocol's +measured stacks. Use `--event-type log` to isolate general pipeline overhead and +`--event-type error` to exercise real stack processing. `none` and `gzip` are +the encodings common to both APIs and therefore the only comparison choices. + +Discard comparisons require `--stack-scenario hot`. First run both protocols +with the intended seed and discard percentage but override +`--expected-persisted` to the full event count, then mark the generated +`Load.hot.DiscardedException*` stacks discarded. Run the measured +comparison with the same seed and omit `--expected-persisted`; the harness +derives it from `--discard-percent`. V3 reports terminal outcome counts, while +the observed-full-processing boundary proves that discard-only requests finished +without waiting for markers that intentionally do not exist for discarded +events. + +Every result includes request count, payload bytes, common submission and +observed-full-processing events/second, query-visible persisted events/second, +request latency p50/p95/p99, completion-observer counters, query poll count, V3 +durable acknowledgement, and V3 terminal counts. Pass +`--results artifacts/ingestion-v3.json` to write a machine-readable, secret-free +artifact containing every trial, sanitized configuration, runtime/OS/CPU +metadata, and the build informational version. Use `--environment-label` for +the Elasticsearch/Redis topology and API/job instance counts. Publish the JSON +artifact with the exact commit and do not report a comparison from warmup output +or from a single trial. + +The load client records payload bytes and client-observed timings, not server +CPU, allocation rate, GC collections, Redis/Elasticsearch operation counts, or +cost. Collect those from the API, job, Redis, and Elasticsearch telemetry for +the same run before making efficiency or cost-per-million claims. diff --git a/docs/event-ingestion-v3-architecture.md b/docs/event-ingestion-v3-architecture.md new file mode 100644 index 0000000000..1b7a49692f --- /dev/null +++ b/docs/event-ingestion-v3-architecture.md @@ -0,0 +1,514 @@ +# Event ingestion V3 architecture + +This document records the implementation decisions and performance baseline +methodology for [issue #2368](https://github.com/exceptionless/Exceptionless/issues/2368). +It is deliberately separate from the public API documentation until the V3 +contract is ready for rollout. + +The companion [client protocol and ecosystem plan](/event-ingestion-v3-client-protocol/) +defines the minimum sender, capability profiles, Sentry and Raygun comparison, +and compatibility rules for future event and non-event telemetry. + +## Objectives + +Event ingestion V3 is a breaking, performance-first API with these invariants: + +- ASP.NET Core Minimal APIs own the HTTP boundary. V3 does not use an MVC + controller or MVC input formatter. +- Clients send independent JSON objects as they capture events. Each nonblank + NDJSON line contains exactly one object; adjacent objects on one line are + invalid, and only the final line may omit its newline. Clients never construct + a top-level JSON array. +- The request body is consumed incrementally from a `PipeReader` with + source-generated System.Text.Json metadata. +- The API processes bounded microbatches inline. A successful response means + that every acknowledged event is durable or reached another terminal success + state such as discarded or duplicate. +- The primary event path does not write the request to object storage and does + not enqueue it for another process to deserialize. +- Clients send raw stack traces. The server computes the grouping fingerprint + and builds the persisted structured error. +- A new language can implement the core sender using only its standard HTTPS + and JSON facilities. Advanced reliability and framework features are + independently testable capability profiles, not ingestion prerequisites. +- Events assigned to discarded stacks terminate before billable admission, + full error materialization, event persistence, stack statistics, or side + effects. +- V3 correctness is scale-out safe and never depends on sticky sessions or + process-local authoritative state. +- V2 remains contract-compatible. V3 does not pay a compatibility tax to share + an implementation with V2. + +## Current V2 flow + +The V2 path is intentionally durable before processing, but that durability +requires the payload to cross several boundaries: + +1. `EventController.PostAsync` resolves the project and wraps the request body + with `EventPostRequestBodyStream`. +2. `EventPostService.SaveAndEnqueueAsync` writes the payload to file storage and + enqueues an `EventPost` pointer. +3. `EventPostsJob` downloads the complete payload into a byte array. +4. Compressed payloads are expanded into a second complete byte array. +5. The job parses the complete body into a collection of `PersistentEvent` + objects. +6. `EventPipeline` runs a reflection-discovered action list. +7. `PipelineBase` creates a filtered context list for every action. +8. `AssignToStackAction` resolves or creates stacks and only then detects the + discarded status. +9. `SaveEventAction` persists events, followed by statistics, notifications, + counters, and processed plugins. + +Plan admission occurs before stack assignment in both `OverageMiddleware` and +`EventPostsJob`. A request can therefore be rejected before the system learns +that its events belong to a free discarded stack. + +## V3 processing boundary + +The V3 endpoint is a transport adapter over an explicit, batch-oriented Core +processor. For each bounded microbatch, the processor performs these steps in +order: + +1. Validate only the small identity and grouping envelope. +2. Compute canonical stack fingerprints. +3. Resolve unique stack routes in bulk. +4. Terminate known-discarded events and record an aggregate discard count. +5. Validate optional context for survivors and detect durable duplicates. +6. Apply retention and fixed-version nonbilling rules to new events. +7. Reserve billable quota for remaining new events only. +8. Materialize complete events and structured errors. +9. Resolve or create missing stacks. +10. Persist events and durable side-effect intent in bulk. +11. Commit or release billable reservations. + +Each API replica applies two independent concurrency boundaries. A relatively +high active-stream guard bounds open HTTP requests for the replica and for each +organization. An idle stream holds only that stream permit; it does not consume +scarce processing capacity. When a microbatch is ready, the endpoint stops +reading and waits for the lower per-organization and global processing limits +before it calls the Core processor. This bounds memory and lets HTTP/TCP flow +control apply backpressure without letting slow producers starve ready work. +Active-stream admission is deliberately two-stage: a global permit is acquired +before the raw request-body limit is relaxed, then the endpoint acquires an +organization permit keyed by the organization of the authorized, routed project. +The second stage does not consume another global permit, and explicit-project +requests are never attributed to an unrelated default organization. + +Both processing queues are bounded and ordered oldest first, with the +organization boundary applied before global admission. A full queue returns +`429`; if earlier microbatches in the same request are already durable, the +normal problem response includes their partial result and replay guidance. +Reading and processing may overlap through a channel with capacity one only if +benchmarks demonstrate a material benefit. + +## Decision: inline acknowledgement + +V3 acknowledges after the primary event write, not after a queue write. This +removes file-storage I/O, queue I/O, worker scheduling, a payload download, and +a second deserialization from the normal path. + +If durable storage is unavailable, V3 fails quickly with a retryable response. +It does not enqueue after processing begins because a partially successful +write makes that fallback ambiguous. A separate, explicit buffered mode is +required if the product must acknowledge while the event store is unavailable. + +## Decision: bounded stream segments + +An indefinitely open upload cannot provide a reliable acknowledgement boundary. +Official clients therefore stream events immediately into a request segment and +close the segment when a configured count, byte, or elapsed-time limit is +reached. The client retains only the unacknowledged segment and replays it after +a connection loss or retryable response. + +This is streaming rather than array batching: each event is serialized once, +written immediately as one NDJSON line, and followed by a newline before the +next event. The final newline is optional; concatenating adjacent JSON objects +without a line delimiter is invalid. + +## Decision: bounded idempotency with date-routable identities + +Every V3 event carries a stable client event identifier. The server derives a +date-routable storage identity from the project, client identifier, and supplied +event date, then uses create-only persistence semantics. This normal SDK path is +stateless and adds no per-event cache write. When `date` is omitted (or must be +clamped because it is in the future), the server instead atomically claims the +identity in a distributed mapping. Each mapping expires independently at the +configured idempotency window, keeping storage bounded, and preserves the exact +canonical event date and first receipt time so a retry across midnight cannot +route the write and lookup to different indexes. Benchmark processing-status +mode also claims mappings because its read-only endpoint accepts client ids, +not complete events. A duplicate create is a terminal success. + +If a response is lost after persistence, the client replays the complete segment +with the same identifiers. Previously stored events become duplicate successes; +new events continue normally. Side-effect intent uses the same deterministic +identity. Durable queue markers suppress normal notification and webhook replay; +delivery remains at-least-once in the narrow case where a queue accepts an item +but its deduplication marker cannot be recorded. + +## Decision: discarded-stack fast path + +Discard classification requires the same canonical fingerprint used by stack +assignment. V3 computes that fingerprint from a minimal event representation +before building the complete `PersistentEvent`, `Error`, and `StackFrame` +object graph. + +Stack routing uses a lightweight value containing stack identifier, status, and +a cache schema version. Resolution is deduplicated within the microbatch, read +from the distributed cache in bulk, and falls back to a projected repository +query for cache misses. Cold fills and authoritative mutations take one +project-generation lock and compare/write all affected entries in bulk, avoiding +a distributed lock and cache command chain for every distinct signature while +still preventing an older repository result from overwriting a newer status. + +The distributed cache is authoritative initially. A process-local positive +discard cache is unsafe without proven versioning and invalidation because a +stale entry would silently discard events after a stack is reopened. + +A discarded event performs none of the following: + +- Billable quota reservation. +- Full error or stack-frame materialization. +- Event indexing. +- Stack occurrence updates. +- Notification, webhook, archive, enrichment, or outbox writes. + +Discarded events still count toward protective byte, request, and event-rate +limits. Those controls prevent abuse and are not customer billing. + +## Decision: quota reservation and settlement + +The existing read-then-process usage check is not an adequate scale-out +reservation primitive. V3 separates protective admission from billable usage +and uses three logical billable operations: + +- Reserve capacity for non-discarded survivors. +- Commit capacity for successfully persisted events. +- Release capacity for invalid events, duplicates, cancellations, or failed + writes. + +The distributed operation must be atomic across API instances. A customer at +the billable limit can still submit an event assigned to an already-discarded +stack, while active and new-stack events are blocked. + +Availability calculation and the Redis lease are serialized by a short +distributed lock per organization. This prevents a delayed caller from holding +an old availability snapshot until an earlier request has committed usage and +released its lease. Within that decision, Redis admission uses one server-side +atomic operation per microbatch: it purges a bounded number of expired leases, +computes remaining capacity, and records the new lease without scanning active +reservations. Active leases remain organization-scoped across bucket and finite +plan-limit changes until commit, release, or expiry. This can conservatively +delay admission for work crossing a boundary, but it prevents that in-flight +work from disappearing and reusing the same monthly capacity. Unrelated +organizations and replicas continue concurrently; the in-memory implementation +provides the same lease semantics for single-process development and tests. +The leases coordinate V3 admissions. Legacy V2 workers retain their existing +read-then-process quota check during rollout, so a mixed V2/V3 deployment also +retains V2's pre-existing concurrent oversubscription window until that worker +is deliberately migrated to the reservation primitive. + +Billable settlement is deliberately at-most-once. The batch writer that gets a +successful result from the create-only event write owns the settlement; a +persisted duplicate only repairs idempotent side effects and is never charged +again. Usage is added to scalar five-minute organization and project counters, +so admission reads and settlement writes remain O(1) and Redis does not retain +one key or set member per event. + +An ambiguous Elasticsearch bulk failure cannot prove which racing request +created a document. V3 therefore skips settlement for every ambiguous result +rather than risk charging twice. This narrow crash window can undercount but +cannot overcharge. `ex.ingestion.v3.usage.committed` and +`ex.ingestion.v3.usage.ambiguous_skipped` expose the tradeoff; a future offline +Elasticsearch reconciliation job may repair undercounts without adding a +per-event ledger to the ingestion hot path. + +The periodic usage saver acknowledges organization and project discovery ids +individually. It persists the usage document before recording a processed +bucket marker, then removes the scalar counters and finally removes that one +discovery id. A failure leaves the failed id and every unvisited id available +for retry. If persistence and the marker succeeded but cleanup failed, the +retry observes the marker and completes cleanup without applying the bucket a +second time. The last applied bucket is also persisted atomically with the +organization or project totals. A process failure after the repository commit +but before the Redis marker write therefore retries only the Redis +acknowledgement and cleanup; it cannot apply that bucket twice. Legacy documents +without the field apply their first pending bucket normally and establish the +durable marker on that save. + +## Decision: side-effect durability + +The primary event is authoritative. Notifications, webhooks, archive export, +nonessential geolocation, and repairable derived statistics remain outside the +request critical path. + +The inline writer persists deterministic side-effect or repair intent before +acknowledgement. Derived statistics and internal stages are idempotent; +notification and webhook enqueueing uses durable duplicate suppression while +retaining at-least-once failure semantics. Partial bulk outcomes are reconciled +by deterministic identifiers rather than by placing the original event payload +back on the ingestion queue. + +The retry-state budget is explicit: statistics and terminal notification +completion share one integer bitmask key per persisted event for the configured +idempotency window. Stack statistics use one project-partitioned atomic Redis +settlement per microbatch: the script claims previously unseen event identities, +updates every affected stack aggregate, and sets the statistics bits together. +Projects hash independently across Redis Cluster slots. Overlapping or +retried batches therefore cannot increment an occurrence twice, and the handler +does not issue a serial command chain per stack. Notification and webhook queue +markers exist only for events that actually trigger those effects. Thus one +million persisted events retain one million expiring side-effect state keys, not +one key per stage. Rollout measurements must include Redis bytes per state key, +script duration, lock commands, and key-expiration churn; a compact sharded-hash +store is the next design step if that measured footprint misses the +cost-per-million target. + +## V2 compatibility boundary + +V2 retains its routes, payloads, 202 response, controller, and durable queue. +Shared services are extracted below the transport boundary: + +- Canonical fingerprinting. +- Stack route resolution. +- Server-side stack parsing. +- Batch stack resolution and creation. +- Batch event persistence. +- Usage accounting primitives. + +The V3 request and acknowledgement path must not instantiate V2 queue models, +plugin contexts, or compatibility converters. The asynchronous side-effect +worker may temporarily adapt persisted events to the existing enrichment and +notification actions while those actions are extracted into transport-neutral +services; it does not run the legacy preprocessing or post-processing plugin +pipeline. V2 may adopt a shared service only when its compatibility fixtures +remain unchanged and the change is neutral or positive in measurements. + +## Baseline methodology + +All comparisons use the same commit, machine, runtime configuration, +Elasticsearch topology, Redis topology, payload corpus, compression setting, +and client concurrency. Record the following environment information with every +result: + +- Commit SHA and configuration. +- .NET SDK and runtime versions. +- Operating system and processor topology. +- Elasticsearch and Redis versions/resources. +- Number of API and job instances. +- Payload size distribution and stack-signature cardinality. +- Client concurrency and connection reuse. + +Measure V2 and V3 separately for these scenarios: + +- One hot active stack. +- High-cardinality active stacks. +- New stacks. +- 0%, 10%, 50%, 90%, and 100% discarded events. +- Small, median, large, and maximum events. +- Compressed and uncompressed requests. +- Healthy, slow, and unavailable Elasticsearch. +- One, two, four, and eight API instances. + +Capture: + +- Events per second and bytes per second. +- CPU time per event. +- Allocated bytes per event and Gen 0/1/2 collections. +- Working set and managed heap under a long stream. +- p50, p95, and p99 time from final event byte to durable acknowledgement. +- File-storage, queue, cache, and Elasticsearch operations per event. +- Stack-route cache hit and miss rates. +- Cost per million persisted and discarded events. + +## Performance gates + +- No request-sized byte array, string, `MemoryStream`, `JsonDocument`, or + top-level event collection on V3. +- No large-object-heap allocation caused by normal stream framing or microbatch + growth. +- Memory remains bounded by the active-stream guard and microbatch limits during + long or slow streams. Idle streams do not reserve processing permits. +- A discarded event creates no complete error/frame graph and performs no event + index write. +- At most one distributed route lookup per unique signature per microbatch. +- The normal V3 path performs no object-storage payload write/read and no + primary ingestion queue operation. +- Scaling from one to four API instances reaches at least 80% efficiency until + the shared datastore is the measured bottleneck. +- Rollout evidence must demonstrate lower V3 CPU, allocation rate, and + time-to-durable-event relative to V2 before those improvements are claimed. + Numeric reduction targets are set from the repeatable baseline rather than an + unmeasured estimate. + +## Verification requirements + +Completion requires all of the following evidence: + +- Unit tests for contracts, fingerprinting, parsing, routing, quota settlement, + idempotency, and result aggregation. +- Integration tests for chunked NDJSON, compression, authentication, limits, + discard behavior, persistence, replay, and failure responses. +- Multi-instance tests for route status changes and quota reservation. +- V2 serialization audit results before and after shared-service adoption. +- OpenAPI V3 baseline and HTTP samples. +- Language-neutral client specification, golden fixtures, black-box conformance + tests, and timed new-language implementation evidence meeting the client + effort gates. +- Microbenchmark reports and end-to-end load results for the scenario matrix. +- A staged rollout through the internal project and selected projects with a + documented rollback switch. + +## Implemented API and client behavior + +V3 is exposed through `POST /api/v3/events` and +`POST /api/v3/projects/{projectId}/events`. Both are Minimal API routes and +require a client-scoped bearer token. The request content type is +`application/x-ndjson`; each nonblank line contains exactly one complete event +object. Lines may span arbitrary network writes, CRLF is accepted, and the final +newline is optional. Adjacent objects on one line and top-level arrays are +rejected. The routes accept identity, gzip, and Brotli content encoding. + +Clients must generate one stable `id` per captured event and reuse it whenever +the containing segment is replayed. On first receipt, the server atomically +derives a project-scoped, date-routable persisted identifier from `id` and the +original event `date`. This path is stateless, so clients must retain both fields +unchanged for retries. When `date` is omitted, a bounded distributed mapping +preserves the first receipt time for the configured idempotency window, +including retries that cross a UTC day boundary. `reference_id` remains a +separate business identifier and is not transport idempotency. + +Clients write events to the HTTP request as they occur, but close and reopen the +segment at a bounded count, byte size, elapsed time, flush, or shutdown +boundary. They retain only the unacknowledged segment. A connection loss, 5xx, +or timeout replays the complete segment with the same event ids. A 200 response +returns terminal counts for `persisted`, `discarded`, `duplicate`, `blocked`, +and `invalid`. Earlier microbatches may already be durable when a later part of +the segment produces a request-level failure; replay remains safe. + +Error clients send `exception_type` and the original `stack_trace`. The server +parses .NET, Java, JavaScript, and Python frame forms, preserves caused-by +chains, and stores structured frames. Unsupported formats use a normalized, +hashed raw-trace fallback and increment a parser-fallback metric without logging +the trace. Optional `stacking.signature_data` and `stacking.title` provide +explicit manual stacking. Manual signature data is canonicalized by ordinal key +and hashes an unambiguous encoding of both keys and values, making grouping +independent of JSON property order across client languages. + +Only `id` and `type` are required. Maintained SDKs should also identify +themselves with `client.name` and `client.version`; applications can send +first-class `version` and `level` values without knowing internal event data +keys. Unknown event properties are ignored so additions remain compatible. +Top-level custom `data` keys beginning with `@`, plus legacy `sessionend` and +`haserror` keys, are reserved and rejected. This prevents custom JSON from +bypassing the typed request redaction and project PII policy or overriding +first-class grouping and metadata. Runtime validation uses the same durable +limits advertised by OpenAPI: 2,000-character messages, 100-character tags, +1,000-character manual stack titles, and reference identifiers containing 8-100 +letters, digits, or hyphens. +V3 initially accepts error, log, usage, and custom types. It explicitly rejects +the legacy-only 404 and session lifecycle types until their stateful V2 +preprocessing has a native V3 design, avoiding silent semantic drift. + +`/api/v3/events` remains an event-only NDJSON stream. It will not acquire a +per-line kind/payload wrapper or a stream header. A future optional, +length-delimited advanced transport can carry the exact same event JSON beside +attachments, minidumps, profiles, or other typed items. This keeps the minimum +client smaller than Sentry's envelope implementation without forcing binary or +heterogeneous data into the event object. + +The executable reference client and repeatable load harness live in +`benchmarks/Exceptionless.Ingestion.Load`; usage is documented in +`benchmarks/README.md`. It submits the same conceptual corpus through the real +V2 and V3 endpoints and records four explicitly named boundaries: submission +response, observed full processing, V3 durable acknowledgement, and query +visibility of persisted survivors. Observed full processing follows V2 through +its legacy pipeline, correlated child retries, and final queue/archive cleanup. +For V3 it waits for the existing final `notifications` marker, which is recorded +only after archive, enrichment, statistics, and notification/webhook enqueueing +have succeeded. Query visibility remains a second common downstream comparison +for persisted survivors. + +V2 tracking uses per-post correlation rather than global queue depth or sentinel +events, so mixed and 100%-discard runs are measurable. The controller returns +the existing V2 queue entry id without synchronous tracking-cache writes; the +worker lazily creates and updates the opt-in status while processing. V3 status +resolves persisted ids from the existing distributed idempotency mapping and +only reads markers that normal side-effect idempotency already writes, adding no +ingestion write or extending the mapping lifetime. These read-only status +contracts are benchmark-oriented and are disabled unless +`EventIngestionV3:EnableProcessingStatus` is explicitly enabled; this prevents +untrusted V2 clients from creating tracking keys in normal deployments. V3 +markers expire with the configured idempotency window. The status routes are +also excluded from public OpenAPI documents so generated SDKs do not treat +benchmark instrumentation as product API. Warmup also waits for full processing +so its side-effect backlog cannot contaminate a measured trial. Both protocols +use the same read-count query. Status polling is bounded, completed chunks are +not polled again, and each trial records the exact observer load. The observer +cardinality is nevertheless different—V2 has one status identifier per request +and V3 has one per persisted event—so observed full-processing time is a +conservative terminal check, not an unqualified measure of pipeline CPU or cost. +Submission and query visibility have the closest apples-to-apples observers; +efficiency claims additionally require correlated server, Redis, and +Elasticsearch telemetry. +The harness supports one-event requests and large client batches, isolates hot +and new stack identities by protocol, alternates protocol order, reports median +multi-trial results, and can emit a secret-free JSON evidence artifact. +Request examples live in `tests/http/events-v3.http`, and the isolated OpenAPI +document is available at `/docs/v3/openapi.json`. + +## Configuration and rollout + +`EventIngestionV3:Enabled` defaults to `false`. Rollout can be constrained with +`AllowedProjectIds` and `AllowedOrganizationIds`; empty sets allow every +authenticated project. Other controls are: + +- `MicroBatchSize`, `MaximumMicroBatchBytes`, and `MaximumEventSize`. +- `MaximumCompressedBodySize`, `MaximumDecompressedBodySize`, and + `MaximumEventsPerRequest`. +- `MaximumActiveStreams` and `MaximumActiveStreamsPerOrganization` bound open + streaming requests. Their defaults are the greater of eight times the + corresponding processing limit or 128 globally and 32 per organization. + `ActiveStreamQueueLimit` and `ActiveStreamQueueLimitPerOrganization` are + configurable and default to zero so excess slow connections fail promptly. +- `MaximumConcurrentRequests` and `ConcurrencyQueueLimit` bound simultaneous + microbatch processor calls globally. `MaximumConcurrentRequestsPerOrganization` + and `ConcurrencyQueueLimitPerOrganization` provide fair organization admission. + These processing permits are acquired only while a full microbatch executes. +- `MaximumStackCreationConcurrency`. +- `RequestTimeout` and `IdempotencyWindow`. +- `EnableProcessingStatus` for opt-in benchmark completion endpoints and V2 tracking. +- `StackRouteCacheDuration` and `NegativeStackRouteCacheDuration`. + +All stream and processing concurrency limits are per API replica, not cluster +wide. Scaling out therefore adds API-side processing capacity with each replica +while retaining bounded request and queued work on every node; Redis, +Elasticsearch, and downstream workers remain shared bottlenecks that must be +verified with the multi-instance performance gate above. + +Start with the internal Exceptionless project in the allowlist, compare V2 and +V3 stack signatures, terminal counts, usage, route-cache metrics, allocation +profiles, and datastore saturation, then expand the allowlists gradually. +Rollback is immediate: disable `EventIngestionV3:Enabled` and have clients use +the unchanged V2 endpoint. V2 retains its controller, queued payload handoff, +payload formats, and 202 response. Shared distributed quota primitives and +stack-route cache lifecycle improvements do not change that contract. + +## Durable side effects + +The primary event and deterministic side-effect work item are required before +acknowledgement. The background handler performs stack-usage repair, +request/environment/geolocation enrichment, notification and webhook enqueueing, +and deterministic archive export when archival is enabled. Work-item, +notification, and webhook identities are stable for the configured idempotency +window. Stack-statistics completion and aggregation are one atomic operation; +there is no interval in which the count has changed but its event identity is +still unclaimed. Other explicit idempotency stages acquire distributed +per-event locks, recheck completion after acquiring them, run the pending +effects, and record completion only after the stage succeeds. A failed or +abandoned worker cannot poison a retry with a premature claim. Externally +visible notification delivery remains at least once and its handlers must be +idempotent. V3 deliberately does not run the legacy post-processing plugin +pipeline: those plugins were written against V2 semantics and are not a safe +compatibility boundary for the new materializer. Retries repair pending work +from the authoritative event documents. diff --git a/docs/event-ingestion-v3-client-protocol.md b/docs/event-ingestion-v3-client-protocol.md new file mode 100644 index 0000000000..717830eade --- /dev/null +++ b/docs/event-ingestion-v3-client-protocol.md @@ -0,0 +1,411 @@ +# Event ingestion V3 client protocol and ecosystem plan + +This document defines the client-authoring contract for event ingestion V3. It +also records the Sentry and Raygun research that shaped the protocol. The +primary design metric is not only server throughput: a competent developer in a +new language must be able to build a correct basic sender without copying an +existing Exceptionless SDK or implementing an observability framework. + +## Success criteria + +A basic client must need only standard platform facilities for HTTPS, JSON, +UTF-8, time, and random identifiers. It must not need to: + +- Parse a stack trace into frames. +- Construct an error/inner-error object graph. +- Implement an envelope or multipart grammar. +- Buffer a JSON array before sending. +- Download project configuration before its first event. +- Implement tracing, scopes, breadcrumbs, sampling, symbolication, offline + storage, or framework integrations before it can report an error. +- Know Exceptionless persistence data keys such as `@error` or `@environment`. + +The minimum useful error event is therefore: + +```json +{"id":"018f5f5e-8f6d-7a30-bf5b-9a10b0c4d6e7","type":"error","message":"checkout failed","exception_type":"PaymentError","stack_trace":"PaymentError: checkout failed\n at charge (/app/payments.js:42:7)"} +``` + +The client writes a newline before writing the next event. Every nonblank line +contains exactly one JSON object; adjacent objects on the same line are invalid. +The final event may omit its trailing newline. The server owns stack parsing, +canonical grouping, discarded-stack detection, quota admission, and persistence. + +Client effort is a release gate. Before the public contract is frozen, run a +timed implementation exercise with developers who have not worked on V3: + +- First accepted log from the written specification in 30 minutes or less. +- First accepted handled error with the native raw stack in 60 minutes or less. +- A Profile 0 reference sender in 200 non-generated source lines or fewer in a + language with a standard HTTP and JSON library, excluding tests and examples. +- No third-party runtime dependency required for Profile 0. +- No Exceptionless-specific concept beyond endpoint, token, event, segment, and + terminal result required for Profile 0. +- The same event serializer is reused unchanged when reliable buffering or the + future advanced transport is added. + +Failure of one of these gates is a protocol usability defect, not merely a +documentation issue. + +## Minimum implementation surface + +A dependency-free client has five mandatory responsibilities: + +1. Accept a server URL and project client token. +2. Generate a stable event `id` and preserve it for retries. +3. Serialize an object containing `id` and `type`; error clients add the raw + `stack_trace` and normally `message` and `exception_type`. +4. POST UTF-8 JSON objects with exactly one object per nonblank line and + `Content-Type: application/x-ndjson` and `Authorization: Bearer `. +5. Close the bounded request segment, read the terminal response, and replay + the same ids after a timeout, connection failure, or retryable HTTP status. + +No batching abstraction is required. A first implementation may open a request +for one event at a time. A production client can reuse a connection and keep a +request segment open while events arrive, closing it on the first configured +count, byte, age, flush, or shutdown limit. + +The API defaults omitted `date` to server receipt time. This keeps the first +sender small, although production clients should send the capture time. The API +does not require `client`, `version`, `level`, request, environment, user, or +custom data. + +## Capability profiles + +The ecosystem should describe clients by capabilities instead of treating a +large official SDK as the minimum viable implementation. + +### Profile 0: core sender + +- Required fields, raw stack traces, token authentication, and NDJSON. +- One-event requests are valid. +- Stable ids and safe whole-segment replay. +- Terminal response handling and bounded event size. + +This profile is enough for a community integration to be listed as an +Exceptionless-compatible sender. + +### Profile 1: reliable transport + +- Connection reuse and bounded streaming segments. +- Count, byte, age, explicit flush, and shutdown segment boundaries. +- Bounded in-memory queue with a documented overflow policy. +- Exponential backoff with jitter and `Retry-After` support. +- Optional gzip or Brotli request compression. +- Optional bounded disk persistence for unacknowledged segments. +- Flush with a caller-provided deadline. + +Reliability is deliberately independent from event construction so the same +transport can be reused by framework-specific packages. + +### Profile 2: platform SDK + +- Unhandled exception integration appropriate to the platform. +- First-class application `version`, severity `level`, and SDK `client` + metadata. +- Safe request, user, and environment capture with PII controls. +- Optional breadcrumbs, trace correlation, source context, and platform-aware + diagnostics as those contracts become available. +- A before-send hook that can mutate or discard an event. + +### Profile 3: framework integrations + +- Thin adapters for web frameworks, logging systems, job processors, desktop + UI frameworks, and mobile lifecycle APIs. +- Framework packages depend on the platform SDK; they do not reimplement the + wire protocol or retry queue. + +## First-class event fields + +Only `id` and `type` are required. Optional fields are additive and a server +must ignore unknown fields so an upgraded client can continue sending to an +older V3 server. + +The launch contract accepts `error`, `log`, `usage`, and stable custom event +types. The legacy stateful types `404`, `session`, `sessionend`, and `heartbeat` +must continue using V2 until their preprocessing semantics have native V3 +contracts; V3 rejects them per event instead of silently changing grouping or +session behavior. + +| Concern | V3 field | Client work | +| --- | --- | --- | +| Idempotency | `id` | Generate once and retain through retries; replay the same `date` too when supplied. | +| Classification | `type` | Use a known type or a stable custom type. | +| Capture time | `date` | RFC 3339 timestamp; server time is the fallback. | +| Display | `source`, `message`, `value`, `tags` | Optional scalars and string tags. | +| Application release | `version` | Optional release/build version. | +| Severity | `level` | Optional platform severity name. | +| SDK identity | `client.name`, `client.version` | Recommended for maintained SDKs. | +| Error | `exception_type`, `stack_trace` | Send the runtime's original text. | +| Grouping override | `stacking` | Advanced opt-in only. | +| Context | `user`, `request`, `environment` | Optional typed objects. | +| Custom extension | `data` | JSON object for user-defined values within documented limits. | + +The launch limits match the values that can be stored without truncation or +filtering: + +| Field | Limit | +| --- | --- | +| `id` | 1-100 characters. | +| `type` | 1-100 characters. | +| `source`, `message` | 1-2,000 characters when present. | +| `reference_id` | 8-100 letters, digits, or `-`. | +| `tags` | At most 50 nonblank values, each at most 100 characters. | +| `stacking.title` | 1-1,000 characters when present. | +| `stacking.signature_data` | 1-100 entries; keys are 1-255 characters and values must be non-null and at most 16 KiB. | +| Object metadata | At most 2,000 JSON values, 32 levels deep, with property names at most 255 characters and string values at most 64 KiB. | + +When `stacking` is supplied, `stacking.signature_data` must contain at least one +key/value pair. The server canonicalizes pairs by ordinal key and hashes both +length-delimited keys and values, so JSON property order never changes grouping +and distinct keys cannot collide merely because their values match. + +`client.name` should be stable and ecosystem-wide, for example +`exceptionless.go`, and `client.version` should be the SDK package version, not +the application version. The server maps these fields to submission-client +metadata. This is necessary for compatibility analysis, rollout targeting, and +support without requiring every language to emulate a historical User-Agent. + +`data` is an escape hatch for user data and experiments, not a second route to +first-class event metadata. Top-level keys beginning with `@` are reserved by +Exceptionless, as are the legacy state keys `sessionend` and `haserror`; V3 +rejects them case-insensitively. This prevents custom data from bypassing typed +request redaction, project PII settings, grouping, or other first-class +semantics. These names remain valid inside a nested custom object because only +the event data object's top level can collide with persisted event metadata. +Frequently used, searchable, billable, grouping-sensitive, or +security-sensitive semantics graduate to documented optional fields. + +## Comparison with Sentry + +Sentry's current event transport packs event JSON into an Envelope sent to +`/api/{project_id}/envelope/`. An envelope contains a JSON header, then one or +more items, each with another JSON header and a payload. Payloads containing +newlines require byte lengths. The protocol can combine events with binary +attachments, batch selected item types, persist envelopes offline, and carry +new item types. Implementations must handle unknown item types and attributes. +See Sentry's official [Envelope specification](https://develop.sentry.dev/sdk/foundations/transport/envelopes/). + +Sentry's event itself is a broad, evolving schema. Exceptions are a list of +structured values with type/value, mechanism, thread association, and a +structured stack trace; chained exceptions have defined ordering and tree +metadata. See the official [event payload](https://develop.sentry.dev/sdk/foundations/transport/event-payloads/), +[exception](https://develop.sentry.dev/sdk/foundations/transport/event-payloads/exception/), +and [stack trace](https://develop.sentry.dev/sdk/foundations/transport/event-payloads/stacktrace/) +specifications. + +That design gives Sentry a strong heterogeneous transport, but a new SDK has +substantially more surface area: + +- Parse a DSN into endpoint, project id, and authentication values. +- Generate Sentry-specific ids and event metadata. +- Turn runtime exceptions and causes into the structured exception model. +- Turn runtime stacks into correctly ordered structured frames. +- Serialize envelope and item headers and compute payload byte lengths. +- Categorize item types for rate limits and process Sentry rate-limit headers. +- Queue envelopes, flush a background worker, apply backpressure, report client + drops, and optionally persist offline. +- Add platform integrations, scopes, breadcrumbs, request data, release data, + in-app frames, source context, and tracing for full SDK parity. + +This is visible in Sentry's official [SDK expected-features list](https://develop.sentry.dev/sdk/expected-features/) +and in representative open-source transports such as +[sentry-go](https://github.com/getsentry/sentry-go). The comparison is not that +these features are undesirable. It is that Sentry's definition of a complete +SDK couples a much larger product surface to the client-authoring task. + +Exceptionless V3 is easier for the first event because it has a normal URL and +bearer token, a two-field minimum object, native NDJSON framing, and server-side +stack parsing. Sentry is more future-proof for binary and non-event telemetry +because its envelope has an explicit item-type and length boundary. + +## Comparison with Raygun + +Raygun Crash Reporting uses a conceptually simple HTTPS transport: +`POST https://api.raygun.com/entries`, JSON content, and an `X-ApiKey` header. +However, its documented payload requires `occurredOn`, `details`, an error, and +a stack trace with a line number. The error contains structured stack frames, +and the complete details shape includes client, environment, request, response, +user, tags, custom data, breadcrumbs, version, and grouping data. The maximum +documented single payload is 128 KB. See Raygun's official +[Crash Reporting API](https://raygun.com/documentation/product-guides/crash-reporting/api/). + +The open-source clients show where that work lands. The +[Node client](https://github.com/MindscapeHQ/raygun4node) depends on a stack +parser, converts call sites into line/column/class/file/method frames, recursively +builds inner errors, gathers environment and request context, filters circular +custom data, and then serializes the nested message. Its optional batch +transport joins fully serialized messages into a large JSON array for +`/entries/bulk`. The [Android client](https://github.com/MindscapeHQ/raygun4android) +adds uncaught exception handling, offline caching, device identity, network +state, and obfuscation mapping concerns. The +[.NET client](https://github.com/MindscapeHQ/raygun4net) walks runtime stack +frames and portable executable debug information before submission. + +Raygun is easier than Sentry at the HTTP framing layer, but harder than +Exceptionless V3 for a new error sender because the client must understand and +materialize Raygun's error graph. Raygun's crash schema has evolved through +optional fields and custom data, while other products and browser report types +use separate endpoints. That separation avoids destabilizing crash ingestion, +but it does not provide Sentry's general multi-item transport. + +## Apples-to-apples implementation comparison + +The comparison below is for a new language's first correct handled-error +sender, not for installing an existing SDK and not for full automatic +instrumentation. + +| Responsibility | Exceptionless V3 | Raygun Crash API | Sentry Envelope API | +| --- | --- | --- | --- | +| Endpoint configuration | URL plus token | Fixed/default URL plus API key | Parse DSN and derive endpoint/authentication | +| Minimum event shape | `id`, `type` | `occurredOn`, `details`, error, stack frame line | Envelope header, item header, event id, event JSON | +| Stack input | Original string | Structured frames | Structured frames | +| Chained errors | Server parses common raw forms | Client builds inner-error graph | Client builds ordered exception values/tree | +| Multiple events | Stream one JSON object per NDJSON line | Single documents or buffered JSON array bulk | Multiple envelope items only where item rules allow | +| Binary attachment | Future advanced transport | Not part of crash JSON contract | Native length-delimited attachment item | +| Unknown future fields | Ignored; additive | Optional/custom data | Preserved/ignored according to envelope scope | +| Minimum retry identity | Required stable event id | No equivalent id in documented crash body | Event id | +| Minimum dependencies | Standard HTTPS/JSON | HTTPS/JSON plus stack inspection/parser | HTTPS/JSON plus stack model and envelope framing | + +For a production-grade SDK, all three eventually need bounded buffering, +timeouts, retries, redaction, unhandled-error hooks, and platform integrations. +Exceptionless should publish those as higher profiles instead of blocking the +first client on them. + +## Decision: keep the event endpoint permanently simple + +`POST /api/v3/events` remains a homogeneous stream of event objects. We will +not add a wrapper such as `{ "kind": "event", "payload": ... }` to every line, +and the first line will not become a stream header. Those designs tax every +event and every client for features most clients never use. + +The event object evolves additively: + +- Existing field meanings do not change. +- New fields are optional. +- Unknown fields are ignored. +- Fields may be promoted from experimental `data` conventions to first-class + fields, with a server transition period that accepts both. +- A semantic change that cannot follow these rules requires a new endpoint or + media type, not a `schema_version` switch inside every event. +- Limits and validation errors are documented and machine-testable. + +This gives ordinary event clients the smallest contract while allowing the +event model to grow with optional release, severity, SDK, context, breadcrumb, +trace, thread, module, and advanced error data. + +## Decision: reserve an advanced heterogeneous transport + +Attachments, minidumps, profiles, replays, symbol files, and other binary or +non-event records must not be base64 fields added to the event object. A future +advanced endpoint will use explicit item headers containing at least item type, +content type, and byte length. Unknown item types must be safely skippable. + +The advanced transport must embed the exact V3 event JSON as its `event` item +payload. A client upgrades by adding framing around the serializer it already +has; it does not maintain a second event model. The endpoint can then support +atomic event-plus-attachment submissions and independent limits or billing +categories without changing `/api/v3/events`. + +The advanced transport is intentionally not required for Profiles 0 through 2 +until a client needs a feature that cannot be represented as an event. Its +design should reuse the proven parts of Sentry's envelope—typed items, +byte-length framing, unknown-item handling, and common metadata—without copying +Sentry-specific DSNs, authentication, event schema, or SDK feature requirements. + +## Reserved evolution paths + +The following additions must remain possible without breaking the simple +error path: + +- Advanced exceptions: an optional structured exception collection for + runtimes that can supply cause trees, mechanisms, handled state, native + codes, or pre-parsed frames. `exception_type` and `stack_trace` remain the + preferred simple form; precedence and mutual-exclusion rules must be explicit. +- Breadcrumbs: a bounded optional collection with timestamp, category, level, + message, and JSON data. +- Trace correlation: optional W3C-compatible trace and span identifiers. Full + transaction/span ingestion may use its own item type. +- Threads and modules: optional diagnostic collections, never required to send + an error. +- Deployment identity: additive environment/release/distribution fields that + do not conflict with the existing machine `environment` object. +- Key/value attributes: an indexed bounded scalar map if string `tags` and + arbitrary `data` prove insufficient. +- Client outcomes: an optional aggregate item for locally discarded events, + queue overflow, serialization failure, and rate limiting. + +Names for these fields are reserved in the public specification before SDKs +invent incompatible custom-data conventions. They are implemented only with a +server consumer and cross-language fixtures. + +## Client conformance kit + +The public launch is not complete with OpenAPI alone. NDJSON streaming and +retry semantics need executable, language-neutral evidence: + +1. Publish a short normative wire specification with exact request, response, + status, retry, size, compression, and field-precedence rules. +2. Publish JSON Schema for one event and keep the isolated V3 OpenAPI document. + Explain that OpenAPI's single request schema represents each NDJSON line, + not an array. +3. Publish golden request and response fixtures for minimal log, minimal error, + Unicode, multiline stacks, multiple lines, optional final newline, + same-line adjacent-object rejection, compression, partial invalid + results, duplicates, discarded events, and unknown future fields. +4. Ship a black-box conformance runner. It starts a local receiver, invokes a + client adapter, deliberately drops a response, splits JSON across writes, + returns 429/503, and verifies stable-id replay and bounded flush behavior. +5. Ship a tiny reference transport in pseudocode plus dependency-free examples + in at least JavaScript, Python, Go, Java/Kotlin, Rust, and Swift. Examples + demonstrate the core sender, not a framework-sized official SDK. +6. Give ecosystem packages a machine-readable capability manifest and display + Profile 0/1/2/3 badges in the client directory. +7. Run every maintained client against the same fixtures in CI. A contract + addition lands only after old-client, new-client, and unknown-field cases + pass. + +## Client ecosystem implementation sequence + +1. Freeze the Profile 0 field semantics, status/retry matrix, limits, and + additive-evolution rules. Keep the server's unknown-field test and isolated + OpenAPI baseline as compatibility gates. +2. Extract the current load generator's V3 writer into a small reference + transport whose public surface is event serialization, segment policy, + send, and flush. Do not expose server persistence models. +3. Publish JSON Schema, golden fixtures, and the black-box conformance runner. + Make the runner usable before an SDK repository or package is accepted. +4. Build two pilot clients in unrelated ecosystems, recommended Python and Go, + from the specification rather than by porting the .NET client. Record time, + source lines, dependencies, ambiguities, allocations, and retry defects. +5. Resolve every cross-language ambiguity in the normative specification, then + repeat the timed exercise with Java/Kotlin and Swift or Rust. Do not freeze + the contract based only on .NET and JavaScript ergonomics. +6. Publish the Profile 0 examples and Profile 1 transport guidance. Create a + client directory showing owner, package, supported runtime versions, + capability profile, conformance version, and last successful conformance run. +7. Add official platform integrations only after the shared transport passes + conformance. Keep framework packages thin so transport fixes flow to the + whole ecosystem. +8. Design the optional advanced item transport when the first concrete binary + or heterogeneous feature needs it. Validate that an existing Profile 0 event + serializer can be embedded unchanged before accepting the design. + +## Review gates for future protocol changes + +Every proposal that changes ingestion must answer: + +- Does a Profile 0 sender need to change? If yes, use a new endpoint/media type. +- Can the server compute or default the value more reliably than every client? +- Is the field needed for grouping, discard classification, billing, search, + redaction, or display? If so, it should not remain an undocumented data key. +- Can old servers ignore it and can old clients omit it? +- Does it require a binary payload or a different retention/billing category? + If so, use the advanced item transport. +- What are its byte, count, depth, and privacy limits? +- Are field precedence and duplicate/retry behavior deterministic? +- Do golden fixtures cover at least two unrelated language implementations? + +These gates preserve the strongest aspect of V3—the server does expensive, +product-specific interpretation once—while retaining a clean path to richer +telemetry. diff --git a/src/Exceptionless.Core/Attributes/ApiIgnoreAttribute.cs b/src/Exceptionless.Core/Attributes/ApiIgnoreAttribute.cs new file mode 100644 index 0000000000..24d6f60381 --- /dev/null +++ b/src/Exceptionless.Core/Attributes/ApiIgnoreAttribute.cs @@ -0,0 +1,8 @@ +namespace Exceptionless.Core.Attributes; + +/// +/// Excludes a persisted model member from HTTP JSON contracts while retaining it for storage +/// serialization. Prefer dedicated API models when a larger contract is being designed. +/// +[AttributeUsage(AttributeTargets.Property)] +public sealed class ApiIgnoreAttribute : Attribute; diff --git a/src/Exceptionless.Core/Bootstrapper.cs b/src/Exceptionless.Core/Bootstrapper.cs index efcfae9efb..af87e5f70c 100644 --- a/src/Exceptionless.Core/Bootstrapper.cs +++ b/src/Exceptionless.Core/Bootstrapper.cs @@ -19,6 +19,7 @@ using Exceptionless.Core.Plugins.Formatting; using Exceptionless.Core.Plugins.WebHook; using Exceptionless.Core.Queries.Validation; +using Exceptionless.Core.Queues; using Exceptionless.Core.Queues.Models; using Exceptionless.Core.Repositories; using Exceptionless.Core.Repositories.Configuration; @@ -100,6 +101,7 @@ public static void RegisterServices(IServiceCollection services, AppOptions appO handlers.Register(s.GetRequiredService); handlers.Register(s.GetRequiredService); handlers.Register(s.GetRequiredService); + handlers.Register(s.GetRequiredService); return handlers; }); @@ -111,6 +113,12 @@ public static void RegisterServices(IServiceCollection services, AppOptions appO services.AddSingleton(s => CreateQueue(s, TimeSpan.FromHours(1))); services.TryAddEnumerable(ServiceDescriptor.Singleton, WorkItemDuplicateDetectionQueueBehavior>()); + // V2 keeps Foundatio-compatible dequeue-scoped duplicate detection. V3 bypasses that + // claim and uses only the durable pending/completed behavior so enqueue failures recover. + services.TryAddEnumerable(ServiceDescriptor.Singleton, EventNotificationDuplicateDetectionQueueBehavior>()); + services.TryAddEnumerable(ServiceDescriptor.Singleton, DurableEventNotificationDuplicateDetectionQueueBehavior>()); + services.TryAddEnumerable(ServiceDescriptor.Singleton, WebHookNotificationDuplicateDetectionQueueBehavior>()); + services.TryAddEnumerable(ServiceDescriptor.Singleton, DurableWebHookNotificationDuplicateDetectionQueueBehavior>()); services.AddSingleton(); services.AddSingleton(); @@ -197,6 +205,21 @@ public static void RegisterServices(IServiceCollection services, AppOptions appO services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(s => s.GetRequiredService()); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(s => s.GetRequiredService()); + services.AddSingleton(); + services.AddSingleton(s => s.GetRequiredService()); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); services.AddTransient(); } @@ -208,7 +231,9 @@ private static async ValueTask ConnectToPublicAddressAsync(SocketsHttpCo foreach (var address in addresses) { if (!OAuthClientMetadataService.IsPublicAddress(address)) + { continue; + } var socket = new Socket(address.AddressFamily, SocketType.Stream, ProtocolType.Tcp) { NoDelay = true }; try @@ -229,53 +254,81 @@ private static async ValueTask ConnectToPublicAddressAsync(SocketsHttpCo public static void LogConfiguration(IServiceProvider serviceProvider, AppOptions appOptions, ILogger logger) { if (!logger.IsEnabled(LogLevel.Warning)) + { return; + } if (String.IsNullOrEmpty(appOptions.CacheOptions.Provider)) + { logger.LogWarning("Distributed cache is NOT enabled on {MachineName}", Environment.MachineName); + } if (String.IsNullOrEmpty(appOptions.MessageBusOptions.Provider)) + { logger.LogWarning("Distributed message bus is NOT enabled on {MachineName}", Environment.MachineName); + } if (String.IsNullOrEmpty(appOptions.QueueOptions.Provider)) + { logger.LogWarning("Distributed queue is NOT enabled on {MachineName}", Environment.MachineName); + } if (String.IsNullOrEmpty(appOptions.StorageOptions.Provider)) + { logger.LogWarning("Distributed storage is NOT enabled on {MachineName}", Environment.MachineName); + } if (!appOptions.EnableWebSockets) + { logger.LogWarning("Web Sockets is NOT enabled on {MachineName}", Environment.MachineName); + } if (String.IsNullOrEmpty(appOptions.EmailOptions.SmtpHost)) + { logger.LogWarning("Emails will NOT be sent until the SmtpHost is configured on {MachineName}", Environment.MachineName); + } var fileStorage = serviceProvider.GetService(); if (fileStorage is InMemoryFileStorage) + { logger.LogWarning("Using in memory file storage on {MachineName}", Environment.MachineName); + } if (appOptions.ElasticsearchOptions.DisableIndexConfiguration) + { logger.LogWarning("Index Configuration is NOT enabled on {MachineName}", Environment.MachineName); + } if (appOptions.EventSubmissionDisabled) + { logger.LogWarning("Event Submission is NOT enabled on {MachineName}", Environment.MachineName); + } if (!appOptions.AuthOptions.EnableAccountCreation) + { logger.LogWarning("Account Creation is NOT enabled on {MachineName}", Environment.MachineName); + } } private static async Task CreateSampleDataAsync(IServiceProvider container) { var options = container.GetRequiredService(); if (!options.EnableSampleData) + { return; + } var elasticsearchOptions = container.GetRequiredService(); if (elasticsearchOptions.DisableIndexConfiguration) + { return; + } var userRepository = container.GetRequiredService(); if (await userRepository.CountAsync() != 0) + { return; + } var dataHelper = container.GetRequiredService(); await dataHelper.CreateDataAsync(); @@ -292,6 +345,7 @@ public static void AddHostedJobs(IServiceCollection services, ILoggerFactory log services.AddJob(o => o.WaitForStartupActions()); services.AddJob(o => o.WaitForStartupActions()); services.AddJob(o => o.WaitForStartupActions()); + services.AddJob(o => o.WaitForStartupActions()); services.AddJob(o => o.WaitForStartupActions()); services.AddJob(o => o.WaitForStartupActions()); @@ -320,6 +374,18 @@ private static IQueue CreateQueue(IServiceProvider container, TimeSpan? wo }); } - private sealed class WorkItemDuplicateDetectionQueueBehavior(ICacheClient cacheClient, ILoggerFactory loggerFactory) - : DuplicateDetectionQueueBehavior(cacheClient, loggerFactory, TimeSpan.FromHours(24)); + private sealed class WorkItemDuplicateDetectionQueueBehavior(ICacheClient cacheClient, AppOptions options, ILoggerFactory loggerFactory) + : DuplicateDetectionQueueBehavior(cacheClient, loggerFactory, options.EventIngestionV3.IdempotencyWindow); + + private sealed class EventNotificationDuplicateDetectionQueueBehavior(ICacheClient cacheClient, AppOptions options, ILoggerFactory loggerFactory) + : ConditionalDuplicateDetectionQueueBehavior(cacheClient, loggerFactory, options.EventIngestionV3.IdempotencyWindow); + + private sealed class DurableEventNotificationDuplicateDetectionQueueBehavior(ICacheClient cacheClient, AppOptions options, ILoggerFactory loggerFactory) + : DurableDuplicateDetectionQueueBehavior(cacheClient, loggerFactory, options.EventIngestionV3.IdempotencyWindow); + + private sealed class WebHookNotificationDuplicateDetectionQueueBehavior(ICacheClient cacheClient, AppOptions options, ILoggerFactory loggerFactory) + : ConditionalDuplicateDetectionQueueBehavior(cacheClient, loggerFactory, options.EventIngestionV3.IdempotencyWindow); + + private sealed class DurableWebHookNotificationDuplicateDetectionQueueBehavior(ICacheClient cacheClient, AppOptions options, ILoggerFactory loggerFactory) + : DurableDuplicateDetectionQueueBehavior(cacheClient, loggerFactory, options.EventIngestionV3.IdempotencyWindow); } diff --git a/src/Exceptionless.Core/Configuration/AppOptions.cs b/src/Exceptionless.Core/Configuration/AppOptions.cs index ff6217829a..8b434d8545 100644 --- a/src/Exceptionless.Core/Configuration/AppOptions.cs +++ b/src/Exceptionless.Core/Configuration/AppOptions.cs @@ -80,6 +80,7 @@ public class AppOptions public StripeOptions StripeOptions { get; internal set; } = null!; public AuthOptions AuthOptions { get; internal set; } = null!; public OAuthServerOptions OAuthServerOptions { get; internal set; } = null!; + public EventIngestionV3Options EventIngestionV3 { get; internal set; } = null!; public static AppOptions ReadFromConfiguration(IConfiguration config) { @@ -133,11 +134,84 @@ public static AppOptions ReadFromConfiguration(IConfiguration config) options.StripeOptions = StripeOptions.ReadFromConfiguration(config); options.AuthOptions = AuthOptions.ReadFromConfiguration(config); options.OAuthServerOptions = OAuthServerOptions.ReadFromConfiguration(config); + options.EventIngestionV3 = EventIngestionV3Options.ReadFromConfiguration(config); return options; } } +public sealed class EventIngestionV3Options +{ + public bool Enabled { get; internal set; } + public bool EnableProcessingStatus { get; internal set; } + public int MicroBatchSize { get; internal set; } + public long MaximumMicroBatchBytes { get; internal set; } + public long MaximumEventSize { get; internal set; } + public long MaximumCompressedBodySize { get; internal set; } + public long MaximumDecompressedBodySize { get; internal set; } + public TimeSpan RequestTimeout { get; internal set; } + public TimeSpan IdempotencyWindow { get; internal set; } + public TimeSpan StackRouteCacheDuration { get; internal set; } + public TimeSpan NegativeStackRouteCacheDuration { get; internal set; } + public int MaximumEventsPerRequest { get; internal set; } + public int MaximumActiveStreams { get; internal set; } + public int ActiveStreamQueueLimit { get; internal set; } + public int MaximumActiveStreamsPerOrganization { get; internal set; } + public int ActiveStreamQueueLimitPerOrganization { get; internal set; } + public int MaximumConcurrentRequests { get; internal set; } + public int ConcurrencyQueueLimit { get; internal set; } + public int MaximumConcurrentRequestsPerOrganization { get; internal set; } + public int ConcurrencyQueueLimitPerOrganization { get; internal set; } + public int MaximumStackCreationConcurrency { get; internal set; } + public int MaximumStackUsageConcurrency { get; internal set; } + public TimeSpan StackUsageClaimLease { get; internal set; } + public IReadOnlySet AllowedProjectIds { get; internal set; } = new HashSet(); + public IReadOnlySet AllowedOrganizationIds { get; internal set; } = new HashSet(); + + internal static EventIngestionV3Options ReadFromConfiguration(IConfiguration config) + { + IConfigurationSection section = config.GetSection(nameof(AppOptions.EventIngestionV3)); + int maximumConcurrentRequests = Math.Max(section.GetValue(nameof(MaximumConcurrentRequests), Math.Max(Environment.ProcessorCount * 4, 16)), 1); + int concurrencyQueueLimit = Math.Max(section.GetValue(nameof(ConcurrencyQueueLimit), Math.Max(Environment.ProcessorCount * 16, 64)), 0); + int maximumConcurrentRequestsPerOrganization = Math.Clamp(section.GetValue(nameof(MaximumConcurrentRequestsPerOrganization), Math.Max(maximumConcurrentRequests / 4, 1)), 1, maximumConcurrentRequests); + int defaultMaximumActiveStreams = (int)Math.Min(Math.Max((long)maximumConcurrentRequests * 8, 128), Int32.MaxValue); + int defaultMaximumActiveStreamsPerOrganization = (int)Math.Min(Math.Max((long)maximumConcurrentRequestsPerOrganization * 8, 32), Int32.MaxValue); + int maximumActiveStreams = Math.Max(section.GetValue(nameof(MaximumActiveStreams), defaultMaximumActiveStreams), 1); + int activeStreamQueueLimit = Math.Max(section.GetValue(nameof(ActiveStreamQueueLimit), 0), 0); + return new EventIngestionV3Options + { + Enabled = section.GetValue(nameof(Enabled), false), + EnableProcessingStatus = section.GetValue(nameof(EnableProcessingStatus), false), + MicroBatchSize = Math.Clamp(section.GetValue(nameof(MicroBatchSize), 100), 1, 1000), + MaximumMicroBatchBytes = Math.Max(section.GetValue(nameof(MaximumMicroBatchBytes), 1024L * 1024), 1), + MaximumEventSize = Math.Max(section.GetValue(nameof(MaximumEventSize), 512L * 1024), 1), + MaximumCompressedBodySize = Math.Max(section.GetValue(nameof(MaximumCompressedBodySize), 10L * 1024 * 1024), 1), + MaximumDecompressedBodySize = Math.Max(section.GetValue(nameof(MaximumDecompressedBodySize), 50L * 1024 * 1024), 1), + RequestTimeout = section.GetValue(nameof(RequestTimeout), TimeSpan.FromMinutes(2)), + IdempotencyWindow = section.GetValue(nameof(IdempotencyWindow), TimeSpan.FromDays(7)), + StackRouteCacheDuration = section.GetValue(nameof(StackRouteCacheDuration), TimeSpan.FromHours(1)), + NegativeStackRouteCacheDuration = section.GetValue(nameof(NegativeStackRouteCacheDuration), TimeSpan.FromSeconds(30)), + MaximumEventsPerRequest = Math.Clamp(section.GetValue(nameof(MaximumEventsPerRequest), 10000), 1, 100000), + MaximumActiveStreams = maximumActiveStreams, + ActiveStreamQueueLimit = activeStreamQueueLimit, + MaximumActiveStreamsPerOrganization = Math.Clamp(section.GetValue(nameof(MaximumActiveStreamsPerOrganization), defaultMaximumActiveStreamsPerOrganization), 1, maximumActiveStreams), + ActiveStreamQueueLimitPerOrganization = Math.Clamp(section.GetValue(nameof(ActiveStreamQueueLimitPerOrganization), 0), 0, activeStreamQueueLimit), + MaximumConcurrentRequests = maximumConcurrentRequests, + ConcurrencyQueueLimit = concurrencyQueueLimit, + MaximumConcurrentRequestsPerOrganization = maximumConcurrentRequestsPerOrganization, + ConcurrencyQueueLimitPerOrganization = Math.Clamp(section.GetValue(nameof(ConcurrencyQueueLimitPerOrganization), Math.Max(concurrencyQueueLimit / 4, 0)), 0, concurrencyQueueLimit), + MaximumStackCreationConcurrency = Math.Clamp(section.GetValue(nameof(MaximumStackCreationConcurrency), 8), 1, 64), + MaximumStackUsageConcurrency = Math.Clamp(section.GetValue(nameof(MaximumStackUsageConcurrency), 16), 1, 64), + StackUsageClaimLease = TimeSpan.FromMilliseconds(Math.Clamp( + section.GetValue(nameof(StackUsageClaimLease), TimeSpan.FromMinutes(1)).TotalMilliseconds, + TimeSpan.FromSeconds(10).TotalMilliseconds, + TimeSpan.FromMinutes(10).TotalMilliseconds)), + AllowedProjectIds = section.GetSection(nameof(AllowedProjectIds)).Get()?.ToHashSet(StringComparer.Ordinal) ?? new HashSet(StringComparer.Ordinal), + AllowedOrganizationIds = section.GetSection(nameof(AllowedOrganizationIds)).Get()?.ToHashSet(StringComparer.Ordinal) ?? new HashSet(StringComparer.Ordinal) + }; + } +} + public enum AppMode { Development, diff --git a/src/Exceptionless.Core/Extensions/RequestInfoExtensions.cs b/src/Exceptionless.Core/Extensions/RequestInfoExtensions.cs index 8019f6a9ae..dc3ee64825 100644 --- a/src/Exceptionless.Core/Extensions/RequestInfoExtensions.cs +++ b/src/Exceptionless.Core/Extensions/RequestInfoExtensions.cs @@ -6,6 +6,20 @@ namespace Exceptionless.Core.Extensions; public static class RequestInfoExtensions { + public const int MaximumDataValueLength = 1000; + + public static readonly IReadOnlyList DefaultDataExclusions = + [ + "*VIEWSTATE*", + "*EVENTVALIDATION*", + "*ASPX*", + "__RequestVerificationToken", + "ASP.NET_SessionId", + "__LastErrorId", + "WAWebSiteID", + "ARRAffinity" + ]; + public static RequestInfo ApplyDataExclusions(this RequestInfo request, ITextSerializer serializer, IList exclusions, int maxLength = 1000) { request.Cookies = ApplyExclusions(request.Cookies, exclusions, maxLength); @@ -15,16 +29,32 @@ public static RequestInfo ApplyDataExclusions(this RequestInfo request, ITextSer return request; } + /// + /// Applies request exclusions to already materialized JSON-compatible post data. Unlike the + /// legacy string overload, this handles nested V3 object and array values before persistence. + /// + public static RequestInfo ApplyDataExclusions(this RequestInfo request, IList exclusions, int maxLength = 1000) + { + request.Cookies = ApplyExclusions(request.Cookies, exclusions, maxLength); + request.QueryString = ApplyExclusions(request.QueryString, exclusions, maxLength); + request.PostData = ApplyObjectExclusions(request.PostData, exclusions, maxLength); + return request; + } + private static object? ApplyPostDataExclusions(object? data, ITextSerializer serializer, IEnumerable exclusions, int maxLength) { if (data is null) + { return null; + } var dictionary = data as Dictionary; if (dictionary is null && data is string json) { if (!json.IsJson()) + { return data; + } try { @@ -36,16 +66,60 @@ public static RequestInfo ApplyDataExclusions(this RequestInfo request, ITextSer return dictionary is not null ? ApplyExclusions(dictionary, exclusions, maxLength) : data; } + private static object? ApplyObjectExclusions(object? value, IList exclusions, int maxLength) + { + if (value is IDictionary objectDictionary) + { + foreach (string key in objectDictionary.Keys.ToArray()) + { + if (String.IsNullOrEmpty(key) || key.AnyWildcardMatches(exclusions, true)) + { + objectDictionary.Remove(key); + continue; + } + + objectDictionary[key] = ApplyObjectExclusions(objectDictionary[key], exclusions, maxLength); + } + + return value; + } + + if (value is IDictionary stringDictionary) + { + return ApplyExclusions(new Dictionary(stringDictionary), exclusions, maxLength); + } + + if (value is IList values) + { + for (int index = 0; index < values.Count; index++) + { + values[index] = ApplyObjectExclusions(values[index], exclusions, maxLength); + } + + return value; + } + + return value is string text && text.Length > maxLength + ? "Value is too large to be included." + : value; + } + private static Dictionary? ApplyExclusions(Dictionary? dictionary, IEnumerable exclusions, int maxLength) { if (dictionary is null || dictionary.Count == 0) + { return dictionary; + } foreach (string key in dictionary.Keys.Where(k => String.IsNullOrEmpty(k) || StringExtensions.AnyWildcardMatches(k, exclusions, true)).ToList()) + { dictionary.Remove(key); + } foreach (string key in dictionary.Where(kvp => kvp.Value is not null && kvp.Value.Length > maxLength).Select(kvp => kvp.Key).ToList()) + { dictionary[key] = String.Format("Value is too large to be included."); + } return dictionary; } @@ -57,26 +131,34 @@ public static string GetFullPath(this RequestInfo requestInfo, bool includeHttpM { var sb = new StringBuilder(); if (includeHttpMethod && !String.IsNullOrEmpty(requestInfo.HttpMethod)) + { sb.Append(requestInfo.HttpMethod).Append(" "); + } if (includeHost && !String.IsNullOrEmpty(requestInfo.Host)) { sb.Append(requestInfo.IsSecure.GetValueOrDefault() ? "https://" : "http://"); sb.Append(requestInfo.Host); if (requestInfo.Port.HasValue && requestInfo.Port != 80 && requestInfo.Port != 443) + { sb.Append(":").Append(requestInfo.Port); + } } if (requestInfo.Path is not null) { if (!requestInfo.Path.StartsWith("/")) + { sb.Append("/"); + } sb.Append(requestInfo.Path); } if (includeQueryString && requestInfo.QueryString is not null && requestInfo.QueryString.Count > 0) + { sb.Append("?").Append(CreateQueryString(requestInfo.QueryString)); + } return sb.ToString(); } @@ -84,17 +166,23 @@ public static string GetFullPath(this RequestInfo requestInfo, bool includeHttpM private static string CreateQueryString(IEnumerable> args) { if (args is null) + { return String.Empty; + } if (!args.Any()) + { return String.Empty; + } var sb = new StringBuilder(args.Count() * 10); foreach (var p in args) { if (String.IsNullOrEmpty(p.Key) && p.Value is null) + { continue; + } if (!String.IsNullOrEmpty(p.Key)) { @@ -102,7 +190,10 @@ private static string CreateQueryString(IEnumerable sb.Append('='); } if (p.Value is not null) + { sb.Append(p.Value); + } + sb.Append('&'); } sb.Length--; // remove trailing & diff --git a/src/Exceptionless.Core/Extensions/StackExtensions.cs b/src/Exceptionless.Core/Extensions/StackExtensions.cs index 0f11c42880..c0750095a1 100644 --- a/src/Exceptionless.Core/Extensions/StackExtensions.cs +++ b/src/Exceptionless.Core/Extensions/StackExtensions.cs @@ -10,6 +10,7 @@ public static void MarkFixed(this Stack stack, SemanticVersion? version, TimePro stack.Status = StackStatus.Fixed; stack.DateFixed = timeProvider.GetUtcNow().UtcDateTime; stack.FixedInVersion = version?.ToString(); + stack.RegressionEventId = null; stack.SnoozeUntilUtc = null; } @@ -18,19 +19,26 @@ public static void MarkOpen(this Stack stack) stack.Status = StackStatus.Open; stack.DateFixed = null; stack.FixedInVersion = null; + stack.RegressionEventId = null; stack.SnoozeUntilUtc = null; } public static Stack ApplyOffset(this Stack stack, TimeSpan offset) { if (stack.DateFixed.HasValue) + { stack.DateFixed = stack.DateFixed.Value.Add(offset); + } if (stack.FirstOccurrence != DateTime.MinValue) + { stack.FirstOccurrence = stack.FirstOccurrence.Add(offset); + } if (stack.LastOccurrence != DateTime.MinValue) + { stack.LastOccurrence = stack.LastOccurrence.Add(offset); + } return stack; } @@ -38,7 +46,9 @@ public static Stack ApplyOffset(this Stack stack, TimeSpan offset) public static string? GetTypeName(this Stack stack) { if (stack.SignatureInfo.TryGetValue("ExceptionType", out string? type) && !String.IsNullOrEmpty(type)) + { return type.TypeName(); + } return type; } diff --git a/src/Exceptionless.Core/Jobs/EventPostsJob.cs b/src/Exceptionless.Core/Jobs/EventPostsJob.cs index 0b7aaffec3..8c0ff5e552 100644 --- a/src/Exceptionless.Core/Jobs/EventPostsJob.cs +++ b/src/Exceptionless.Core/Jobs/EventPostsJob.cs @@ -55,6 +55,18 @@ protected override async Task ProcessQueueEntryAsync(QueueEntryContex var entry = context.QueueEntry; var ep = entry.Value; + // The controller returns the queue id without performing cache I/O so opt-in benchmark + // instrumentation does not distort V2 submission latency. The worker lazily creates the + // correlation state when processing begins; until then the status endpoint reports unknown. + if (ep.TrackProcessing && String.IsNullOrEmpty(ep.ProcessingCorrelationId)) + { + bool initialized = await _eventPostService.InitializeProcessingTrackingAsync(entry.Id, ep.ProjectId); + if (initialized) + { + ep = ep with { ProcessingCorrelationId = entry.Id }; + } + } + using var _ = _logger.BeginScope(new ExceptionlessState().Organization(ep.OrganizationId).Project(ep.ProjectId)); string payloadPath = Path.ChangeExtension(ep.FilePath, ".payload"); @@ -72,7 +84,7 @@ protected override async Task ProcessQueueEntryAsync(QueueEntryContex AppDiagnostics.PostsMessageSize.Record(payload.LongLength); if (payload.LongLength > _maximumEventPostFileSize) { - await Task.WhenAll(AppDiagnostics.PostsCompleteTime.TimeAsync(() => entry.CompleteAsync()), projectTask, organizationTask); + await Task.WhenAll(CompleteEntryAsync(entry, ep, _timeProvider.GetUtcNow().UtcDateTime), projectTask, organizationTask); return JobResult.FailedWithMessage($"Unable to process payload '{payloadPath}' ({payload.LongLength} bytes): Maximum event post size limit ({_appOptions.MaximumEventPostSize} bytes) reached."); } @@ -83,13 +95,19 @@ protected override async Task ProcessQueueEntryAsync(QueueEntryContex if (!isInternalProject && _logger.IsEnabled(LogLevel.Information)) { using (_logger.BeginScope(new ExceptionlessState().Tag("processing").Tag("compressed").Tag(ep.ContentEncoding).Value(payload.Length))) + { _logger.LogInformation("Processing post: id={QueueEntryId} path={FilePath} project={ProjectId} ip={IpAddress} v={ApiVersion} agent={UserAgent}", entry.Id, payloadPath, ep.ProjectId, ep.IpAddress, ep.ApiVersion, ep.UserAgent); + } } var project = await projectTask; if (project is null) { - if (!isInternalProject) _logger.LogError("Unable to process EventPost {FilePath}: Unable to load project: {ProjectId}", payloadPath, ep.ProjectId); + if (!isInternalProject) + { + _logger.LogError("Unable to process EventPost {FilePath}: Unable to load project: {ProjectId}", payloadPath, ep.ProjectId); + } + await Task.WhenAll(CompleteEntryAsync(entry, ep, _timeProvider.GetUtcNow().UtcDateTime), organizationTask); return JobResult.Success; } @@ -101,7 +119,9 @@ protected override async Task ProcessQueueEntryAsync(QueueEntryContex if (!isInternalProject && isDebugLogLevelEnabled) { using (_logger.BeginScope(new ExceptionlessState().Tag("decompressing").Tag(ep.ContentEncoding))) + { _logger.LogDebug("Decompressing EventPost: {QueueEntryId} ({CompressedBytes} bytes)", entry.Id, payload.Length); + } } maxEventPostSize = _maximumUncompressedEventPostSize; @@ -125,9 +145,14 @@ protected override async Task ProcessQueueEntryAsync(QueueEntryContex { var org = await organizationTask; if (org is not null) + { await _usageService.IncrementTooBigAsync(org.Id, project.Id); + } else + { _logger.LogWarning("Organization {OrganizationId} not found, skipping too-big usage increment for event post {EventPostId}", ep.OrganizationId, entry.Id); + } + await CompleteEntryAsync(entry, ep, _timeProvider.GetUtcNow().UtcDateTime); return JobResult.FailedWithMessage($"Unable to process decompressed EventPost data '{payloadPath}' ({payload.Length} bytes compressed, {uncompressedData.Length} bytes): Maximum uncompressed event post size limit ({maxEventPostSize} bytes) reached."); } @@ -135,7 +160,9 @@ protected override async Task ProcessQueueEntryAsync(QueueEntryContex if (!isInternalProject && isDebugLogLevelEnabled) { using (_logger.BeginScope(new ExceptionlessState().Tag("uncompressed").Value(uncompressedData.Length))) + { _logger.LogDebug("Processing uncompressed EventPost: {QueueEntryId} ({UncompressedBytes} bytes)", entry.Id, uncompressedData.Length); + } } var createdUtc = _timeProvider.GetUtcNow().UtcDateTime; @@ -156,7 +183,9 @@ protected override async Task ProcessQueueEntryAsync(QueueEntryContex if (organization is null) { if (!isInternalProject) + { _logger.LogError("Unable to process EventPost {FilePath}: Unable to load organization: {OrganizationId}", payloadPath, project.OrganizationId); + } await CompleteEntryAsync(entry, ep, _timeProvider.GetUtcNow().UtcDateTime); return JobResult.Success; @@ -167,7 +196,9 @@ protected override async Task ProcessQueueEntryAsync(QueueEntryContex if (eventsToProcess < 1) { if (!isInternalProject) + { _logger.LogDebug("Unable to process EventPost {FilePath}: Over plan limits", payloadPath); + } await _usageService.IncrementBlockedAsync(organization.Id, project.Id, events.Count); @@ -195,7 +226,9 @@ protected override async Task ProcessQueueEntryAsync(QueueEntryContex if (!isInternalProject && isDebugLogLevelEnabled) { using (_logger.BeginScope(new ExceptionlessState().Value(contexts.Count))) + { _logger.LogDebug("Ran {@Value} events through the pipeline: id={QueueEntryId} success={SuccessCount} error={ErrorCount}", contexts.Count, entry.Id, contexts.Count(r => r.IsProcessed), contexts.Count(r => r.HasError)); + } } // increment the plan usage counters (note: OverageHandler already incremented usage by 1) @@ -208,14 +241,24 @@ protected override async Task ProcessQueueEntryAsync(QueueEntryContex foreach (var ctx in contexts) { if (ctx.IsCancelled) + { continue; + } if (!ctx.HasError) + { continue; + } + + if (!isInternalProject) + { + _logger.LogError(ctx.Exception, "Error processing EventPost {QueueEntryId} {FilePath}: {Message}", entry.Id, payloadPath, ctx.ErrorMessage); + } - if (!isInternalProject) _logger.LogError(ctx.Exception, "Error processing EventPost {QueueEntryId} {FilePath}: {Message}", entry.Id, payloadPath, ctx.ErrorMessage); if (ctx.Exception is MiniValidatorException) + { continue; + } errorCount++; if (!isSingleEvent) @@ -227,7 +270,11 @@ protected override async Task ProcessQueueEntryAsync(QueueEntryContex } catch (Exception ex) { - if (!isInternalProject) _logger.LogError(ex, "Error processing EventPost {QueueEntryId} {FilePath}: {Message}", entry.Id, payloadPath, ex.Message); + if (!isInternalProject) + { + _logger.LogError(ex, "Error processing EventPost {QueueEntryId} {FilePath}: {Message}", entry.Id, payloadPath, ex.Message); + } + if (ex is ArgumentException || ex is DocumentNotFoundException) { await CompleteEntryAsync(entry, ep, createdUtc); @@ -236,16 +283,27 @@ protected override async Task ProcessQueueEntryAsync(QueueEntryContex errorCount++; if (!isSingleEvent) + { eventsToRetry.AddRange(events); + } } + bool completeTracking = true; if (eventsToRetry.Count > 0) - await AppDiagnostics.PostsRetryTime.TimeAsync(() => RetryEventsAsync(eventsToRetry, ep, entry, project, isInternalProject)); + { + bool propagateTracking = await _eventPostService.AddPendingProcessingUnitsAsync(ep, eventsToRetry.Count); + await AppDiagnostics.PostsRetryTime.TimeAsync(() => RetryEventsAsync(eventsToRetry, ep, entry, project, isInternalProject, propagateTracking)); + completeTracking = propagateTracking; + } if (isSingleEvent && errorCount > 0) + { await AbandonEntryAsync(entry); + } else - await CompleteEntryAsync(entry, ep, createdUtc); + { + await CompleteEntryAsync(entry, ep, createdUtc, completeTracking); + } return JobResult.Success; } @@ -255,14 +313,18 @@ protected override async Task ProcessQueueEntryAsync(QueueEntryContex using (_logger.BeginScope(new ExceptionlessState().Tag("parsing"))) { if (!isInternalProject && _logger.IsEnabled(LogLevel.Debug)) + { _logger.LogDebug("Parsing EventPost: {QueueEntryId}", queueEntryId); + } var events = new List(); try { var encoding = Encoding.UTF8; if (!String.IsNullOrEmpty(ep.CharSet)) + { encoding = Encoding.GetEncoding(ep.CharSet); + } AppDiagnostics.PostsParsingTime.Time(() => { @@ -276,7 +338,9 @@ protected override async Task ProcessQueueEntryAsync(QueueEntryContex // set the reference id to the event id if one was defined. if (!String.IsNullOrEmpty(ev.Id) && String.IsNullOrEmpty(ev.ReferenceId)) + { ev.ReferenceId = ev.Id; + } // the event id and stack id should never be set for posted events ev.Id = ev.StackId = null!; @@ -288,17 +352,22 @@ protected override async Task ProcessQueueEntryAsync(QueueEntryContex catch (Exception ex) { AppDiagnostics.PostsParseErrors.Add(1); - if (!isInternalProject) _logger.LogError(ex, "An error occurred while processing the EventPost {QueueEntryId}: {Message}", queueEntryId, ex.Message); + if (!isInternalProject) + { + _logger.LogError(ex, "An error occurred while processing the EventPost {QueueEntryId}: {Message}", queueEntryId, ex.Message); + } } if (!isInternalProject && _logger.IsEnabled(LogLevel.Debug)) + { _logger.LogDebug("Parsed {ParsedCount} events from EventPost: {QueueEntryId}", events?.Count ?? 0, queueEntryId); + } return events; } } - private async Task RetryEventsAsync(List eventsToRetry, EventPostInfo ep, IQueueEntry queueEntry, Project project, bool isInternalProject) + private async Task RetryEventsAsync(List eventsToRetry, EventPost ep, IQueueEntry queueEntry, Project project, bool isInternalProject, bool propagateTracking) { AppDiagnostics.EventsRetryCount.Add(eventsToRetry.Count); foreach (var ev in eventsToRetry) @@ -316,6 +385,7 @@ await _eventPostService.EnqueueAsync(new EventPost(false) IpAddress = ep.IpAddress, MediaType = ep.MediaType, OrganizationId = ep.OrganizationId ?? project.OrganizationId, + ProcessingCorrelationId = propagateTracking ? ep.ProcessingCorrelationId : null, ProjectId = ep.ProjectId ?? project.Id, UserAgent = ep.UserAgent }, stream); @@ -325,7 +395,9 @@ await _eventPostService.EnqueueAsync(new EventPost(false) if (!isInternalProject && _logger.IsEnabled(LogLevel.Critical)) { using (_logger.BeginScope(new ExceptionlessState().Property("Event", new { ev.Date, ev.StackId, ev.Type, ev.Source, ev.Message, ev.Value, ev.Geo, ev.ReferenceId, ev.Tags }))) + { _logger.LogCritical(ex, "Error while requeuing event post {QueueEntryId} {FilePath}: {Message}", queueEntry.Id, queueEntry.Value.FilePath, ex.Message); + } } AppDiagnostics.EventsRetryErrors.Add(1); @@ -338,12 +410,16 @@ private Task AbandonEntryAsync(IQueueEntry queueEntry) return AppDiagnostics.PostsAbandonTime.TimeAsync(queueEntry.AbandonAsync); } - private Task CompleteEntryAsync(IQueueEntry entry, EventPost eventPost, DateTime created) + private Task CompleteEntryAsync(IQueueEntry entry, EventPost eventPost, DateTime created, bool completeTracking = true) { return AppDiagnostics.PostsCompleteTime.TimeAsync(async () => { await entry.CompleteAsync(); - await _eventPostService.CompleteEventPostAsync(eventPost.FilePath, eventPost.ProjectId, created, eventPost.ShouldArchive); + bool eventPostCompleted = await _eventPostService.CompleteEventPostAsync(eventPost.FilePath, eventPost.ProjectId, created, eventPost.ShouldArchive); + if (eventPostCompleted) + { + await _eventPostService.MarkProcessingCompletedAsync(entry.Id, eventPost, completeTracking); + } }); } diff --git a/src/Exceptionless.Core/Jobs/IngestionStackEventCountJob.cs b/src/Exceptionless.Core/Jobs/IngestionStackEventCountJob.cs new file mode 100644 index 0000000000..c05114615d --- /dev/null +++ b/src/Exceptionless.Core/Jobs/IngestionStackEventCountJob.cs @@ -0,0 +1,52 @@ +using Exceptionless.Core.Services; +using Foundatio.Jobs; +using Foundatio.Resilience; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Logging; + +namespace Exceptionless.Core.Jobs; + +/// +/// Drains V3 stack-usage settlements without a cluster-wide lock. Redis leases partition work +/// across every job instance, and Elasticsearch settlement sequences make retries idempotent. +/// +[Job(Description = "Apply V3 event occurrence counts to stacks.", InitialDelay = "2s", Interval = "1s")] +public sealed class IngestionStackEventCountJob : JobBase, IHealthCheck +{ + private readonly StackService _stackService; + private DateTime? _lastRun; + + public IngestionStackEventCountJob( + StackService stackService, + TimeProvider timeProvider, + IResiliencePolicyProvider resiliencePolicyProvider, + ILoggerFactory loggerFactory) + : base(timeProvider, resiliencePolicyProvider, loggerFactory) + { + _stackService = stackService; + } + + protected override async Task RunInternalAsync(JobContext context) + { + _lastRun = _timeProvider.GetUtcNow().UtcDateTime; + _logger.LogTrace("Start applying V3 stack event counts"); + await _stackService.SaveIngestionStackUsagesAsync(cancellationToken: context.CancellationToken); + _logger.LogTrace("Finished applying V3 stack event counts"); + return JobResult.Success; + } + + public Task CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default) + { + if (!_lastRun.HasValue) + { + return Task.FromResult(HealthCheckResult.Healthy("Job has not been run yet.")); + } + + if (_timeProvider.GetUtcNow().UtcDateTime.Subtract(_lastRun.Value) > TimeSpan.FromSeconds(15)) + { + return Task.FromResult(HealthCheckResult.Unhealthy("Job has not run in the last 15 seconds.")); + } + + return Task.FromResult(HealthCheckResult.Healthy("Job has run in the last 15 seconds.")); + } +} diff --git a/src/Exceptionless.Core/Jobs/StackEventCountJob.cs b/src/Exceptionless.Core/Jobs/StackEventCountJob.cs index 3e2dea7ca1..0a4d23000f 100644 --- a/src/Exceptionless.Core/Jobs/StackEventCountJob.cs +++ b/src/Exceptionless.Core/Jobs/StackEventCountJob.cs @@ -34,7 +34,7 @@ protected override async Task RunInternalAsync(JobContext context) { _lastRun = _timeProvider.GetUtcNow().UtcDateTime; _logger.LogTrace("Start save stack event counts"); - await _stackService.SaveStackUsagesAsync(cancellationToken: context.CancellationToken); + await _stackService.SaveLegacyStackUsagesAsync(cancellationToken: context.CancellationToken); _logger.LogTrace("Finished save stack event counts"); return JobResult.Success; } @@ -42,10 +42,14 @@ protected override async Task RunInternalAsync(JobContext context) public Task CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default) { if (!_lastRun.HasValue) + { return Task.FromResult(HealthCheckResult.Healthy("Job has not been run yet.")); + } if (_timeProvider.GetUtcNow().UtcDateTime.Subtract(_lastRun.Value) > TimeSpan.FromSeconds(15)) + { return Task.FromResult(HealthCheckResult.Unhealthy("Job has not run in the last 15 seconds.")); + } return Task.FromResult(HealthCheckResult.Healthy("Job has run in the last 15 seconds.")); } diff --git a/src/Exceptionless.Core/Jobs/StackStatusJob.cs b/src/Exceptionless.Core/Jobs/StackStatusJob.cs index 0f0c8d3d2f..9f309311df 100644 --- a/src/Exceptionless.Core/Jobs/StackStatusJob.cs +++ b/src/Exceptionless.Core/Jobs/StackStatusJob.cs @@ -1,5 +1,7 @@ using Exceptionless.Core.Extensions; +using Exceptionless.Core.Models.Ingestion; using Exceptionless.Core.Repositories; +using Exceptionless.Core.Services; using Foundatio.Caching; using Foundatio.Jobs; using Foundatio.Lock; @@ -15,15 +17,17 @@ public class StackStatusJob : JobWithLockBase, IHealthCheck { private readonly IStackRepository _stackRepository; private readonly ILockProvider _lockProvider; + private readonly IStackRouteResolver _stackRouteResolver; private DateTime? _lastRun; - public StackStatusJob(IStackRepository stackRepository, ICacheClient cacheClient, + public StackStatusJob(IStackRepository stackRepository, IStackRouteResolver stackRouteResolver, ICacheClient cacheClient, TimeProvider timeProvider, IResiliencePolicyProvider resiliencePolicyProvider, ILoggerFactory loggerFactory ) : base(timeProvider, resiliencePolicyProvider, loggerFactory) { _stackRepository = stackRepository; + _stackRouteResolver = stackRouteResolver; _lockProvider = new ThrottlingLockProvider(cacheClient, 1, TimeSpan.FromSeconds(10), timeProvider, resiliencePolicyProvider, loggerFactory); } @@ -43,18 +47,26 @@ protected override async Task RunInternalAsync(JobContext context) while (results.Documents.Count > 0 && !context.CancellationToken.IsCancellationRequested) { foreach (var stack in results.Documents) + { stack.MarkOpen(); + } await _stackRepository.SaveAsync(results.Documents); + await Task.WhenAll(results.Documents.Select(stack => + _stackRouteResolver.UpdateAsync(stack.ProjectId, stack.SignatureHash, StackRouteResolver.CreateRoute(stack)))); // Sleep so we are not hammering the backend. await Task.Delay(TimeSpan.FromSeconds(2.5), _timeProvider); if (context.CancellationToken.IsCancellationRequested || !await results.NextPageAsync()) + { break; + } if (results.Documents.Count > 0) + { await context.RenewLockAsync(); + } } _logger.LogTrace("Finished save stack event counts"); @@ -64,10 +76,14 @@ protected override async Task RunInternalAsync(JobContext context) public Task CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default) { if (!_lastRun.HasValue) + { return Task.FromResult(HealthCheckResult.Healthy("Job has not been run yet.")); + } if (_timeProvider.GetUtcNow().UtcDateTime.Subtract(_lastRun.Value) > TimeSpan.FromMinutes(1)) + { return Task.FromResult(HealthCheckResult.Unhealthy("Job has not run in the last minute.")); + } return Task.FromResult(HealthCheckResult.Healthy("Job has run in the last minute.")); } diff --git a/src/Exceptionless.Core/Jobs/WorkItemHandlers/EventIngestionSideEffectsWorkItemHandler.cs b/src/Exceptionless.Core/Jobs/WorkItemHandlers/EventIngestionSideEffectsWorkItemHandler.cs new file mode 100644 index 0000000000..2d45ace859 --- /dev/null +++ b/src/Exceptionless.Core/Jobs/WorkItemHandlers/EventIngestionSideEffectsWorkItemHandler.cs @@ -0,0 +1,173 @@ +using System.Security.Cryptography; +using System.Text; +using Exceptionless.Core.Models; +using Exceptionless.Core.Models.WorkItems; +using Exceptionless.Core.Pipeline; +using Exceptionless.Core.Plugins.EventProcessor; +using Exceptionless.Core.Plugins.EventProcessor.Default; +using Exceptionless.Core.Repositories; +using Exceptionless.Core.Services; +using Foundatio.Jobs; +using Foundatio.Lock; +using Foundatio.Queues; +using Foundatio.Repositories; +using Foundatio.Storage; +using Microsoft.Extensions.Logging; + +namespace Exceptionless.Core.Jobs.WorkItemHandlers; + +public sealed class EventIngestionSideEffectsWorkItemHandler( + IOrganizationRepository organizationRepository, + IProjectRepository projectRepository, + IEventRepository eventRepository, + IStackRepository stackRepository, + IIngestionStackUsageStore ingestionStackUsageStore, + RequestInfoPlugin requestInfoPlugin, + EnvironmentInfoPlugin environmentInfoPlugin, + GeoPlugin geoPlugin, + QueueNotificationAction queueNotificationAction, + IngestionSideEffectExecutor sideEffectExecutor, + IQueue workItemQueue, + IFileStorage storage, + AppOptions options, + ILockProvider lockProvider, + ILoggerFactory loggerFactory) : WorkItemHandlerBase(loggerFactory) +{ + public override Task GetWorkItemLockAsync(object workItem, CancellationToken cancellationToken = default) + { + var sideEffects = (EventIngestionSideEffectsWorkItem)workItem; + return lockProvider.TryAcquireAsync(sideEffects.UniqueIdentifier, TimeSpan.FromMinutes(5), cancellationToken); + } + + public override async Task HandleItemAsync(WorkItemContext context) + { + var workItem = context.GetData()!; + var organization = await organizationRepository.GetByIdAsync(workItem.OrganizationId, o => o.Cache()); + var project = await projectRepository.GetByIdAsync(workItem.ProjectId, o => o.Cache()); + if (organization is null || project is null) + { + return; + } + + var events = await eventRepository.GetByIdsAsync(workItem.EventIds); + if (events.Count == 0) + { + return; + } + + if (options.EnableArchive) + { + DateTime archiveDate = events.Min(ev => ev.CreatedUtc); + string archivePath = GetArchivePath(workItem, archiveDate); + bool archived = await storage.SaveObjectAsync(archivePath, events.OrderBy(ev => ev.Id).ToArray(), context.CancellationToken); + if (!archived) + { + throw new InvalidOperationException($"Unable to archive V3 ingestion side-effect batch '{workItem.UniqueIdentifier}'."); + } + } + + var stacks = await stackRepository.GetByIdsAsync(events.Select(e => e.StackId).Distinct().ToArray()); + var stacksById = stacks.ToDictionary(stack => stack.Id); + var contexts = new List(events.Count); + foreach (var ev in events) + { + if (!stacksById.TryGetValue(ev.StackId, out var stack)) + { + continue; + } + + contexts.Add(new EventContext(ev, organization, project) + { + Stack = stack, + IsNew = ev.IsFirstOccurrence, + IsRegression = ev.IsRegression, + IsIngestionV3 = true, + IsProcessed = true + }); + } + + if (contexts.Count == 0) + { + return; + } + + if (!project.IsConfigured.GetValueOrDefault()) + { + await sideEffectExecutor.ExecuteAsync(IngestionSideEffectExecutor.ProjectConfiguredStage, project.Id, [project.Id], async _ => + { + await workItemQueue.EnqueueAsync(new SetProjectIsConfiguredWorkItem + { + ProjectId = project.Id, + IsConfigured = true + }); + }, context.CancellationToken); + } + + var enrichmentContexts = contexts + .Where(eventContext => eventContext.Event.Data?.ContainsKey(Event.KnownDataKeys.RequestInfo) is true + || eventContext.Event.Data?.ContainsKey(Event.KnownDataKeys.EnvironmentInfo) is true) + .ToList(); + if (enrichmentContexts.Count > 0) + { + await requestInfoPlugin.EventBatchProcessingAsync(enrichmentContexts); + foreach (var eventContext in enrichmentContexts) + { + await environmentInfoPlugin.EventProcessingAsync(eventContext); + } + + await geoPlugin.EventBatchProcessingAsync(enrichmentContexts); + await eventRepository.SaveAsync(enrichmentContexts.Select(eventContext => eventContext.Event), o => o.Notifications(false)); + } + + var settledStackUsages = await ingestionStackUsageStore.SettleAsync( + contexts.Select(eventContext => new IngestionStackUsage( + eventContext.Event.Id, + organization.Id, + project.Id, + eventContext.Event.StackId, + eventContext.Event.Date.UtcDateTime)).ToArray(), + context.CancellationToken); + foreach (var usage in settledStackUsages) + { + if (stacksById.TryGetValue(usage.StackId, out var stack)) + { + stack.TotalOccurrences += usage.Count; + if (stack.FirstOccurrence > usage.MinimumOccurrenceDateUtc) + { + stack.FirstOccurrence = usage.MinimumOccurrenceDateUtc; + } + + if (stack.LastOccurrence < usage.MaximumOccurrenceDateUtc) + { + stack.LastOccurrence = usage.MaximumOccurrenceDateUtc; + } + } + } + + var notificationContextsById = contexts.ToDictionary(eventContext => eventContext.Event.Id, StringComparer.Ordinal); + await sideEffectExecutor.ExecuteAsync(IngestionSideEffectExecutor.TerminalStage, project.Id, notificationContextsById.Keys.ToArray(), async pendingIds => + { + var pendingContexts = pendingIds.Select(id => notificationContextsById[id]).ToArray(); + await queueNotificationAction.ProcessIngestionV3BatchAsync(pendingContexts); + }, context.CancellationToken); + } + + private static string GetArchivePath(EventIngestionSideEffectsWorkItem workItem, DateTime createdUtc) + { + return Path.Combine( + "archive", + "v3", + createdUtc.ToString("yy"), + createdUtc.ToString("MM"), + createdUtc.ToString("dd"), + createdUtc.ToString("HH"), + createdUtc.ToString("mm"), + workItem.ProjectId, + String.Concat(GetWorkItemHash(workItem), ".json")); + } + + private static string GetWorkItemHash(EventIngestionSideEffectsWorkItem workItem) + { + return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(workItem.UniqueIdentifier))).ToLowerInvariant(); + } +} diff --git a/src/Exceptionless.Core/Models/Ingestion/EventIngestionV3BufferedRecord.cs b/src/Exceptionless.Core/Models/Ingestion/EventIngestionV3BufferedRecord.cs new file mode 100644 index 0000000000..b5e7171ae3 --- /dev/null +++ b/src/Exceptionless.Core/Models/Ingestion/EventIngestionV3BufferedRecord.cs @@ -0,0 +1,117 @@ +using System.Buffers; +using System.Text.Json; +using Exceptionless.Core.Serialization; + +namespace Exceptionless.Core.Models.Ingestion; + +/// +/// Owns one bounded NDJSON record and its routing-only projection. The complete event is +/// materialized only after stack routing proves that the record will continue through ingestion. +/// +internal sealed class EventIngestionV3BufferedRecord : IDisposable +{ + private IMemoryOwner? _owner; + private EventIngestionV3Event? _materializedEvent; + + public EventIngestionV3BufferedRecord( + IMemoryOwner owner, + int length, + EventIngestionV3Event routingEvent) + { + ArgumentNullException.ThrowIfNull(owner); + ArgumentNullException.ThrowIfNull(routingEvent); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(length); + if (length > owner.Memory.Length) + { + throw new ArgumentOutOfRangeException(nameof(length)); + } + + _owner = owner; + Length = length; + RoutingEvent = routingEvent; + } + + public int Length { get; } + + public EventIngestionV3Event RoutingEvent { get; } + + internal bool IsMaterialized => _materializedEvent is not null; + + public EventIngestionV3Event Materialize() + { + if (_materializedEvent is not null) + { + return _materializedEvent; + } + + ObjectDisposedException.ThrowIf(_owner is null, this); + var payload = JsonSerializer.Deserialize( + _owner.Memory.Span[..Length], + EventIngestionV3SurvivorJsonContext.Default.EventIngestionV3SurvivorPayload) + ?? throw new JsonException("The stream cannot contain null events."); + + // Routing strings can dominate an error event, especially the raw stack trace. They were + // already decoded by the cheap routing projection, so deserialize only the survivor-only + // fields and reuse those references instead of allocating a second LOH-sized stack string. + _materializedEvent = new EventIngestionV3Event + { + Id = RoutingEvent.Id, + Type = RoutingEvent.Type, + Date = payload.Date, + Source = RoutingEvent.Source, + Message = payload.Message, + ReferenceId = payload.ReferenceId, + Value = payload.Value, + Tags = payload.Tags, + Version = payload.Version, + Level = payload.Level, + Client = payload.Client, + ExceptionType = RoutingEvent.ExceptionType, + StackTrace = RoutingEvent.StackTrace, + Stacking = RoutingEvent.Stacking is null + ? null + : new EventIngestionV3Stacking + { + Title = payload.Stacking?.Title, + SignatureData = RoutingEvent.Stacking.SignatureData + }, + User = payload.User, + Request = payload.Request, + Environment = payload.Environment, + Data = payload.Data + }; + return _materializedEvent; + } + + public void Dispose() + { + _owner?.Dispose(); + _owner = null; + } +} + +/// +/// The fields that are needed only after discard routing. Routing properties are intentionally +/// absent so System.Text.Json skips their values without decoding duplicate strings. +/// +internal sealed record EventIngestionV3SurvivorPayload +{ + public DateTimeOffset? Date { get; init; } + public string? Message { get; init; } + public string? ReferenceId { get; init; } + public decimal? Value { get; init; } + public string[]? Tags { get; init; } + public string? Version { get; init; } + public string? Level { get; init; } + public EventIngestionV3Client? Client { get; init; } + public EventIngestionV3SurvivorStacking? Stacking { get; init; } + public EventIngestionV3User? User { get; init; } + public EventIngestionV3Request? Request { get; init; } + public EventIngestionV3Environment? Environment { get; init; } + public JsonElement? Data { get; init; } +} + +internal sealed record EventIngestionV3SurvivorStacking +{ + public string? Title { get; init; } +} diff --git a/src/Exceptionless.Core/Models/Ingestion/EventIngestionV3Event.cs b/src/Exceptionless.Core/Models/Ingestion/EventIngestionV3Event.cs new file mode 100644 index 0000000000..2646371490 --- /dev/null +++ b/src/Exceptionless.Core/Models/Ingestion/EventIngestionV3Event.cs @@ -0,0 +1,125 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json; + +namespace Exceptionless.Core.Models.Ingestion; + +/// +/// A compact V3 ingestion event. Organization and project ownership come from +/// the authenticated request rather than the event payload. +/// +public sealed record EventIngestionV3Event +{ + [Required] + [StringLength(EventIngestionV3Limits.MaximumEventIdLength, MinimumLength = 1)] + public string Id { get; init; } = null!; + + [Required] + [StringLength(EventIngestionV3Limits.MaximumTypeLength, MinimumLength = 1)] + public string Type { get; init; } = null!; + + public DateTimeOffset? Date { get; init; } + + [StringLength(EventIngestionV3Limits.MaximumSourceLength, MinimumLength = 1)] + public string? Source { get; init; } + + [StringLength(EventIngestionV3Limits.MaximumMessageLength, MinimumLength = 1)] + public string? Message { get; init; } + + [StringLength(EventIngestionV3Limits.MaximumReferenceIdLength, MinimumLength = EventIngestionV3Limits.MinimumReferenceIdLength)] + public string? ReferenceId { get; init; } + + public decimal? Value { get; init; } + + [MaxLength(EventIngestionV3Limits.MaximumTags)] + public string[]? Tags { get; init; } + + [StringLength(EventIngestionV3Limits.MaximumVersionLength, MinimumLength = 1)] + public string? Version { get; init; } + + [StringLength(EventIngestionV3Limits.MaximumLevelLength, MinimumLength = 1)] + public string? Level { get; init; } + + public EventIngestionV3Client? Client { get; init; } + + [StringLength(EventIngestionV3Limits.MaximumExceptionTypeLength, MinimumLength = 1)] + public string? ExceptionType { get; init; } + + [StringLength(EventIngestionV3Limits.MaximumStackTraceLength, MinimumLength = 1)] + public string? StackTrace { get; init; } + + public EventIngestionV3Stacking? Stacking { get; init; } + + public EventIngestionV3User? User { get; init; } + + public EventIngestionV3Request? Request { get; init; } + + public EventIngestionV3Environment? Environment { get; init; } + + public JsonElement? Data { get; init; } +} + +public sealed record EventIngestionV3Client +{ + [Required] + [StringLength(EventIngestionV3Limits.MaximumClientNameLength, MinimumLength = 1)] + public string Name { get; init; } = null!; + + [Required] + [StringLength(EventIngestionV3Limits.MaximumClientVersionLength, MinimumLength = 1)] + public string Version { get; init; } = null!; +} + +public sealed record EventIngestionV3Stacking +{ + [StringLength(EventIngestionV3Limits.MaximumStackTitleLength, MinimumLength = 1)] + public string? Title { get; init; } + + [Required] + public Dictionary SignatureData { get; init; } = null!; +} + +public sealed record EventIngestionV3User +{ + [StringLength(EventIngestionV3Limits.MaximumUserIdentityLength, MinimumLength = 1)] + public string? Identity { get; init; } + + [StringLength(EventIngestionV3Limits.MaximumUserNameLength, MinimumLength = 1)] + public string? Name { get; init; } + + public JsonElement? Data { get; init; } +} + +public sealed record EventIngestionV3Request +{ + public string? UserAgent { get; init; } + public string? HttpMethod { get; init; } + public bool? IsSecure { get; init; } + public string? Host { get; init; } + public int? Port { get; init; } + public string? Path { get; init; } + public string? Referrer { get; init; } + public string? ClientIpAddress { get; init; } + public Dictionary? Headers { get; init; } + public Dictionary? Cookies { get; init; } + public Dictionary? QueryString { get; init; } + public JsonElement? PostData { get; init; } + public JsonElement? Data { get; init; } +} + +public sealed record EventIngestionV3Environment +{ + public string? Architecture { get; init; } + public string? OSName { get; init; } + public string? OSVersion { get; init; } + public string? MachineName { get; init; } + public string? RuntimeVersion { get; init; } + public string? ProcessName { get; init; } + public string? ProcessId { get; init; } + public string? ThreadName { get; init; } + public string? ThreadId { get; init; } + public int? ProcessorCount { get; init; } + public long? TotalPhysicalMemory { get; init; } + public long? AvailablePhysicalMemory { get; init; } + public long? ProcessMemorySize { get; init; } + public JsonElement? Data { get; init; } +} diff --git a/src/Exceptionless.Core/Models/Ingestion/EventIngestionV3EventSizer.cs b/src/Exceptionless.Core/Models/Ingestion/EventIngestionV3EventSizer.cs new file mode 100644 index 0000000000..fdedfe630a --- /dev/null +++ b/src/Exceptionless.Core/Models/Ingestion/EventIngestionV3EventSizer.cs @@ -0,0 +1,142 @@ +using System.Text; +using System.Text.Json; + +namespace Exceptionless.Core.Models.Ingestion; + +public static class EventIngestionV3EventSizer +{ + public static long GetEstimatedSize(EventIngestionV3Event source) + { + long size = 128; + size += GetSize(source.Id) + GetSize(source.Type) + GetSize(source.Source) + GetSize(source.Message); + size += GetSize(source.ReferenceId) + GetSize(source.ExceptionType) + GetSize(source.StackTrace); + size += GetSize(source.Version) + GetSize(source.Level); + size += GetSize(source.Tags); + size += GetSize(source.Data); + if (source.Client is not null) + { + size += 32 + GetSize(source.Client.Name) + GetSize(source.Client.Version); + } + + if (source.Stacking is not null) + { + size += 32 + GetSize(source.Stacking.Title) + GetSize(source.Stacking.SignatureData); + } + + if (source.User is not null) + { + size += 32 + GetSize(source.User.Identity) + GetSize(source.User.Name) + GetSize(source.User.Data); + } + + if (source.Request is not null) + { + size += 96 + GetSize(source.Request.UserAgent) + GetSize(source.Request.HttpMethod) + GetSize(source.Request.Host); + size += GetSize(source.Request.Path) + GetSize(source.Request.Referrer) + GetSize(source.Request.ClientIpAddress); + size += GetSize(source.Request.Headers) + GetSize(source.Request.Cookies) + GetSize(source.Request.QueryString); + size += GetSize(source.Request.PostData) + GetSize(source.Request.Data); + } + if (source.Environment is not null) + { + size += 96 + GetSize(source.Environment.Architecture) + GetSize(source.Environment.OSName); + size += GetSize(source.Environment.OSVersion) + GetSize(source.Environment.MachineName); + size += GetSize(source.Environment.RuntimeVersion) + GetSize(source.Environment.ProcessName); + size += GetSize(source.Environment.ProcessId) + GetSize(source.Environment.ThreadName); + size += GetSize(source.Environment.ThreadId) + GetSize(source.Environment.Data); + } + + return size; + } + + private static int GetSize(string? value) => value is null ? 0 : Encoding.UTF8.GetByteCount(value); + + private static long GetSize(IEnumerable? values) + { + if (values is null) + { + return 0; + } + + long size = 0; + foreach (string value in values) + { + size += GetSize(value); + } + + return size; + } + + private static long GetSize(IReadOnlyDictionary? values) + { + if (values is null) + { + return 0; + } + + long size = 0; + foreach (var pair in values) + { + size += GetSize(pair.Key) + GetSize(pair.Value); + } + + return size; + } + + private static long GetSize(IReadOnlyDictionary? values) + { + if (values is null) + { + return 0; + } + + long size = 0; + foreach (var pair in values) + { + size += GetSize(pair.Key) + GetSize(pair.Value); + } + + return size; + } + + private static long GetSize(JsonElement? element) + { + if (element is null) + { + return 0; + } + + return GetSize(element.Value); + } + + private static long GetSize(JsonElement element) + { + if (element.ValueKind == JsonValueKind.Object) + { + long size = 0; + foreach (JsonProperty property in element.EnumerateObject()) + { + size += GetSize(property.Name) + GetSize(property.Value) + 4; + } + + return size; + } + if (element.ValueKind == JsonValueKind.Array) + { + long size = 0; + foreach (JsonElement item in element.EnumerateArray()) + { + size += GetSize(item) + 1; + } + + return size; + } + + return element.ValueKind switch + { + JsonValueKind.String => GetSize(element.GetString()), + JsonValueKind.Number => 32, + JsonValueKind.True or JsonValueKind.False => 5, + JsonValueKind.Null or JsonValueKind.Undefined => 4, + _ => 0 + }; + } +} diff --git a/src/Exceptionless.Core/Models/Ingestion/EventIngestionV3Limits.cs b/src/Exceptionless.Core/Models/Ingestion/EventIngestionV3Limits.cs new file mode 100644 index 0000000000..3de2932fca --- /dev/null +++ b/src/Exceptionless.Core/Models/Ingestion/EventIngestionV3Limits.cs @@ -0,0 +1,28 @@ +namespace Exceptionless.Core.Models.Ingestion; + +public static class EventIngestionV3Limits +{ + public const int MaximumEventIdLength = 100; + public const int MaximumTypeLength = 100; + public const int MaximumSourceLength = 2000; + public const int MaximumMessageLength = 2000; + public const int MinimumReferenceIdLength = 8; + public const int MaximumReferenceIdLength = 100; + public const int MaximumExceptionTypeLength = 2000; + public const int MaximumStackTraceLength = 256 * 1024; + public const int MaximumUserIdentityLength = 255; + public const int MaximumUserNameLength = 255; + public const int MaximumVersionLength = 255; + public const int MaximumLevelLength = 32; + public const int MaximumClientNameLength = 255; + public const int MaximumClientVersionLength = 255; + public const int MaximumStackTitleLength = 1000; + public const int MaximumTags = 50; + public const int MaximumTagLength = 100; + public const int MaximumMetadataEntries = 100; + public const int MaximumMetadataKeyLength = 255; + public const int MaximumMetadataValueLength = 16 * 1024; + public const int MaximumDataTokens = 2000; + public const int MaximumDataStringLength = 64 * 1024; + public const int MaximumJsonDepth = 32; +} diff --git a/src/Exceptionless.Core/Models/Ingestion/EventIngestionV3Response.cs b/src/Exceptionless.Core/Models/Ingestion/EventIngestionV3Response.cs new file mode 100644 index 0000000000..3ac510bc30 --- /dev/null +++ b/src/Exceptionless.Core/Models/Ingestion/EventIngestionV3Response.cs @@ -0,0 +1,30 @@ +namespace Exceptionless.Core.Models.Ingestion; + +public sealed record EventIngestionV3Response +{ + public int Received { get; set; } + public int Persisted { get; set; } + public int Discarded { get; set; } + public int Duplicate { get; set; } + public int Blocked { get; set; } + public int Invalid { get; set; } + public List Errors { get; init; } = []; + + public void Add(EventIngestionV3Response other) + { + Received += other.Received; + Persisted += other.Persisted; + Discarded += other.Discarded; + Duplicate += other.Duplicate; + Blocked += other.Blocked; + Invalid += other.Invalid; + + int remaining = 100 - Errors.Count; + if (remaining > 0 && other.Errors.Count > 0) + { + Errors.AddRange(other.Errors.Take(remaining)); + } + } +} + +public sealed record EventIngestionV3Error(string Id, string Code, string Message); diff --git a/src/Exceptionless.Core/Models/Ingestion/EventIngestionWrite.cs b/src/Exceptionless.Core/Models/Ingestion/EventIngestionWrite.cs new file mode 100644 index 0000000000..dc6ae8d0a0 --- /dev/null +++ b/src/Exceptionless.Core/Models/Ingestion/EventIngestionWrite.cs @@ -0,0 +1,47 @@ +using Exceptionless.Core.Models; + +namespace Exceptionless.Core.Models.Ingestion; + +public sealed record EventIngestionWrite( + string ClientId, + PersistentEvent Event, + StackFingerprint Fingerprint, + StackRoute? Route, + bool IsRegressionCandidate = false); + +public sealed record EventIngestionIdentity( + string ClientId, + string EventId, + bool IsDuplicate, + bool IsPersisted, + string? PersistedStackId, + StackStatus? PersistedStackStatus, + bool IsRecoveryEligible, + DateTime CreatedUtc, + DateTimeOffset EventDate); + +public sealed record EventIngestionReconciliation( + string EventId, + string StackId); + +public sealed record EventUsageSettlement(string EventId, DateTime CreatedUtc); + +public sealed record EventBatchWriteResult( + int Persisted, + int Duplicate, + IReadOnlyCollection Settlements); + +public sealed record EventIngestionReservation( + string Id, + string OrganizationId, + int Count, + bool IsUnlimited = false) +{ + public static EventIngestionReservation Unlimited(string organizationId, int count) => new(String.Empty, organizationId, count, true); +} + +public sealed class EventBatchWriteException(Exception innerException, IReadOnlyCollection settlements) + : Exception("Event persistence completed only partially.", innerException) +{ + public IReadOnlyCollection Settlements { get; } = settlements; +} diff --git a/src/Exceptionless.Core/Models/Ingestion/StackFingerprint.cs b/src/Exceptionless.Core/Models/Ingestion/StackFingerprint.cs new file mode 100644 index 0000000000..b198b30616 --- /dev/null +++ b/src/Exceptionless.Core/Models/Ingestion/StackFingerprint.cs @@ -0,0 +1,3 @@ +namespace Exceptionless.Core.Models.Ingestion; + +public sealed record StackFingerprint(string SignatureHash, IReadOnlyDictionary SignatureData, string? Title = null); diff --git a/src/Exceptionless.Core/Models/Ingestion/StackRoute.cs b/src/Exceptionless.Core/Models/Ingestion/StackRoute.cs new file mode 100644 index 0000000000..3e1f74c5cb --- /dev/null +++ b/src/Exceptionless.Core/Models/Ingestion/StackRoute.cs @@ -0,0 +1,43 @@ +using Exceptionless.Core.Models; + +namespace Exceptionless.Core.Models.Ingestion; + +public sealed record StackRoute( + string StackId, + StackStatus Status, + long Version, + string? FixedInVersion = null, + DateTime? DateFixed = null, + bool OccurrencesAreCritical = false, + string? RegressionEventId = null, + string? IngestionFirstEventId = null) +{ + public bool IsDiscarded => Status is StackStatus.Discarded; +} + +public sealed record StackRouteCacheEntry( + bool Exists, + long Version, + string? StackId = null, + StackStatus Status = StackStatus.Open, + string? FixedInVersion = null, + DateTime? DateFixed = null, + bool OccurrencesAreCritical = false, + string? RegressionEventId = null, + string? IngestionFirstEventId = null) +{ + public StackRoute? ToRoute() => Exists && StackId is not null + ? new StackRoute(StackId, Status, Version, FixedInVersion, DateFixed, OccurrencesAreCritical, RegressionEventId, IngestionFirstEventId) + : null; + + public static StackRouteCacheEntry FromRoute(StackRoute route) => new( + true, + route.Version, + route.StackId, + route.Status, + route.FixedInVersion, + route.DateFixed, + route.OccurrencesAreCritical, + route.RegressionEventId, + route.IngestionFirstEventId); +} diff --git a/src/Exceptionless.Core/Models/Organization.cs b/src/Exceptionless.Core/Models/Organization.cs index 0a92a03660..34319b2434 100644 --- a/src/Exceptionless.Core/Models/Organization.cs +++ b/src/Exceptionless.Core/Models/Organization.cs @@ -169,6 +169,11 @@ public Organization() public ICollection Usage { get; set; } public DateTime? LastEventDateUtc { get; set; } + /// + /// Most recent five-minute usage bucket durably applied to this organization. + /// + public DateTime? LastAppliedUsageBucketUtc { get; set; } + /// /// Optional data entries that contain additional configuration information for this organization. /// @@ -280,4 +285,3 @@ public enum BillingStatus Unpaid = 4 } - diff --git a/src/Exceptionless.Core/Models/PersistentEvent.cs b/src/Exceptionless.Core/Models/PersistentEvent.cs index 968a4acd37..6c1b88a33a 100644 --- a/src/Exceptionless.Core/Models/PersistentEvent.cs +++ b/src/Exceptionless.Core/Models/PersistentEvent.cs @@ -1,5 +1,7 @@ +using System.ComponentModel; using System.ComponentModel.DataAnnotations; using System.Diagnostics; +using System.Text.Json.Serialization; using Exceptionless.Core.Attributes; using Exceptionless.Core.Extensions; using Foundatio.Repositories.Models; @@ -41,6 +43,32 @@ public class PersistentEvent : Event, IOwnedByOrganizationAndProjectAndStackWith /// public bool IsFirstOccurrence { get; set; } + /// + /// Whether this occurrence caused a fixed stack to regress. + /// + [ReadOnly(true)] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + public bool IsRegression { get; set; } + + /// + /// Records that this occurrence was selected to regress the fixed stack generation + /// observed during ingestion. This makes a retry independent of the replay payload. + /// + [ReadOnly(true)] + [ApiIgnore] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + public bool IngestionIsRegressionCandidate { get; set; } + + [ReadOnly(true)] + [ApiIgnore] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + public string? IngestionRegressionFixedInVersion { get; set; } + + [ReadOnly(true)] + [ApiIgnore] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + public DateTime IngestionRegressionDateFixed { get; set; } + /// /// The date that the event was created in the system. /// diff --git a/src/Exceptionless.Core/Models/Project.cs b/src/Exceptionless.Core/Models/Project.cs index 4ac783e893..794bc496f1 100644 --- a/src/Exceptionless.Core/Models/Project.cs +++ b/src/Exceptionless.Core/Models/Project.cs @@ -52,6 +52,11 @@ public Project() public ICollection Usage { get; set; } public DateTime? LastEventDateUtc { get; set; } + /// + /// Most recent five-minute usage bucket durably applied to this project. + /// + public DateTime? LastAppliedUsageBucketUtc { get; set; } + /// /// Optional data entries that contain additional configuration information for this project. /// diff --git a/src/Exceptionless.Core/Models/Queues/EventNotification.cs b/src/Exceptionless.Core/Models/Queues/EventNotification.cs index f5d732f7ad..6d3d7425ba 100644 --- a/src/Exceptionless.Core/Models/Queues/EventNotification.cs +++ b/src/Exceptionless.Core/Models/Queues/EventNotification.cs @@ -1,9 +1,16 @@ -namespace Exceptionless.Core.Queues.Models; +using System.Text.Json.Serialization; +using Exceptionless.Core.Queues; -public record EventNotification +namespace Exceptionless.Core.Queues.Models; + +public record EventNotification : IHaveDurableUniqueIdentifier { public required string EventId { get; set; } public required bool IsNew { get; set; } public required bool IsRegression { get; set; } public required int TotalOccurrences { get; set; } + public string DeduplicationId { get; set; } = Guid.NewGuid().ToString("N"); + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + public bool UseDurableDeduplication { get; set; } + public string UniqueIdentifier => DeduplicationId; } diff --git a/src/Exceptionless.Core/Models/Queues/EventPostInfo.cs b/src/Exceptionless.Core/Models/Queues/EventPostInfo.cs index 5ba9ecb819..4930ee2085 100644 --- a/src/Exceptionless.Core/Models/Queues/EventPostInfo.cs +++ b/src/Exceptionless.Core/Models/Queues/EventPostInfo.cs @@ -1,4 +1,6 @@ -namespace Exceptionless.Core.Queues.Models; +using System.Text.Json.Serialization; + +namespace Exceptionless.Core.Queues.Models; public record EventPostInfo { @@ -20,5 +22,9 @@ public EventPost(bool shouldArchive) } public bool ShouldArchive { get; init; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + public bool TrackProcessing { get; init; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + public string? ProcessingCorrelationId { get; init; } public string FilePath { get; set; } = null!; } diff --git a/src/Exceptionless.Core/Models/Queues/WebHookNotification.cs b/src/Exceptionless.Core/Models/Queues/WebHookNotification.cs index ee0e849518..6a4f9e8bee 100644 --- a/src/Exceptionless.Core/Models/Queues/WebHookNotification.cs +++ b/src/Exceptionless.Core/Models/Queues/WebHookNotification.cs @@ -1,6 +1,9 @@ -namespace Exceptionless.Core.Queues.Models; +using System.Text.Json.Serialization; +using Exceptionless.Core.Queues; -public record WebHookNotification +namespace Exceptionless.Core.Queues.Models; + +public record WebHookNotification : IHaveDurableUniqueIdentifier { public required string OrganizationId { get; set; } public required string ProjectId { get; set; } @@ -8,6 +11,10 @@ public record WebHookNotification public required WebHookType Type { get; set; } = WebHookType.General; public required string Url { get; set; } public required object? Data { get; set; } + public string DeduplicationId { get; set; } = Guid.NewGuid().ToString("N"); + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + public bool UseDurableDeduplication { get; set; } + public string UniqueIdentifier => DeduplicationId; } public enum WebHookType diff --git a/src/Exceptionless.Core/Models/Stack.cs b/src/Exceptionless.Core/Models/Stack.cs index 911835df31..cb451f283d 100644 --- a/src/Exceptionless.Core/Models/Stack.cs +++ b/src/Exceptionless.Core/Models/Stack.cs @@ -1,4 +1,5 @@ using System.Collections.ObjectModel; +using System.ComponentModel; using System.ComponentModel.DataAnnotations; using System.Diagnostics; using System.Runtime.Serialization; @@ -70,6 +71,23 @@ public class Stack : IOwnedByOrganizationAndProjectWithIdentity, IHaveDates, ISu /// public DateTime? DateFixed { get; set; } + /// + /// The event that most recently caused this stack to regress. + /// + [ObjectId] + public string? RegressionEventId { get; set; } + + /// + /// Durable identity of the event selected as the first occurrence while V3 creates a stack. + /// A retry can therefore recover first-occurrence side effects if the process stops after the + /// stack write but before the event write. + /// + [ObjectId] + [ReadOnly(true)] + [ApiIgnore] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? IngestionFirstEventId { get; set; } + /// /// The stack title. /// @@ -81,6 +99,13 @@ public class Stack : IOwnedByOrganizationAndProjectWithIdentity, IHaveDates, ISu /// public int TotalOccurrences { get; set; } + /// + /// Monotonic fence for idempotent V3 aggregate settlements. This is persisted with the stack + /// so full-document saves cannot erase the last applied settlement identity. + /// + [ApiIgnore] + public long IngestionStackUsageSequence { get; set; } + /// /// The date of the 1st occurrence of this stack in UTC time. /// diff --git a/src/Exceptionless.Core/Models/WorkItems/EventIngestionSideEffectsWorkItem.cs b/src/Exceptionless.Core/Models/WorkItems/EventIngestionSideEffectsWorkItem.cs new file mode 100644 index 0000000000..bd7de060c1 --- /dev/null +++ b/src/Exceptionless.Core/Models/WorkItems/EventIngestionSideEffectsWorkItem.cs @@ -0,0 +1,12 @@ +using Foundatio.Queues; + +namespace Exceptionless.Core.Models.WorkItems; + +public sealed record EventIngestionSideEffectsWorkItem : IHaveUniqueIdentifier +{ + public required string OrganizationId { get; init; } + public required string ProjectId { get; init; } + public required string BatchId { get; init; } + public required string[] EventIds { get; init; } + public string UniqueIdentifier => String.Concat("event-ingestion-v3:", ProjectId, ":", BatchId); +} diff --git a/src/Exceptionless.Core/Pipeline/070_QueueNotificationAction.cs b/src/Exceptionless.Core/Pipeline/070_QueueNotificationAction.cs index 9c9a6b8fa4..cfbd490a20 100644 --- a/src/Exceptionless.Core/Pipeline/070_QueueNotificationAction.cs +++ b/src/Exceptionless.Core/Pipeline/070_QueueNotificationAction.cs @@ -4,6 +4,7 @@ using Exceptionless.Core.Queues.Models; using Exceptionless.Core.Repositories; using Foundatio.Queues; +using Foundatio.Repositories.Models; using Microsoft.Extensions.Logging; namespace Exceptionless.Core.Pipeline; @@ -29,27 +30,80 @@ public override async Task ProcessAsync(EventContext ctx) { // if they don't have premium features, then we don't need to queue notifications if (!ctx.Organization.HasPremiumFeatures) + { return; + } if (ctx.Stack is null || !ctx.Stack.AllowNotifications) + { return; + } if (ShouldQueueNotification(ctx)) - await _notificationQueue.EnqueueAsync(new EventNotification + { + await QueueEventNotificationAsync(ctx); + } + + var webHooks = await GetWebHooksAsync(ctx.Event.OrganizationId, ctx.Event.ProjectId); + await QueueWebHooksAsync(ctx, webHooks.Documents); + } + + /// + /// Queues V3 notifications as a bounded, sequential batch. Webhooks are loaded once for each + /// organization/project pair and queue failures are allowed to escape so the side-effect worker + /// can retry the batch. Deterministic queue identifiers suppress already-enqueued work on retry. + /// + public async Task ProcessIngestionV3BatchAsync(IReadOnlyCollection contexts) + { + ArgumentNullException.ThrowIfNull(contexts); + + var eligibleContexts = contexts + .Where(ctx => ctx.Organization.HasPremiumFeatures && ctx.Stack?.AllowNotifications is true) + .ToArray(); + + foreach (var group in eligibleContexts.GroupBy(ctx => (ctx.Event.OrganizationId, ctx.Event.ProjectId))) + { + var webHooks = await GetWebHooksAsync(group.Key.OrganizationId, group.Key.ProjectId); + foreach (var ctx in group) { - EventId = ctx.Event.Id, - IsNew = ctx.IsNew, - IsRegression = ctx.IsRegression, - TotalOccurrences = ctx.Stack.TotalOccurrences - }); + if (ShouldQueueNotification(ctx)) + { + await QueueEventNotificationAsync(ctx); + } - var webHooks = await _webHookRepository.GetByOrganizationIdOrProjectIdAsync(ctx.Event.OrganizationId, ctx.Event.ProjectId); - foreach (var hook in webHooks.Documents) + await QueueWebHooksAsync(ctx, webHooks.Documents); + } + } + } + + private Task> GetWebHooksAsync(string organizationId, string projectId) + { + return _webHookRepository.GetByOrganizationIdOrProjectIdAsync(organizationId, projectId); + } + + private Task QueueEventNotificationAsync(EventContext ctx) + { + return _notificationQueue.EnqueueAsync(new EventNotification + { + EventId = ctx.Event.Id, + IsNew = ctx.IsNew, + IsRegression = ctx.IsRegression, + TotalOccurrences = ctx.Stack!.TotalOccurrences, + DeduplicationId = String.Concat("event-notification:", ctx.Event.Id), + UseDurableDeduplication = ctx.IsIngestionV3 + }); + } + + private async Task QueueWebHooksAsync(EventContext ctx, IReadOnlyCollection webHooks) + { + foreach (var hook in webHooks) { if (!ShouldCallWebHook(hook, ctx)) + { continue; + } - var context = new WebHookDataContext(hook, ctx.Organization, ctx.Project, ctx.Stack, ctx.Event, ctx.IsNew, ctx.IsRegression); + var context = new WebHookDataContext(hook, ctx.Organization, ctx.Project, ctx.Stack!, ctx.Event, ctx.IsNew, ctx.IsRegression); var notification = new WebHookNotification { OrganizationId = ctx.Event.OrganizationId, @@ -57,7 +111,9 @@ await _notificationQueue.EnqueueAsync(new EventNotification WebHookId = hook.Id, Url = hook.Url, Type = WebHookType.General, - Data = await _webHookDataPluginManager.CreateFromEventAsync(context) + Data = await _webHookDataPluginManager.CreateFromEventAsync(context), + DeduplicationId = String.Concat("event-webhook:", ctx.Event.Id, ":", hook.Id, ":", WebHookType.General), + UseDurableDeduplication = ctx.IsIngestionV3 }; if (notification.Data is null) @@ -74,25 +130,39 @@ await _notificationQueue.EnqueueAsync(new EventNotification private bool ShouldCallWebHook(WebHook hook, EventContext ctx) { if (!hook.IsEnabled) + { return false; + } if (!String.IsNullOrEmpty(hook.ProjectId) && !String.Equals(ctx.Project.Id, hook.ProjectId)) + { return false; + } if (ctx.IsNew && ctx.Event.IsError() && hook.EventTypes.Contains(WebHook.KnownEventTypes.NewError)) + { return true; + } if (ctx.Event.IsCritical() && ctx.Event.IsError() && hook.EventTypes.Contains(WebHook.KnownEventTypes.CriticalError)) + { return true; + } if (ctx.IsRegression && hook.EventTypes.Contains(WebHook.KnownEventTypes.StackRegression)) + { return true; + } if (ctx.IsNew && hook.EventTypes.Contains(WebHook.KnownEventTypes.NewEvent)) + { return true; + } if (ctx.Event.IsCritical() && hook.EventTypes.Contains(WebHook.KnownEventTypes.CriticalEvent)) + { return true; + } return false; } @@ -100,22 +170,34 @@ private bool ShouldCallWebHook(WebHook hook, EventContext ctx) private bool ShouldQueueNotification(EventContext ctx) { if (ctx.Project.NotificationSettings.Count == 0) + { return false; + } if (ctx.IsNew && ctx.Event.IsError() && ctx.Project.NotificationSettings.Any(n => n.Value.ReportNewErrors)) + { return true; + } if (ctx.Event.IsCritical() && ctx.Event.IsError() && ctx.Project.NotificationSettings.Any(n => n.Value.ReportCriticalErrors)) + { return true; + } if (ctx.IsRegression && ctx.Project.NotificationSettings.Any(n => n.Value.ReportEventRegressions)) + { return true; + } if (ctx.IsNew && ctx.Project.NotificationSettings.Any(n => n.Value.ReportNewEvents)) + { return true; + } if (ctx.Event.IsCritical() && ctx.Project.NotificationSettings.Any(n => n.Value.ReportCriticalEvents)) + { return true; + } return false; } diff --git a/src/Exceptionless.Core/Pipeline/100_RunEventProcessedPluginsAction.cs b/src/Exceptionless.Core/Pipeline/100_RunEventProcessedPluginsAction.cs index 2b763bc176..856ce8c494 100644 --- a/src/Exceptionless.Core/Pipeline/100_RunEventProcessedPluginsAction.cs +++ b/src/Exceptionless.Core/Pipeline/100_RunEventProcessedPluginsAction.cs @@ -18,4 +18,5 @@ public override Task ProcessBatchAsync(ICollection contexts) { return _pluginManager.EventBatchProcessedAsync(contexts); } + } diff --git a/src/Exceptionless.Core/Plugins/EventProcessor/Default/40_RequestInfoPlugin.cs b/src/Exceptionless.Core/Plugins/EventProcessor/Default/40_RequestInfoPlugin.cs index 7b9ecc2b14..c62de37821 100644 --- a/src/Exceptionless.Core/Plugins/EventProcessor/Default/40_RequestInfoPlugin.cs +++ b/src/Exceptionless.Core/Plugins/EventProcessor/Default/40_RequestInfoPlugin.cs @@ -11,18 +11,8 @@ namespace Exceptionless.Core.Plugins.EventProcessor; [Priority(40)] public sealed class RequestInfoPlugin : EventProcessorPluginBase { - public const int MAX_VALUE_LENGTH = 1000; - public static readonly List DefaultExclusions = - [ - "*VIEWSTATE*", - "*EVENTVALIDATION*", - "*ASPX*", - "__RequestVerificationToken", - "ASP.NET_SessionId", - "__LastErrorId", - "WAWebSiteID", - "ARRAffinity" - ]; + public const int MAX_VALUE_LENGTH = RequestInfoExtensions.MaximumDataValueLength; + public static readonly List DefaultExclusions = [.. RequestInfoExtensions.DefaultDataExclusions]; private readonly UserAgentParser _parser; private readonly ITextSerializer _serializer; @@ -41,7 +31,9 @@ public override async Task EventBatchProcessingAsync(ICollection c { var request = context.Event.GetRequestInfo(_serializer, _logger); if (request is null) + { continue; + } if (context.IncludePrivateInformation) { @@ -72,7 +64,9 @@ private void AddClientIpAddress(RequestInfo request, SubmissionClient? submissio { bool requestIpIsLocal = submissionClient.IpAddress.IsLocalHost(); if (ips.Count == 0 || !requestIpIsLocal && ips.Count(ip => !ip.IsLocalHost()) == 0) + { ips.Add(submissionClient.IpAddress); + } } request.ClientIpAddress = ips.Distinct().ToDelimitedString(); @@ -81,11 +75,15 @@ private void AddClientIpAddress(RequestInfo request, SubmissionClient? submissio private async Task SetBrowserOsAndDeviceFromUserAgent(RequestInfo request, EventContext context) { if (String.IsNullOrEmpty(request.UserAgent)) + { return; + } var info = await _parser.ParseAsync(request.UserAgent); if (info is null) + { return; + } request.Data ??= new DataDictionary(); if (!String.Equals(info.UA.Family, "Other")) @@ -99,7 +97,9 @@ private async Task SetBrowserOsAndDeviceFromUserAgent(RequestInfo request, Event } if (!String.Equals(info.Device.Family, "Other")) + { request.Data[RequestInfo.KnownDataKeys.Device] = info.Device.Family; + } if (!String.Equals(info.OS.Family, "Other")) { diff --git a/src/Exceptionless.Core/Plugins/EventProcessor/EventContext.cs b/src/Exceptionless.Core/Plugins/EventProcessor/EventContext.cs index 5b348b016c..1f54920437 100644 --- a/src/Exceptionless.Core/Plugins/EventProcessor/EventContext.cs +++ b/src/Exceptionless.Core/Plugins/EventProcessor/EventContext.cs @@ -27,6 +27,7 @@ public EventContext(PersistentEvent ev, Organization organization, Project proje public bool IsDiscarded { get; set; } public bool IsNew { get; set; } public bool IsRegression { get; set; } + public bool IsIngestionV3 { get; set; } public bool IncludePrivateInformation { get; set; } public bool AllowExtendedEventDateRange { get; set; } public string? SignatureHash { get; set; } diff --git a/src/Exceptionless.Core/Plugins/EventProcessor/EventPluginManager.cs b/src/Exceptionless.Core/Plugins/EventProcessor/EventPluginManager.cs index 08d91136d8..0e86c93cbc 100644 --- a/src/Exceptionless.Core/Plugins/EventProcessor/EventPluginManager.cs +++ b/src/Exceptionless.Core/Plugins/EventProcessor/EventPluginManager.cs @@ -34,7 +34,9 @@ public async Task EventBatchProcessingAsync(ICollection contexts) { var contextsToProcess = contexts.Where(c => c is { IsCancelled: false, HasError: false }).ToList(); if (contextsToProcess.Count == 0) + { break; + } string metricName = String.Concat(_metricPrefix, plugin.Name.ToLower()); try @@ -42,7 +44,9 @@ public async Task EventBatchProcessingAsync(ICollection contexts) await AppDiagnostics.TimeAsync(() => plugin.EventBatchProcessingAsync(contextsToProcess), metricName); if (contextsToProcess.All(c => c.IsCancelled || c.HasError)) + { break; + } } catch (Exception ex) { @@ -60,7 +64,9 @@ public async Task EventBatchProcessedAsync(ICollection contexts) { var contextsToProcess = contexts.Where(c => c is { IsCancelled: false, HasError: false }).ToList(); if (contextsToProcess.Count == 0) + { break; + } string metricName = String.Concat(_metricPrefix, plugin.Name.ToLower()); try @@ -68,7 +74,9 @@ public async Task EventBatchProcessedAsync(ICollection contexts) await AppDiagnostics.TimeAsync(() => plugin.EventBatchProcessedAsync(contextsToProcess), metricName); if (contextsToProcess.All(c => c.IsCancelled || c.HasError)) + { break; + } } catch (Exception ex) { @@ -76,4 +84,5 @@ public async Task EventBatchProcessedAsync(ICollection contexts) } } } + } diff --git a/src/Exceptionless.Core/Properties/AssemblyInfo.cs b/src/Exceptionless.Core/Properties/AssemblyInfo.cs index 2941889e70..5dc06e1737 100644 --- a/src/Exceptionless.Core/Properties/AssemblyInfo.cs +++ b/src/Exceptionless.Core/Properties/AssemblyInfo.cs @@ -1,5 +1,6 @@ using System.Runtime.CompilerServices; [assembly: InternalsVisibleTo("Exceptionless.Tests")] +[assembly: InternalsVisibleTo("Exceptionless.Benchmarks")] [assembly: InternalsVisibleTo("Exceptionless.Web")] [assembly: InternalsVisibleTo("Exceptionless.Insulation")] diff --git a/src/Exceptionless.Core/Queues/ConditionalDuplicateDetectionQueueBehavior.cs b/src/Exceptionless.Core/Queues/ConditionalDuplicateDetectionQueueBehavior.cs new file mode 100644 index 0000000000..2e0fe58ece --- /dev/null +++ b/src/Exceptionless.Core/Queues/ConditionalDuplicateDetectionQueueBehavior.cs @@ -0,0 +1,48 @@ +using Foundatio.Caching; +using Foundatio.Queues; +using Microsoft.Extensions.Logging; + +namespace Exceptionless.Core.Queues; + +/// +/// Preserves Foundatio's dequeue-scoped duplicate detection for legacy messages while allowing +/// durable messages to be claimed exclusively by . +/// +public class ConditionalDuplicateDetectionQueueBehavior( + ICacheClient cache, + ILoggerFactory loggerFactory, + TimeSpan detectionWindow) : QueueBehaviorBase where T : class +{ + private readonly ILogger _logger = loggerFactory.CreateLogger(); + + protected override async Task OnEnqueuing(object sender, EnqueuingEventArgs args) + { + string? identifier = GetIdentifier(args.Data); + if (String.IsNullOrEmpty(identifier) || await cache.AddAsync(identifier, true, detectionWindow)) + { + return; + } + + _logger.LogInformation("Discarding queue entry due to duplicate {UniqueIdentifier}", identifier); + args.Cancel = true; + } + + protected override async Task OnDequeued(object sender, DequeuedEventArgs args) + { + string? identifier = GetIdentifier(args.Entry.Value); + if (!String.IsNullOrEmpty(identifier)) + { + await cache.RemoveAsync(identifier); + } + } + + private static string? GetIdentifier(T data) + { + if (data is IHaveDurableUniqueIdentifier { UseDurableDeduplication: true }) + { + return null; + } + + return (data as IHaveUniqueIdentifier)?.UniqueIdentifier; + } +} diff --git a/src/Exceptionless.Core/Queues/DurableDuplicateDetectionQueueBehavior.cs b/src/Exceptionless.Core/Queues/DurableDuplicateDetectionQueueBehavior.cs new file mode 100644 index 0000000000..014ac64961 --- /dev/null +++ b/src/Exceptionless.Core/Queues/DurableDuplicateDetectionQueueBehavior.cs @@ -0,0 +1,68 @@ +using Foundatio.Caching; +using Foundatio.Queues; +using Microsoft.Extensions.Logging; + +namespace Exceptionless.Core.Queues; + +public interface IHaveDurableUniqueIdentifier : IHaveUniqueIdentifier +{ + bool UseDurableDeduplication { get; } +} + +/// +/// Keeps successful queue deduplication markers after dequeue. A short pending lease protects +/// against a producer dying after claiming an identifier but before the queue accepts the item. +/// +public class DurableDuplicateDetectionQueueBehavior( + ICacheClient cache, + ILoggerFactory loggerFactory, + TimeSpan detectionWindow) : QueueBehaviorBase where T : class +{ + private static readonly TimeSpan PendingWindow = TimeSpan.FromMinutes(1); + private readonly ILogger _logger = loggerFactory.CreateLogger>(); + + protected override async Task OnEnqueuing(object sender, EnqueuingEventArgs args) + { + string? identifier = GetIdentifier(args.Data); + if (String.IsNullOrEmpty(identifier)) + { + return; + } + + string key = GetCacheKey(identifier); + if (await cache.AddAsync(key, "pending", PendingWindow)) + { + return; + } + + var existing = await cache.GetAsync(key); + if (existing.HasValue && String.Equals(existing.Value, "completed", StringComparison.Ordinal)) + { + _logger.LogDebug("Discarding durable duplicate queue entry {UniqueIdentifier}", identifier); + args.Cancel = true; + return; + } + + // Do not turn an abandoned producer lease into a permanent false success. The caller can + // retry after the short lease expires, preserving at-least-once delivery. + throw new InvalidOperationException($"Queue identifier '{identifier}' is currently being enqueued."); + } + + protected override async Task OnEnqueued(object sender, EnqueuedEventArgs args) + { + string? identifier = GetIdentifier(args.Entry.Value); + if (String.IsNullOrEmpty(identifier)) + { + return; + } + + await cache.SetAsync(GetCacheKey(identifier), "completed", detectionWindow); + } + + internal static string GetCacheKey(string identifier) => + String.Concat("queue:durable-deduplication:", typeof(T).FullName, ":", identifier); + + private static string? GetIdentifier(T data) => data is IHaveDurableUniqueIdentifier { UseDurableDeduplication: true } durable + ? durable.UniqueIdentifier + : null; +} diff --git a/src/Exceptionless.Core/Repositories/Configuration/Indexes/EventIndex.cs b/src/Exceptionless.Core/Repositories/Configuration/Indexes/EventIndex.cs index 9c7b77b4cd..97bcd588d1 100644 --- a/src/Exceptionless.Core/Repositories/Configuration/Indexes/EventIndex.cs +++ b/src/Exceptionless.Core/Repositories/Configuration/Indexes/EventIndex.cs @@ -32,7 +32,9 @@ public EventIndex(ExceptionlessElasticConfiguration configuration, IServiceProvi _serviceProvider = serviceProvider; if (appOptions.MaximumRetentionDays > 0) + { MaxIndexAge = TimeSpan.FromDays(appOptions.MaximumRetentionDays); + } AddAlias($"{Name}-today", TimeSpan.FromDays(1)); AddAlias($"{Name}-last3days", TimeSpan.FromDays(7)); @@ -80,6 +82,10 @@ public override void ConfigureIndexMapping(TypeMappingDescriptor e.Count) .Boolean(e => e.IsFirstOccurrence) .FieldAlias(Alias.IsFirstOccurrence, a => a.Path(f => f.IsFirstOccurrence)) + .Boolean(e => e.IsRegression) + .Boolean(e => e.IngestionIsRegressionCandidate) + .Keyword(e => e.IngestionRegressionFixedInVersion) + .Date(e => e.IngestionRegressionDateFixed) .Object(e => e.Idx, o => o.Dynamic(DynamicMapping.True)) .Object(e => e.Data, o => o.Properties(p2 => p2 .AddVersionMapping() @@ -98,7 +104,9 @@ public override void ConfigureIndexMapping(TypeMappingDescriptor s.Enabled(true)); + } } public override void ConfigureIndex(CreateIndexRequestDescriptor idx) @@ -472,7 +480,9 @@ private static string Field(Expression> property) foreach (JsonPropertyInfo jsonProperty in typeInfo.Properties) { if (jsonProperty.AttributeProvider is PropertyInfo modelProperty && String.Equals(modelProperty.Name, propertyName, StringComparison.Ordinal)) + { return jsonProperty.Name; + } } throw new InvalidOperationException($"Unable to resolve JSON field name for {typeof(TModel).FullName}.{propertyName}."); @@ -485,7 +495,9 @@ private static PropertyInfo GetPropertyInfo(Expression m .Keyword("token") .Text("email_address", t => t.Analyzer(KEYWORD_LOWERCASE_ANALYZER)))) .Date(e => e.LastEventDateUtc) + .Date(e => e.LastAppliedUsageBucketUtc) .AddUsageMappings()); } diff --git a/src/Exceptionless.Core/Repositories/Configuration/Indexes/ProjectIndex.cs b/src/Exceptionless.Core/Repositories/Configuration/Indexes/ProjectIndex.cs index 8daf613e2a..74ee90830b 100644 --- a/src/Exceptionless.Core/Repositories/Configuration/Indexes/ProjectIndex.cs +++ b/src/Exceptionless.Core/Repositories/Configuration/Indexes/ProjectIndex.cs @@ -27,6 +27,7 @@ public override void ConfigureIndexMapping(TypeMappingDescriptor map) .Text(e => e.Name, t => t.AddKeywordField()) .LongNumber(e => e.NextSummaryEndOfDayTicks) .Date(e => e.LastEventDateUtc) + .Date(e => e.LastAppliedUsageBucketUtc) .AddUsageMappings() ); } diff --git a/src/Exceptionless.Core/Repositories/Configuration/Indexes/StackIndex.cs b/src/Exceptionless.Core/Repositories/Configuration/Indexes/StackIndex.cs index 02b15bdd89..c3d3ec80c4 100644 --- a/src/Exceptionless.Core/Repositories/Configuration/Indexes/StackIndex.cs +++ b/src/Exceptionless.Core/Repositories/Configuration/Indexes/StackIndex.cs @@ -63,10 +63,13 @@ public override void ConfigureIndexMapping(TypeMappingDescriptor map) .Boolean(Alias.IsFixed) .Keyword(e => e.FixedInVersion, k => k.IgnoreAbove(1024)) .FieldAlias(Alias.FixedInVersion, a => a.Path(f => f.FixedInVersion)) + .Keyword(e => e.RegressionEventId) + .Keyword(e => e.IngestionFirstEventId) .Boolean(e => e.OccurrencesAreCritical) .FieldAlias(Alias.OccurrencesAreCritical, a => a.Path(f => f.OccurrencesAreCritical)) .IntegerNumber(e => e.TotalOccurrences) .FieldAlias(Alias.TotalOccurrences, a => a.Path(f => f.TotalOccurrences)) + .LongNumber(e => e.IngestionStackUsageSequence) ); } diff --git a/src/Exceptionless.Core/Repositories/Interfaces/IStackRepository.cs b/src/Exceptionless.Core/Repositories/Interfaces/IStackRepository.cs index 13199d28c6..8d821fe0d5 100644 --- a/src/Exceptionless.Core/Repositories/Interfaces/IStackRepository.cs +++ b/src/Exceptionless.Core/Repositories/Interfaces/IStackRepository.cs @@ -1,4 +1,5 @@ using Exceptionless.Core.Models; +using Exceptionless.Core.Models.Ingestion; using Foundatio.Repositories; using Foundatio.Repositories.Models; @@ -7,10 +8,12 @@ namespace Exceptionless.Core.Repositories; public interface IStackRepository : IRepositoryOwnedByOrganizationAndProject { Task GetStackBySignatureHashAsync(string projectId, string signatureHash); + Task> GetStackRoutesBySignatureHashAsync(string projectId, IReadOnlyCollection signatureHashes); Task> GetIdsByQueryAsync(RepositoryQueryDescriptor query, CommandOptionsDescriptor? options = null); Task> GetExpiredSnoozedStatuses(DateTime utcNow, CommandOptionsDescriptor? options = null); Task MarkAsRegressedAsync(string stackId); Task IncrementEventCounterAsync(string organizationId, string projectId, string stackId, DateTime minOccurrenceDateUtc, DateTime maxOccurrenceDateUtc, int count, bool sendNotifications = true); + Task ApplyIngestionStackUsageAsync(string organizationId, string projectId, string stackId, DateTime minOccurrenceDateUtc, DateTime maxOccurrenceDateUtc, int count, long settlementSequence, bool sendNotifications = true); Task SetEventCounterAsync(string stackId, DateTime firstOccurrenceUtc, DateTime lastOccurrenceUtc, long totalOccurrences, bool sendNotifications = true); Task> GetStacksForCleanupAsync(string organizationId, DateTime cutoff); Task> GetSoftDeleted(); diff --git a/src/Exceptionless.Core/Repositories/Queries/StackQuery.cs b/src/Exceptionless.Core/Repositories/Queries/StackQuery.cs index 1d893d11e6..fb5087bb53 100644 --- a/src/Exceptionless.Core/Repositories/Queries/StackQuery.cs +++ b/src/Exceptionless.Core/Repositories/Queries/StackQuery.cs @@ -14,6 +14,7 @@ public static class StackQueryExtensions { internal const string StacksKey = "@Stacks"; internal const string ExcludedStacksKey = "@ExcludedStacks"; + internal const string SignatureHashesKey = "@SignatureHashes"; public static T Stack(this T query, string stackId) where T : IRepositoryQuery { @@ -34,6 +35,11 @@ public static T ExcludeStack(this T query, IEnumerable stackIds) wher { return query.AddCollectionOptionValue(ExcludedStacksKey, stackIds); } + + public static T SignatureHash(this T query, IEnumerable signatureHashes) where T : IRepositoryQuery + { + return query.AddCollectionOptionValue(SignatureHashesKey, signatureHashes.Distinct()); + } } } @@ -50,6 +56,12 @@ public static ICollection GetExcludedStacks(this IRepositoryQuery query) { return query.SafeGetCollection(StackQueryExtensions.ExcludedStacksKey); } + + + public static ICollection GetSignatureHashes(this IRepositoryQuery query) + { + return query.SafeGetCollection(StackQueryExtensions.SignatureHashesKey); + } } } @@ -58,20 +70,39 @@ namespace Exceptionless.Core.Repositories.Queries public class StackQueryBuilder : IElasticQueryBuilder { private static readonly Field StackIdField = nameof(IOwnedByStack.StackId).ToLowerUnderscoredWords(); + private static readonly Field SignatureHashField = nameof(Stack.SignatureHash).ToLowerUnderscoredWords(); public Task BuildAsync(QueryBuilderContext ctx) where T : class, new() { var stackIds = ctx.Source.GetStacks(); if (stackIds.Count == 1) + { ctx.Filter &= new TermQuery { Field = StackIdField, Value = stackIds.Single() }; + } else if (stackIds.Count > 1) + { ctx.Filter &= new TermsQuery { Field = StackIdField, Terms = new TermsQueryField(stackIds.Select(FieldValueHelper.ToFieldValue).ToList()) }; + } var excludedStackIds = ctx.Source.GetExcludedStacks(); if (excludedStackIds.Count == 1) + { ctx.Filter &= new BoolQuery { MustNot = [new TermQuery { Field = StackIdField, Value = excludedStackIds.Single() }] }; + } else if (excludedStackIds.Count > 1) + { ctx.Filter &= new BoolQuery { MustNot = [new TermsQuery { Field = StackIdField, Terms = new TermsQueryField(excludedStackIds.Select(FieldValueHelper.ToFieldValue).ToList()) }] }; + } + + var signatureHashes = ctx.Source.GetSignatureHashes(); + if (signatureHashes.Count == 1) + { + ctx.Filter &= new TermQuery { Field = SignatureHashField, Value = signatureHashes.Single() }; + } + else if (signatureHashes.Count > 1) + { + ctx.Filter &= new TermsQuery { Field = SignatureHashField, Terms = new TermsQueryField(signatureHashes.Select(FieldValueHelper.ToFieldValue).ToList()) }; + } return Task.CompletedTask; } diff --git a/src/Exceptionless.Core/Repositories/StackRepository.cs b/src/Exceptionless.Core/Repositories/StackRepository.cs index 29b0c3407e..c24f899da5 100644 --- a/src/Exceptionless.Core/Repositories/StackRepository.cs +++ b/src/Exceptionless.Core/Repositories/StackRepository.cs @@ -1,7 +1,10 @@ using Exceptionless.Core.Extensions; using Exceptionless.Core.Models; +using Exceptionless.Core.Models.Ingestion; using Exceptionless.Core.Repositories.Configuration; +using Exceptionless.Core.Services; using Exceptionless.Core.Validation; +using Foundatio.Caching; using Foundatio.Repositories; using Foundatio.Repositories.Exceptions; using Foundatio.Repositories.Models; @@ -13,12 +16,19 @@ namespace Exceptionless.Core.Repositories; public class StackRepository : RepositoryOwnedByOrganizationAndProject, IStackRepository { private readonly TimeProvider _timeProvider; + private readonly AppOptions _appOptions; + private readonly IStackRouteCache _stackRouteCache; private const string STACKING_VERSION = "v2"; - public StackRepository(ExceptionlessElasticConfiguration configuration, MiniValidationValidator validator, AppOptions options) + public StackRepository(ExceptionlessElasticConfiguration configuration, MiniValidationValidator validator, AppOptions options, IStackRouteCache stackRouteCache) : base(configuration.Stacks, validator, options) { _timeProvider = configuration.TimeProvider; + _appOptions = options; + _stackRouteCache = stackRouteCache; + // Both the legacy signature cache and the V3 route cache must invalidate the previous + // key when a stack's signature changes or it is soft-deleted. + OriginalsEnabled = true; AddRequiredField(s => s.SignatureHash); } @@ -89,7 +99,9 @@ Instant parseDate(def dt) { { bool modified = await PatchAsync(stackId, operation, o => o.Notifications(false)); if (!modified) + { return false; + } } catch (DocumentNotFoundException) { @@ -97,11 +109,86 @@ Instant parseDate(def dt) { } if (sendNotifications) + { await PublishMessageAsync(CreateEntityChanged(ChangeType.Saved, organizationId, projectId, null, stackId)); + } return true; } + public async Task ApplyIngestionStackUsageAsync( + string organizationId, + string projectId, + string stackId, + DateTime minOccurrenceDateUtc, + DateTime maxOccurrenceDateUtc, + int count, + long settlementSequence, + bool sendNotifications = true) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(count); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(settlementSequence); + + const string script = @" +Instant parseDate(def dt) { + if (dt != null) { + try { + return Instant.parse(dt); + } catch(DateTimeParseException e) {} + } + return Instant.MIN; +} + +long appliedSequence = ctx._source.ingestion_stack_usage_sequence == null + ? 0 + : ctx._source.ingestion_stack_usage_sequence; +if (appliedSequence >= params.settlementSequence) { + ctx.op = 'noop'; +} else { + if (ctx._source.total_occurrences == 0 || parseDate(ctx._source.first_occurrence).isAfter(parseDate(params.minOccurrenceDateUtc))) { + ctx._source.first_occurrence = params.minOccurrenceDateUtc; + } + + if (parseDate(ctx._source.last_occurrence).isBefore(parseDate(params.maxOccurrenceDateUtc))) { + ctx._source.last_occurrence = params.maxOccurrenceDateUtc; + } + + if (parseDate(ctx._source.updated_utc).isBefore(parseDate(params.updatedUtc))) { + ctx._source.updated_utc = params.updatedUtc; + } + + ctx._source.total_occurrences += params.count; + ctx._source.ingestion_stack_usage_sequence = params.settlementSequence; +}"; + + var operation = new ScriptPatch(script.TrimScript()) + { + Params = new Dictionary(5) + { + { "minOccurrenceDateUtc", minOccurrenceDateUtc }, + { "maxOccurrenceDateUtc", maxOccurrenceDateUtc }, + { "count", count }, + { "settlementSequence", settlementSequence }, + { "updatedUtc", _timeProvider.GetUtcNow().UtcDateTime } + } + }; + + bool modified; + try + { + modified = await PatchAsync(stackId, operation, o => o.Notifications(false)); + } + catch (DocumentNotFoundException) + { + return; + } + + if (modified && sendNotifications) + { + await PublishMessageAsync(CreateEntityChanged(ChangeType.Saved, organizationId, projectId, null, stackId)); + } + } + public async Task SetEventCounterAsync(string stackId, DateTime firstOccurrenceUtc, DateTime lastOccurrenceUtc, long totalOccurrences, bool sendNotifications = true) { const string script = @" @@ -153,11 +240,44 @@ Instant parseDate(def dt) { public async Task GetStackBySignatureHashAsync(string projectId, string signatureHash) { - string key = GetStackSignatureCacheKey(projectId, signatureHash); + long projectGeneration = await _stackRouteCache.GetProjectGenerationAsync(projectId); + string key = GetStackSignatureCacheKey(projectId, signatureHash, projectGeneration); var hit = await FindOneAsync(q => q.Project(projectId).FieldEquals(s => s.SignatureHash, signatureHash), o => o.Cache(key)); return hit?.Document; } + public async Task> GetStackRoutesBySignatureHashAsync(string projectId, IReadOnlyCollection signatureHashes) + { + if (signatureHashes.Count == 0) + { + return new Dictionary(); + } + + var results = await FindAsync( + q => q.Project(projectId).SignatureHash(signatureHashes).Include( + s => s.Id, + s => s.SignatureHash, + s => s.Status, + s => s.UpdatedUtc, + s => s.FixedInVersion, + s => s.DateFixed, + s => s.OccurrencesAreCritical, + s => s.RegressionEventId, + s => s.IngestionFirstEventId), + o => o.PageLimit(signatureHashes.Count)); + + var routes = new Dictionary(results.Documents.Count); + foreach (var stack in results.Documents) + { + if (!String.IsNullOrEmpty(stack.SignatureHash)) + { + routes[stack.SignatureHash] = StackRouteResolver.CreateRoute(stack); + } + } + + return routes; + } + public Task> GetIdsByQueryAsync(RepositoryQueryDescriptor query, CommandOptionsDescriptor? options = null) { return FindAsync(q => query.Configure().OnlyIds(), options); @@ -176,36 +296,150 @@ public async Task MarkAsRegressedAsync(string stackId) await SaveAsync(stack, o => o.Cache()); } - public Task SoftDeleteByProjectIdAsync(string organizationId, string projectId) + public async Task SoftDeleteByProjectIdAsync(string organizationId, string projectId) { ArgumentException.ThrowIfNullOrEmpty(organizationId); ArgumentException.ThrowIfNullOrEmpty(projectId); - return PatchAllAsync( + long deleted = await PatchAllAsync( q => q.Organization(organizationId).Project(projectId), - new PartialPatch(new { is_deleted = true, updated_utc = _timeProvider.GetUtcNow().UtcDateTime }) + new PartialPatch(new { is_deleted = true, updated_utc = _timeProvider.GetUtcNow().UtcDateTime }), + o => o.ImmediateConsistency() ); + // The same generation scopes both V3 route entries and legacy signature lookups. Any + // lookup that began before deletion can only refill the old namespace and is invisible + // after the durable, immediately-consistent patch advances it. + await _stackRouteCache.AdvanceProjectGenerationAsync(projectId); + return deleted; } protected override async Task AddDocumentsToCacheAsync(ICollection> findHits, ICommandOptions options, bool isDirtyRead) { await base.AddDocumentsToCacheAsync(findHits, options, isDirtyRead); + Stack[] signatureStacks = findHits + .Select(hit => hit.Document) + .Where(stack => stack is not null && !String.IsNullOrEmpty(stack.ProjectId) && !String.IsNullOrEmpty(stack.SignatureHash)) + .Cast() + .ToArray(); + var signatureGenerations = await GetProjectGenerationsAsync(signatureStacks); var cacheEntries = new Dictionary>(); foreach (var hit in findHits.Where(d => !String.IsNullOrEmpty(d.Document?.SignatureHash))) - cacheEntries[GetStackSignatureCacheKey(hit.Document!)] = hit; + { + Stack stack = hit.Document!; + cacheEntries[GetStackSignatureCacheKey( + stack.ProjectId, + stack.SignatureHash, + signatureGenerations[stack.ProjectId])] = hit; + } if (cacheEntries.Count > 0) + { await AddDocumentsToCacheWithKeyAsync(cacheEntries, options.GetExpiresIn()); + } + + // Route resolver misses carry the generation observed before their repository lookup + // and populate the route cache themselves. Generic repository read-through cannot + // safely infer that generation, so only authoritative mutations write routes here. } protected override async Task InvalidateCacheAsync(IReadOnlyCollection> documents, ChangeType? changeType = null) { - var keysToRemove = documents.UnionOriginalAndModified().Select(GetStackSignatureCacheKey).Distinct(); + Stack[] cacheStacks = documents + .UnionOriginalAndModified() + .Where(stack => !String.IsNullOrEmpty(stack.ProjectId) && !String.IsNullOrEmpty(stack.SignatureHash)) + .ToArray(); + var projectGenerations = await GetProjectGenerationsAsync(cacheStacks); + var keysToRemove = cacheStacks + .Select(stack => GetStackSignatureCacheKey( + stack.ProjectId, + stack.SignatureHash, + projectGenerations[stack.ProjectId])) + .Distinct(); await Cache.RemoveAllAsync(keysToRemove); await base.InvalidateCacheAsync(documents, changeType); + + Stack[] routeStacks = cacheStacks; + if (changeType is ChangeType.Added or ChangeType.Saved) + { + var obsoleteRouteKeys = documents + .SelectMany(document => new[] { document.Original, document.Value }) + .Where(stack => stack is not null + && !String.IsNullOrEmpty(stack.ProjectId) + && !String.IsNullOrEmpty(stack.SignatureHash)) + .Select(stack => new + { + Stack = stack!, + Key = GetStackRouteCacheKey( + stack!.ProjectId, + stack.SignatureHash, + projectGenerations[stack.ProjectId]) + }) + .Where(item => item.Stack.IsDeleted || !documents.Any(document => + !document.Value.IsDeleted + && !String.IsNullOrEmpty(document.Value.ProjectId) + && !String.IsNullOrEmpty(document.Value.SignatureHash) + && GetStackRouteCacheKey( + document.Value.ProjectId, + document.Value.SignatureHash, + projectGenerations[document.Value.ProjectId]) == item.Key)) + .Select(item => item.Key) + .Distinct(StringComparer.Ordinal) + .ToArray(); + if (obsoleteRouteKeys.Length > 0) + { + await _stackRouteCache.RemoveAllAsync(obsoleteRouteKeys, _appOptions.EventIngestionV3.StackRouteCacheDuration); + } + + var routeEntries = new Dictionary(); + foreach (var stack in documents.Select(d => d.Value)) + { + if (stack.IsDeleted || String.IsNullOrEmpty(stack.ProjectId) || String.IsNullOrEmpty(stack.SignatureHash)) + { + continue; + } + + routeEntries[GetStackRouteCacheKey( + stack.ProjectId, + stack.SignatureHash, + projectGenerations[stack.ProjectId])] = StackRouteCacheEntry.FromRoute(StackRouteResolver.CreateRoute(stack)); + } + if (routeEntries.Count > 0) + { + await _stackRouteCache.SetAllAuthoritativeAsync(routeEntries, _appOptions.EventIngestionV3.StackRouteCacheDuration); + } + } + else if (changeType is ChangeType.Removed) + { + string[] routeKeys = routeStacks + .Select(stack => GetStackRouteCacheKey( + stack.ProjectId, + stack.SignatureHash, + projectGenerations[stack.ProjectId])) + .Distinct(StringComparer.Ordinal) + .ToArray(); + await _stackRouteCache.RemoveAllAsync(routeKeys, _appOptions.EventIngestionV3.StackRouteCacheDuration); + } + } + + private async Task> GetProjectGenerationsAsync(IEnumerable stacks) + { + string[] projectIds = stacks + .Select(stack => stack.ProjectId) + .Where(projectId => !String.IsNullOrEmpty(projectId)) + .Distinct(StringComparer.Ordinal) + .ToArray(); + var generations = await Task.WhenAll(projectIds.Select(async projectId => new + { + ProjectId = projectId, + Generation = await _stackRouteCache.GetProjectGenerationAsync(projectId) + })); + return generations.ToDictionary(item => item.ProjectId, item => item.Generation, StringComparer.Ordinal); } - private static string GetStackSignatureCacheKey(Stack stack) => GetStackSignatureCacheKey(stack.ProjectId, stack.SignatureHash); - private static string GetStackSignatureCacheKey(string projectId, string signatureHash) => String.Concat(projectId, ":", signatureHash, ":", STACKING_VERSION); + private static string GetStackSignatureCacheKey(string projectId, string signatureHash, long projectGeneration) => + String.Concat(projectId, ":", signatureHash, ":", STACKING_VERSION, ":", projectGeneration); + internal static string GetStackRouteCacheKey(string projectId, string signatureHash, long projectGeneration = 0) => + StackRouteResolver.GetCacheKey(projectId, signatureHash, projectGeneration); + internal static string GetStackRouteCachePrefix(string projectId) => String.Concat("stack-route:v3:v1:", projectId, ":"); } diff --git a/src/Exceptionless.Core/Serialization/EventIngestionJsonContext.cs b/src/Exceptionless.Core/Serialization/EventIngestionJsonContext.cs new file mode 100644 index 0000000000..7874358831 --- /dev/null +++ b/src/Exceptionless.Core/Serialization/EventIngestionJsonContext.cs @@ -0,0 +1,33 @@ +using System.Text.Json.Serialization; +using Exceptionless.Core.Models.Ingestion; + +namespace Exceptionless.Core.Serialization; + +[JsonSourceGenerationOptions( + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + GenerationMode = JsonSourceGenerationMode.Metadata, + MaxDepth = EventIngestionV3Limits.MaximumJsonDepth, + PropertyNamingPolicy = JsonKnownNamingPolicy.SnakeCaseLower, + RespectNullableAnnotations = true, + UseStringEnumConverter = false)] +[JsonSerializable(typeof(EventIngestionV3Event))] +[JsonSerializable(typeof(EventIngestionV3Event[]))] +[JsonSerializable(typeof(EventIngestionV3Client))] +[JsonSerializable(typeof(EventIngestionV3User))] +[JsonSerializable(typeof(EventIngestionV3Request))] +[JsonSerializable(typeof(EventIngestionV3Environment))] +[JsonSerializable(typeof(EventIngestionV3Stacking))] +[JsonSerializable(typeof(EventIngestionV3Response))] +[JsonSerializable(typeof(EventIngestionV3Error))] +public sealed partial class EventIngestionJsonContext : JsonSerializerContext; + +[JsonSourceGenerationOptions( + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + GenerationMode = JsonSourceGenerationMode.Metadata, + MaxDepth = EventIngestionV3Limits.MaximumJsonDepth, + PropertyNamingPolicy = JsonKnownNamingPolicy.SnakeCaseLower, + RespectNullableAnnotations = true, + UseStringEnumConverter = false)] +[JsonSerializable(typeof(EventIngestionV3SurvivorPayload))] +[JsonSerializable(typeof(EventIngestionV3SurvivorStacking))] +internal sealed partial class EventIngestionV3SurvivorJsonContext : JsonSerializerContext; diff --git a/src/Exceptionless.Core/Serialization/JsonSerializerOptionsExtensions.cs b/src/Exceptionless.Core/Serialization/JsonSerializerOptionsExtensions.cs index cd27fb785e..1c76da619d 100644 --- a/src/Exceptionless.Core/Serialization/JsonSerializerOptionsExtensions.cs +++ b/src/Exceptionless.Core/Serialization/JsonSerializerOptionsExtensions.cs @@ -3,6 +3,7 @@ using System.Text.Json.Serialization; using System.Text.Json.Serialization.Metadata; using System.Text.Unicode; +using Exceptionless.Core.Attributes; namespace Exceptionless.Core.Serialization; @@ -35,9 +36,30 @@ public static JsonSerializerOptions ConfigureExceptionlessDefaults(this JsonSeri public static JsonSerializerOptions ConfigureExceptionlessApiDefaults(this JsonSerializerOptions options) { ConfigureExceptionlessDefaults(options, skipEmptyCollections: false); + if (options.TypeInfoResolver is DefaultJsonTypeInfoResolver resolver) + { + resolver.Modifiers.Add(RemoveApiIgnoredProperties); + } + return options; } + private static void RemoveApiIgnoredProperties(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind is not JsonTypeInfoKind.Object) + { + return; + } + + for (int index = typeInfo.Properties.Count - 1; index >= 0; index--) + { + if (typeInfo.Properties[index].AttributeProvider?.IsDefined(typeof(ApiIgnoreAttribute), inherit: true) is true) + { + typeInfo.Properties.RemoveAt(index); + } + } + } + private static JsonSerializerOptions ConfigureExceptionlessDefaults(JsonSerializerOptions options, bool skipEmptyCollections) { options.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull; @@ -68,7 +90,9 @@ private static JsonSerializerOptions ConfigureExceptionlessDefaults(JsonSerializ var resolver = new DefaultJsonTypeInfoResolver(); if (skipEmptyCollections) + { resolver.Modifiers.Add(EmptyCollectionModifier.SkipEmptyCollections); + } options.TypeInfoResolver = resolver; return options; diff --git a/src/Exceptionless.Core/Services/EventBatchWriter.cs b/src/Exceptionless.Core/Services/EventBatchWriter.cs new file mode 100644 index 0000000000..6753ffc53d --- /dev/null +++ b/src/Exceptionless.Core/Services/EventBatchWriter.cs @@ -0,0 +1,517 @@ +using System.Buffers.Binary; +using System.Security.Cryptography; +using System.Text; +using Exceptionless.Core.Extensions; +using Exceptionless.Core.Models; +using Exceptionless.Core.Models.Ingestion; +using Exceptionless.Core.Models.WorkItems; +using Exceptionless.Core.Repositories; +using Foundatio.Jobs; +using Foundatio.Lock; +using Foundatio.Queues; +using Foundatio.Repositories; + +namespace Exceptionless.Core.Services; + +public interface IEventBatchWriter +{ + Task> PrepareAsync( + IReadOnlyCollection events, + string projectId, + DateTime utcNow, + CancellationToken cancellationToken); + + Task ReconcileAsync( + IReadOnlyCollection reconciliations, + Organization organization, + Project project, + CancellationToken cancellationToken); + + Task WriteAsync(IReadOnlyCollection writes, Organization organization, Project project, CancellationToken cancellationToken); +} + +public sealed class EventBatchWriter( + IEventRepository eventRepository, + IStackRepository stackRepository, + IStackRouteResolver stackRouteResolver, + ILockProvider lockProvider, + IQueue workItemQueue, + IEventIngestionIdStore eventIngestionIdStore, + AppOptions options, + TimeProvider timeProvider) : IEventBatchWriter +{ + public async Task> PrepareAsync( + IReadOnlyCollection events, + string projectId, + DateTime utcNow, + CancellationToken cancellationToken) + { + if (events.Count == 0) + { + return []; + } + + var sources = events.ToArray(); + var idCandidates = new EventIngestionIdCandidate[sources.Length]; + DateTime createdUtc = DateTime.SpecifyKind(utcNow, DateTimeKind.Utc); + DateTimeOffset receiptDate = new(createdUtc); + HashSet? mappedClientIds = null; + + for (int i = 0; i < sources.Length; i++) + { + cancellationToken.ThrowIfCancellationRequested(); + DateTimeOffset eventDate = GetCanonicalEventDate(sources[i].Date, receiptDate); + idCandidates[i] = new EventIngestionIdCandidate( + sources[i].Id, + new EventIngestionId( + GetDeterministicEventId(projectId, sources[i].Id, eventDate.UtcDateTime), + eventDate, + createdUtc)); + if (options.EventIngestionV3.EnableProcessingStatus + || sources[i].Date is null + || sources[i].Date > receiptDate) + { + (mappedClientIds ??= new HashSet(StringComparer.Ordinal)).Add(sources[i].Id); + } + } + + IReadOnlyDictionary? assignedIds = null; + if (mappedClientIds is not null) + { + assignedIds = await eventIngestionIdStore.GetOrAddAsync( + projectId, + idCandidates.Where(candidate => mappedClientIds.Contains(candidate.ClientId)).ToArray(), + options.EventIngestionV3.IdempotencyWindow, + cancellationToken); + } + var eventIds = new string[sources.Length]; + var eventIdentities = new EventIngestionId[sources.Length]; + for (int i = 0; i < sources.Length; i++) + { + EventIngestionId identity = mappedClientIds?.Contains(sources[i].Id) is true + ? assignedIds![sources[i].Id] + : idCandidates[i].Identity; + eventIds[i] = identity.EventId; + eventIdentities[i] = identity; + } + + string[] distinctIds = eventIds.Distinct(StringComparer.Ordinal).ToArray(); + var existingEvents = await eventRepository.GetByIdsAsync(distinctIds, o => o.Include( + e => e.Id, + e => e.StackId, + e => e.Date, + e => e.CreatedUtc)); + cancellationToken.ThrowIfCancellationRequested(); + var existingById = existingEvents.ToDictionary(e => e.Id, StringComparer.Ordinal); + string[] persistedStackIds = existingEvents + .Select(ev => ev.StackId) + .Where(stackId => !String.IsNullOrEmpty(stackId)) + .Distinct(StringComparer.Ordinal) + .ToArray(); + var persistedStacks = persistedStackIds.Length == 0 + ? [] + : await stackRepository.GetByIdsAsync(persistedStackIds, o => o.Include(stack => stack.Id, stack => stack.Status)); + var persistedStackStatuses = persistedStacks.ToDictionary(stack => stack.Id, stack => stack.Status, StringComparer.Ordinal); + var seen = new HashSet(StringComparer.Ordinal); + var identities = new EventIngestionIdentity[sources.Length]; + DateTime recoveryCutoffUtc = timeProvider.GetUtcNow().UtcDateTime.Subtract(options.EventIngestionV3.IdempotencyWindow); + for (int i = 0; i < sources.Length; i++) + { + bool isPersisted = existingById.TryGetValue(eventIds[i], out var existing); + bool isDuplicate = isPersisted || !seen.Add(eventIds[i]); + DateTime identityCreatedUtc = existing?.CreatedUtc ?? eventIdentities[i].CreatedUtc; + DateTimeOffset eventDate = existing?.Date ?? eventIdentities[i].EventDate; + identities[i] = new EventIngestionIdentity( + sources[i].Id, + eventIds[i], + isDuplicate, + isPersisted, + existing?.StackId, + existing is not null && persistedStackStatuses.TryGetValue(existing.StackId, out StackStatus stackStatus) ? stackStatus : null, + !isPersisted || identityCreatedUtc > recoveryCutoffUtc, + identityCreatedUtc, + eventDate); + } + + return identities; + } + + public async Task ReconcileAsync( + IReadOnlyCollection reconciliations, + Organization organization, + Project project, + CancellationToken cancellationToken) + { + if (reconciliations.Count == 0) + { + return; + } + + var requestedStackIds = reconciliations + .GroupBy(item => item.EventId, StringComparer.Ordinal) + .ToDictionary(group => group.Key, group => group.First().StackId, StringComparer.Ordinal); + var persistedEvents = await eventRepository.GetByIdsAsync(requestedStackIds.Keys.ToArray(), o => o.Include( + ev => ev.Id, + ev => ev.StackId, + ev => ev.IsRegression, + ev => ev.IngestionIsRegressionCandidate, + ev => ev.IngestionRegressionFixedInVersion, + ev => ev.IngestionRegressionDateFixed)); + string[] stackIds = persistedEvents + .Where(ev => requestedStackIds.TryGetValue(ev.Id, out string? requestedStackId) + && String.Equals(requestedStackId, ev.StackId, StringComparison.Ordinal)) + .Select(ev => ev.StackId) + .Distinct(StringComparer.Ordinal) + .ToArray(); + var stacks = stackIds.Length == 0 + ? [] + : await stackRepository.GetByIdsAsync(stackIds, o => o.Include( + stack => stack.Id, + stack => stack.Status, + stack => stack.RegressionEventId)); + var stacksById = stacks.ToDictionary(stack => stack.Id, StringComparer.Ordinal); + var activeEventIds = new List(persistedEvents.Count); + foreach (var ev in persistedEvents) + { + if (!requestedStackIds.TryGetValue(ev.Id, out string? requestedStackId) + || !String.Equals(requestedStackId, ev.StackId, StringComparison.Ordinal) + || !stacksById.TryGetValue(ev.StackId, out Stack? stack) + || stack.Status == StackStatus.Discarded) + { + continue; + } + + activeEventIds.Add(ev.Id); + StackRoute? regressionRoute = null; + if (ev.IngestionIsRegressionCandidate && ev.IngestionRegressionDateFixed != DateTime.MinValue) + { + regressionRoute = new StackRoute( + ev.StackId, + StackStatus.Fixed, + 0, + ev.IngestionRegressionFixedInVersion, + ev.IngestionRegressionDateFixed, + RegressionEventId: stack.RegressionEventId); + } + else if (stack.RegressionEventId == ev.Id) + { + regressionRoute = new StackRoute( + ev.StackId, + stack.Status, + 0, + RegressionEventId: stack.RegressionEventId); + } + + if (regressionRoute is not null) + { + // Reconciliation deliberately loads only routing fields above. CompleteRegressionAsync + // must reload the full document before SaveAsync so a duplicate retry cannot validate or + // overwrite the event using that source-filtered projection. + await CompleteRegressionAsync(ev.Id, regressionRoute, cancellationToken); + } + } + + await EnqueueSideEffectsAsync( + activeEventIds.Distinct(StringComparer.Ordinal).ToArray(), + organization.Id, + project.Id); + } + + public async Task WriteAsync(IReadOnlyCollection writes, Organization organization, Project project, CancellationToken cancellationToken) + { + using var activity = AppDiagnostics.StartActivity("Ingestion V3 Batch Write"); + if (writes.Count == 0) + { + return new EventBatchWriteResult(0, 0, []); + } + + var uniqueWrites = writes + .GroupBy(write => write.Event.Id, StringComparer.Ordinal) + .Select(group => group.First()) + .ToList(); + // PrepareAsync already checked these deterministic IDs. Avoid a second Elasticsearch + // multi-get on every successful microbatch; create-only conflicts are rare and take the + // reconciliation path below. + var existing = new List(); + var writesToAdd = uniqueWrites; + using (AppDiagnostics.StartActivity("Ingestion V3 Stack Resolve Create")) + { + await AssignStacksAsync(writesToAdd, organization, project, cancellationToken); + } + + var eventsToAdd = writesToAdd.Select(write => write.Event).ToList(); + foreach (var write in writesToAdd) + { + PersistentEvent ev = write.Event; + if (write.IsRegressionCandidate && write.Route is { Status: StackStatus.Fixed, DateFixed: not null } route) + { + ev.IngestionIsRegressionCandidate = true; + ev.IngestionRegressionFixedInVersion = route.FixedInVersion; + ev.IngestionRegressionDateFixed = route.DateFixed.Value; + } + } + EventUsageSettlement[] settlements = []; + + if (eventsToAdd.Count > 0) + { + try + { + await eventRepository.AddAsync(eventsToAdd); + // The writer that receives a successful add owns billing for these documents. + // Existing documents and ambiguous bulk outcomes never produce settlements, + // so a retry can recover side effects without charging the event twice. + settlements = GetPersistedSettlements(eventsToAdd); + } + catch (Exception ex) + { + AppDiagnostics.IngestionV3WriteReconciliations.Add(1); + var reconciled = await eventRepository.GetByIdsAsync(eventsToAdd.Select(e => e.Id).ToArray(), o => o.Include( + e => e.Id, + e => e.StackId, + e => e.CreatedUtc)); + AppDiagnostics.IngestionV3AmbiguousSettlementsSkipped.Add(reconciled.Count); + if (reconciled.Count != eventsToAdd.Count) + { + // Elasticsearch did not tell us which documents this request created. + // Fail open for billing rather than risk charging a racing retry twice. + throw new EventBatchWriteException(ex, []); + } + + // The bulk outcome is ambiguous. From this point on use only the durable + // documents; never let replay-materialized payloads drive regression repair. + existing.AddRange(reconciled.DistinctBy(ev => ev.Id, StringComparer.Ordinal)); + writesToAdd.Clear(); + eventsToAdd.Clear(); + } + } + + try + { + if (existing.Count > 0) + { + await ReconcileAsync( + existing + .Where(ev => !String.IsNullOrEmpty(ev.StackId)) + .Select(ev => new EventIngestionReconciliation(ev.Id, ev.StackId)) + .ToArray(), + organization, + project, + cancellationToken); + } + + foreach (var write in writesToAdd.Where(write => write.IsRegressionCandidate || write.Route?.RegressionEventId == write.Event.Id)) + { + await CompleteRegressionAsync( + write.Event.Id, + write.Route, + cancellationToken, + write.Event); + } + + await EnqueueSideEffectsAsync(eventsToAdd.Select(ev => ev.Id).ToArray(), organization.Id, project.Id); + } + catch (Exception ex) when (ex is not EventBatchWriteException) + { + throw new EventBatchWriteException(ex, settlements); + } + + return new EventBatchWriteResult(eventsToAdd.Count, writes.Count - eventsToAdd.Count, settlements); + } + + private static EventUsageSettlement[] GetPersistedSettlements(IReadOnlyCollection events) + { + if (events.Count == 0) + { + return []; + } + + return events + .DistinctBy(ev => ev.Id, StringComparer.Ordinal) + .Select(ev => new EventUsageSettlement(ev.Id, ev.CreatedUtc)) + .ToArray(); + } + + private async Task CompleteRegressionAsync( + string eventId, + StackRoute? route, + CancellationToken cancellationToken, + PersistentEvent? knownCompleteEvent = null) + { + if (route is null) + { + return; + } + + bool isRegression = route.RegressionEventId == eventId; + if (!isRegression && route.Status == StackStatus.Fixed) + { + isRegression = await stackRouteResolver.TryMarkRegressedAsync(route, eventId, cancellationToken); + if (!isRegression) + { + var currentStack = await stackRepository.GetByIdAsync(route.StackId); + isRegression = currentStack?.RegressionEventId == eventId; + } + } + + if (!isRegression) + { + return; + } + + var ev = knownCompleteEvent ?? await eventRepository.GetByIdAsync(eventId); + if (ev is null || ev.IsRegression) + { + return; + } + + ev.IsRegression = true; + await eventRepository.SaveAsync(ev, o => o.Notifications(false)); + } + + private async Task EnqueueSideEffectsAsync(string[] eventIds, string organizationId, string projectId) + { + if (eventIds.Length == 0) + { + return; + } + + Array.Sort(eventIds, StringComparer.Ordinal); + using (AppDiagnostics.StartActivity("Ingestion V3 Outbox Write")) + using (AppDiagnostics.IngestionV3OutboxWriteTime.StartTimer()) + { + await workItemQueue.EnqueueAsync(new EventIngestionSideEffectsWorkItem + { + OrganizationId = organizationId, + ProjectId = projectId, + BatchId = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(String.Join(':', eventIds)))).ToLowerInvariant(), + EventIds = eventIds + }); + } + } + + private Task AssignStacksAsync(List writes, Organization organization, Project project, CancellationToken cancellationToken) + { + foreach (var write in writes.Where(write => write.Route is not null)) + { + write.Event.StackId = write.Route!.StackId; + write.Event.IsFirstOccurrence = String.Equals( + write.Event.Id, + write.Route.IngestionFirstEventId, + StringComparison.Ordinal); + } + + var missingGroups = writes + .Where(write => write.Route is null) + .GroupBy(write => write.Fingerprint.SignatureHash) + .ToArray(); + return Parallel.ForEachAsync(missingGroups, new ParallelOptions + { + CancellationToken = cancellationToken, + MaxDegreeOfParallelism = options.EventIngestionV3.MaximumStackCreationConcurrency + }, async (group, token) => + { + token.ThrowIfCancellationRequested(); + Stack? stack = null; + bool isNew = false; + bool acquired = await lockProvider.TryUsingAsync($"new-stack:{project.Id}:{group.Key}", async () => + { + stack = await stackRepository.GetStackBySignatureHashAsync(project.Id, group.Key); + if (stack is not null) + { + return; + } + + EventIngestionWrite first = group.First(); + EventIngestionWrite firstOccurrence = group + .OrderBy(write => write.Event.Date.UtcDateTime) + .ThenBy(write => write.Event.Id, StringComparer.Ordinal) + .First(); + stack = new Stack + { + OrganizationId = organization.Id, + ProjectId = project.Id, + SignatureInfo = new SettingsDictionary(first.Fingerprint.SignatureData), + SignatureHash = group.Key, + DuplicateSignature = String.Concat(project.Id, ":", group.Key), + Title = GetStackTitle(first).Truncate(1000), + Tags = first.Event.Tags ?? [], + Type = first.Event.Type ?? Event.KnownTypes.Log, + TotalOccurrences = 0, + FirstOccurrence = group.Min(write => write.Event.Date.UtcDateTime), + LastOccurrence = group.Max(write => write.Event.Date.UtcDateTime), + IngestionFirstEventId = firstOccurrence.Event.Id, + CreatedUtc = timeProvider.GetUtcNow().UtcDateTime, + UpdatedUtc = timeProvider.GetUtcNow().UtcDateTime + }; + await stackRepository.AddAsync(stack, o => o.Cache()); + isNew = true; + }, TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(10)); + + if (!acquired || stack is null) + { + throw new InvalidOperationException($"Unable to resolve stack for signature '{group.Key}'."); + } + + foreach (var write in group) + { + write.Event.StackId = stack.Id; + write.Event.IsFirstOccurrence = String.Equals( + write.Event.Id, + stack.IngestionFirstEventId, + StringComparison.Ordinal); + } + + // AddAsync publishes an authoritative route through repository invalidation. A stack + // found after our earlier route miss still needs to replace that negative entry. + if (!isNew) + { + await stackRouteResolver.UpdateAsync(project.Id, group.Key, StackRouteResolver.CreateRoute(stack)); + } + }); + } + + internal static string GetDeterministicEventId(string projectId, string clientId, DateTime eventDateUtc) + { + byte[] hash = SHA256.HashData(Encoding.UTF8.GetBytes(String.Concat(projectId, ":", clientId))); + Span objectId = stackalloc byte[12]; + long timestamp = new DateTimeOffset(DateTime.SpecifyKind(eventDateUtc, DateTimeKind.Utc)).ToUnixTimeSeconds(); + BinaryPrimitives.WriteUInt32BigEndian(objectId, checked((uint)timestamp)); + hash.AsSpan(0, 8).CopyTo(objectId[4..]); + return Convert.ToHexStringLower(objectId); + } + + private static DateTimeOffset GetCanonicalEventDate(DateTimeOffset? requestedDate, DateTimeOffset receiptDate) + { + if (requestedDate is not { } date || date > receiptDate) + { + return receiptDate; + } + + return date < DateTimeOffset.UnixEpoch ? DateTimeOffset.UnixEpoch : date; + } + + private static string GetStackTitle(EventIngestionWrite write) + { + if (!String.IsNullOrWhiteSpace(write.Fingerprint.Title)) + { + return write.Fingerprint.Title; + } + + if (write.Fingerprint.SignatureData.TryGetValue("ExceptionType", out string? exceptionType)) + { + return String.IsNullOrWhiteSpace(write.Event.Message) ? exceptionType : String.Concat(exceptionType, ": ", write.Event.Message); + } + + if (!String.IsNullOrWhiteSpace(write.Event.Message)) + { + return write.Event.Message; + } + + if (!String.IsNullOrWhiteSpace(write.Event.Source)) + { + return write.Event.Source; + } + + return write.Event.Type ?? Event.KnownTypes.Log; + } +} diff --git a/src/Exceptionless.Core/Services/EventIngestionIdStore.cs b/src/Exceptionless.Core/Services/EventIngestionIdStore.cs new file mode 100644 index 0000000000..41bd0b544b --- /dev/null +++ b/src/Exceptionless.Core/Services/EventIngestionIdStore.cs @@ -0,0 +1,168 @@ +using System.Security.Cryptography; +using System.Text; +using Foundatio.Caching; + +namespace Exceptionless.Core.Services; + +public interface IEventIngestionIdStore +{ + Task> GetOrAddAsync( + string projectId, + IReadOnlyCollection candidates, + TimeSpan expiresIn, + CancellationToken cancellationToken = default); + + Task> GetAsync( + string projectId, + IReadOnlyCollection clientIds, + CancellationToken cancellationToken = default); +} + +public readonly record struct EventIngestionId( + string EventId, + DateTimeOffset EventDate, + DateTime CreatedUtc); + +public readonly record struct EventIngestionIdCandidate( + string ClientId, + EventIngestionId Identity); + +/// +/// Claims date-routable event identifiers in the distributed cache. Each client identifier gets +/// an independently expiring key so storage stays bounded by the configured idempotency window. +/// +public sealed class EventIngestionIdStore(ICacheClient cache) : IEventIngestionIdStore +{ + public async Task> GetOrAddAsync( + string projectId, + IReadOnlyCollection candidates, + TimeSpan expiresIn, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(projectId); + if (expiresIn <= TimeSpan.Zero) + { + throw new ArgumentOutOfRangeException(nameof(expiresIn)); + } + + if (candidates.Count == 0) + { + return new Dictionary(StringComparer.Ordinal); + } + + cancellationToken.ThrowIfCancellationRequested(); + var distinctCandidates = new Dictionary(StringComparer.Ordinal); + foreach (EventIngestionIdCandidate candidate in candidates) + { + ArgumentException.ThrowIfNullOrWhiteSpace(candidate.ClientId); + distinctCandidates.TryAdd(candidate.ClientId, candidate); + } + + var keysByClientId = distinctCandidates.Keys.ToDictionary( + clientId => clientId, + clientId => GetCacheKey(projectId, clientId), + StringComparer.Ordinal); + var cached = await cache.GetAllAsync(keysByClientId.Values); + cancellationToken.ThrowIfCancellationRequested(); + + var results = new Dictionary(distinctCandidates.Count, StringComparer.Ordinal); + var missing = new List(); + foreach ((string clientId, EventIngestionIdCandidate candidate) in distinctCandidates) + { + if (cached.TryGetValue(keysByClientId[clientId], out CacheValue? value) && value is { HasValue: true }) + { + results.Add(clientId, value.Value); + } + else + { + missing.Add(candidate); + } + } + + if (missing.Count == 0) + { + return results; + } + + // Start all conditional writes before awaiting so Redis can pipeline a whole microbatch. + Task[] claims = missing + .Select(candidate => cache.AddAsync( + keysByClientId[candidate.ClientId], + candidate.Identity, + expiresIn)) + .ToArray(); + bool[] claimed = await Task.WhenAll(claims); + cancellationToken.ThrowIfCancellationRequested(); + + var lostClientIds = new List(); + for (int i = 0; i < missing.Count; i++) + { + if (claimed[i]) + { + results.Add(missing[i].ClientId, missing[i].Identity); + } + else + { + lostClientIds.Add(missing[i].ClientId); + } + } + + if (lostClientIds.Count == 0) + { + return results; + } + + var winners = await cache.GetAllAsync(lostClientIds.Select(clientId => keysByClientId[clientId])); + cancellationToken.ThrowIfCancellationRequested(); + foreach (string clientId in lostClientIds) + { + string key = keysByClientId[clientId]; + if (!winners.TryGetValue(key, out CacheValue? winner) || winner is not { HasValue: true }) + { + throw new InvalidOperationException("The event ingestion identity claim could not be resolved."); + } + + results.Add(clientId, winner.Value); + } + + return results; + } + + public async Task> GetAsync( + string projectId, + IReadOnlyCollection clientIds, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(projectId); + if (clientIds.Count == 0) + { + return new Dictionary(StringComparer.Ordinal); + } + + cancellationToken.ThrowIfCancellationRequested(); + string[] distinctClientIds = clientIds.Distinct(StringComparer.Ordinal).ToArray(); + var keysByClientId = distinctClientIds.ToDictionary( + clientId => clientId, + clientId => GetCacheKey(projectId, clientId), + StringComparer.Ordinal); + var cached = await cache.GetAllAsync(keysByClientId.Values); + cancellationToken.ThrowIfCancellationRequested(); + + var results = new Dictionary(cached.Count, StringComparer.Ordinal); + foreach ((string clientId, string key) in keysByClientId) + { + if (cached.TryGetValue(key, out CacheValue? value) && value is { HasValue: true }) + { + results.Add(clientId, value.Value); + } + } + + return results; + } + + internal static string GetCacheKey(string projectId, string clientId) + { + byte[] hash = SHA256.HashData(Encoding.UTF8.GetBytes(clientId)); + return String.Concat("ingest-v3:id:v2:{", projectId, "}:", Convert.ToHexStringLower(hash)); + } +} diff --git a/src/Exceptionless.Core/Services/EventIngestionV3Processor.cs b/src/Exceptionless.Core/Services/EventIngestionV3Processor.cs new file mode 100644 index 0000000000..bf4e8c4f24 --- /dev/null +++ b/src/Exceptionless.Core/Services/EventIngestionV3Processor.cs @@ -0,0 +1,826 @@ +using System.Text.Json; +using Exceptionless.Core.Extensions; +using Exceptionless.Core.Models; +using Exceptionless.Core.Models.Ingestion; +using Exceptionless.Core.Utility; + +namespace Exceptionless.Core.Services; + +public interface IEventIngestionProcessor +{ + Task ProcessAsync( + IReadOnlyCollection sourceEvents, + Organization organization, + Project project, + CancellationToken cancellationToken); +} + +public sealed class EventIngestionV3Processor( + IStackFingerprintService fingerprintService, + IStackRouteResolver stackRouteResolver, + IEventMaterializer eventMaterializer, + IEventBatchWriter eventBatchWriter, + IIngestionQuotaService quotaService, + SemanticVersionParser semanticVersionParser, + TimeProvider timeProvider) : IEventIngestionProcessor +{ + private static readonly HashSet _legacyPipelineOnlyTypes = new(StringComparer.OrdinalIgnoreCase) + { + Event.KnownTypes.NotFound, + Event.KnownTypes.Session, + Event.KnownTypes.SessionEnd, + Event.KnownTypes.SessionHeartbeat + }; + private static readonly HashSet _reservedLegacyDataKeys = new(StringComparer.OrdinalIgnoreCase) + { + Event.KnownDataKeys.SessionEnd, + Event.KnownDataKeys.SessionHasError + }; + + public Task ProcessAsync( + IReadOnlyCollection sourceEvents, + Organization organization, + Project project, + CancellationToken cancellationToken) + { + var sources = new SourceEvent[sourceEvents.Count]; + int index = 0; + foreach (EventIngestionV3Event sourceEvent in sourceEvents) + { + sources[index++] = new SourceEvent(sourceEvent); + } + + return ProcessCoreAsync(sources, organization, project, cancellationToken); + } + + internal Task ProcessBufferedAsync( + IReadOnlyCollection sourceRecords, + Organization organization, + Project project, + CancellationToken cancellationToken) + { + var sources = new SourceEvent[sourceRecords.Count]; + int index = 0; + foreach (EventIngestionV3BufferedRecord sourceRecord in sourceRecords) + { + sources[index++] = new SourceEvent(sourceRecord); + } + + return ProcessCoreAsync(sources, organization, project, cancellationToken); + } + + private async Task ProcessCoreAsync( + IReadOnlyCollection sourceEvents, + Organization organization, + Project project, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + var response = new EventIngestionV3Response { Received = sourceEvents.Count }; + AppDiagnostics.IngestionV3MicroBatchSize.Record(sourceEvents.Count); + AppDiagnostics.IngestionV3InFlightEvents.Add(sourceEvents.Count); + using var activity = AppDiagnostics.StartActivity("Ingestion V3 Microbatch"); + try + { + if (sourceEvents.Count == 0) + { + return response; + } + + DateTime utcNow = timeProvider.GetUtcNow().UtcDateTime; + var candidates = new List(sourceEvents.Count); + using (AppDiagnostics.StartActivity("Ingestion V3 Fingerprint")) + using (AppDiagnostics.IngestionV3FingerprintTime.StartTimer()) + { + foreach (SourceEvent input in sourceEvents) + { + cancellationToken.ThrowIfCancellationRequested(); + EventIngestionV3Event source = input.RoutingEvent; + string? validationError = ValidateRoutingFields(source); + if (validationError is not null) + { + response.Invalid++; + AddError(response, source.Id ?? String.Empty, "validation_error", validationError); + continue; + } + + candidates.Add(new Candidate(input, fingerprintService.Create(source, organization, project))); + } + } + + if (candidates.Count == 0) + { + return response; + } + + var routes = await stackRouteResolver.ResolveAsync(project.Id, candidates.Select(c => c.Fingerprint.SignatureHash).ToArray(), cancellationToken); + var survivors = new List(candidates.Count); + foreach (var routeGroup in candidates.GroupBy(candidate => candidate.Fingerprint.SignatureHash)) + { + if (!routes.TryGetValue(routeGroup.Key, out var route)) + { + survivors.AddRange(routeGroup); + continue; + } + + if (route.IsDiscarded) + { + response.Discarded += routeGroup.Count(); + continue; + } + + foreach (var candidate in routeGroup) + { + candidate.Route = route; + survivors.Add(candidate); + } + } + + int stackDiscarded = candidates.Count - survivors.Count; + if (stackDiscarded > 0) + { + await quotaService.TrackDiscardedAsync(organization.Id, project.Id, stackDiscarded); + } + + if (survivors.Count == 0) + { + return response; + } + + // Discard routing needs only the small grouping envelope. Validate optional context + // after known-discarded stacks have terminated so free events do not pay to traverse + // large metadata bags that will never be materialized or stored. + for (int index = survivors.Count - 1; index >= 0; index--) + { + Candidate candidate = survivors[index]; + candidate.Materialize(); + string? validationError = Validate(candidate.Source); + if (validationError is null) + { + continue; + } + + response.Invalid++; + AddError(response, candidate.Source.Id, "validation_error", validationError); + survivors.RemoveAt(index); + } + + if (survivors.Count == 0) + { + return response; + } + + IReadOnlyList identities = await eventBatchWriter.PrepareAsync( + survivors.Select(candidate => candidate.Source).ToArray(), + project.Id, + utcNow, + cancellationToken); + var uniqueSurvivors = new List(survivors.Count); + var duplicateReconciliations = new List(); + for (int i = 0; i < survivors.Count; i++) + { + Candidate candidate = survivors[i]; + EventIngestionIdentity identity = identities[i]; + candidate.Identity = identity; + if (!identity.IsDuplicate) + { + uniqueSurvivors.Add(candidate); + continue; + } + + response.Duplicate++; + if (identity.IsPersisted) + { + // A route discarded before this lookup stays on the cheapest path and a + // persisted event whose original stack is now discarded has no recovery + // side effects or usage settlement. Never reconcile using replay payload data. + if (identity.PersistedStackId is null + || identity.PersistedStackStatus is null or StackStatus.Discarded + || !identity.IsRecoveryEligible) + { + continue; + } + + duplicateReconciliations.Add(new EventIngestionReconciliation( + identity.EventId, + identity.PersistedStackId)); + } + } + + int uniqueDiscarded = DiscardUniqueCandidates( + uniqueSurvivors, + organization, + utcNow, + semanticVersionParser); + if (uniqueDiscarded > 0) + { + response.Discarded += uniqueDiscarded; + await quotaService.TrackDiscardedAsync(organization.Id, project.Id, uniqueDiscarded); + } + + if (duplicateReconciliations.Count > 0) + { + await eventBatchWriter.ReconcileAsync(duplicateReconciliations, organization, project, cancellationToken); + } + + if (uniqueSurvivors.Count == 0) + { + return response; + } + + EventIngestionReservation reservation; + using (AppDiagnostics.StartActivity("Ingestion V3 Quota Reserve")) + using (AppDiagnostics.IngestionV3QuotaTime.StartTimer()) + { + reservation = await quotaService.ReserveAsync(organization.Id, uniqueSurvivors.Count); + } + + int admittedCount = reservation.Count; + if (admittedCount < uniqueSurvivors.Count) + { + response.Blocked = uniqueSurvivors.Count - admittedCount; + await quotaService.TrackBlockedAsync(organization.Id, project.Id, response.Blocked); + } + + if (admittedCount == 0) + { + return response; + } + + MarkRegressionCandidates(uniqueSurvivors.Take(admittedCount), utcNow, semanticVersionParser); + + bool retainReservationOnFailure = false; + try + { + var writes = new List(admittedCount); + for (int i = 0; i < admittedCount; i++) + { + cancellationToken.ThrowIfCancellationRequested(); + Candidate candidate = uniqueSurvivors[i]; + PersistentEvent ev; + using (AppDiagnostics.IngestionV3MaterializationTime.StartTimer()) + { + ev = eventMaterializer.Materialize(candidate.Source, candidate.Fingerprint, organization, project); + } + + ev.Id = candidate.Identity!.EventId; + ev.CreatedUtc = candidate.Identity.CreatedUtc; + ev.Date = candidate.Identity.EventDate; + if (candidate.Route?.OccurrencesAreCritical is true) + { + ev.MarkAsCritical(); + } + + writes.Add(new EventIngestionWrite( + candidate.Source.Id, + ev, + candidate.Fingerprint, + candidate.Route, + candidate.IsRegressionCandidate)); + } + + EventBatchWriteResult result; + using (AppDiagnostics.IngestionV3WriteTime.StartTimer()) + { + result = await eventBatchWriter.WriteAsync(writes, organization, project, cancellationToken); + } + + response.Persisted = result.Persisted; + response.Duplicate += result.Duplicate; + + if (result.Settlements.Count > 0) + { + retainReservationOnFailure = true; + using (AppDiagnostics.StartActivity("Ingestion V3 Quota Settle")) + using (AppDiagnostics.IngestionV3SettlementTime.StartTimer()) + { + await quotaService.CommitAsync(organization.Id, project.Id, result.Settlements); + } + + AppDiagnostics.IngestionV3UsageCommitted.Add(result.Settlements.Count); + retainReservationOnFailure = false; + } + + return response; + } + catch (EventBatchWriteException ex) when (ex.Settlements.Count > 0) + { + retainReservationOnFailure = true; + using (AppDiagnostics.StartActivity("Ingestion V3 Partial Write Quota Reconcile")) + using (AppDiagnostics.IngestionV3SettlementTime.StartTimer()) + { + await quotaService.CommitAsync(organization.Id, project.Id, ex.Settlements); + } + + AppDiagnostics.IngestionV3UsageCommitted.Add(ex.Settlements.Count); + retainReservationOnFailure = false; + throw; + } + finally + { + if (!retainReservationOnFailure) + { + await quotaService.ReleaseAsync(reservation); + } + } + } + finally + { + AppDiagnostics.IngestionV3Received.Add(response.Received); + AppDiagnostics.IngestionV3Persisted.Add(response.Persisted); + AppDiagnostics.IngestionV3Discarded.Add(response.Discarded); + AppDiagnostics.IngestionV3Duplicate.Add(response.Duplicate); + AppDiagnostics.IngestionV3Blocked.Add(response.Blocked); + AppDiagnostics.IngestionV3Invalid.Add(response.Invalid); + AppDiagnostics.IngestionV3InFlightEvents.Add(-sourceEvents.Count); + } + } + + private static string? Validate(EventIngestionV3Event source) + { + string? routingError = ValidateRoutingFields(source); + if (routingError is not null) + { + return routingError; + } + + if (source.Date?.UtcDateTime < DateTime.UnixEpoch) + { + return "date cannot be earlier than 1970-01-01T00:00:00Z."; + } + + if (source.Message is not null && source.Message.Length is < 1 or > EventIngestionV3Limits.MaximumMessageLength) + { + return $"message must contain between 1 and {EventIngestionV3Limits.MaximumMessageLength} characters."; + } + + if (source.ReferenceId is not null + && (source.ReferenceId.Length is < EventIngestionV3Limits.MinimumReferenceIdLength or > EventIngestionV3Limits.MaximumReferenceIdLength + || !source.ReferenceId.IsValidIdentifier())) + { + return $"reference_id must contain between {EventIngestionV3Limits.MinimumReferenceIdLength} and {EventIngestionV3Limits.MaximumReferenceIdLength} alphanumeric or '-' characters."; + } + + if (source.Version is not null && (String.IsNullOrWhiteSpace(source.Version) || source.Version.Length > EventIngestionV3Limits.MaximumVersionLength)) + { + return $"version must contain between 1 and {EventIngestionV3Limits.MaximumVersionLength} characters."; + } + + if (source.Level is not null && (String.IsNullOrWhiteSpace(source.Level) || source.Level.Length > EventIngestionV3Limits.MaximumLevelLength)) + { + return $"level must contain between 1 and {EventIngestionV3Limits.MaximumLevelLength} characters."; + } + + if (source.Client is not null) + { + if (String.IsNullOrWhiteSpace(source.Client.Name) || source.Client.Name.Length > EventIngestionV3Limits.MaximumClientNameLength) + { + return $"client.name must contain between 1 and {EventIngestionV3Limits.MaximumClientNameLength} characters."; + } + + if (String.IsNullOrWhiteSpace(source.Client.Version) || source.Client.Version.Length > EventIngestionV3Limits.MaximumClientVersionLength) + { + return $"client.version must contain between 1 and {EventIngestionV3Limits.MaximumClientVersionLength} characters."; + } + } + if (source.Stacking?.Title is not null && source.Stacking.Title.Length is < 1 or > EventIngestionV3Limits.MaximumStackTitleLength) + { + return $"stacking.title must contain between 1 and {EventIngestionV3Limits.MaximumStackTitleLength} characters."; + } + + if (source.Tags is { Length: > EventIngestionV3Limits.MaximumTags }) + { + return $"tags cannot contain more than {EventIngestionV3Limits.MaximumTags} values."; + } + + if (source.Tags?.Any(tag => tag is null || String.IsNullOrWhiteSpace(tag) || tag.Length > EventIngestionV3Limits.MaximumTagLength) is true) + { + return $"tags must be non-empty and cannot exceed {EventIngestionV3Limits.MaximumTagLength} characters."; + } + + if (source.User?.Identity?.Length > EventIngestionV3Limits.MaximumUserIdentityLength) + { + return $"user.identity cannot exceed {EventIngestionV3Limits.MaximumUserIdentityLength} characters."; + } + + if (source.User?.Name?.Length > EventIngestionV3Limits.MaximumUserNameLength) + { + return $"user.name cannot exceed {EventIngestionV3Limits.MaximumUserNameLength} characters."; + } + + if (source.Request is not null) + { + string? requestError = ValidateRequest(source.Request); + if (requestError is not null) + { + return requestError; + } + } + if (source.Environment is not null) + { + string? environmentError = ValidateEnvironment(source.Environment); + if (environmentError is not null) + { + return environmentError; + } + } + + string? dataError = ValidateEventData(source.Data); + if (dataError is not null) + { + return dataError; + } + + dataError = ValidateJsonObject(source.User?.Data, "user.data"); + if (dataError is not null) + { + return dataError; + } + + return null; + } + + private static string? ValidateRoutingFields(EventIngestionV3Event source) + { + if (String.IsNullOrWhiteSpace(source.Id) || source.Id.Length > EventIngestionV3Limits.MaximumEventIdLength) + { + return $"id must contain between 1 and {EventIngestionV3Limits.MaximumEventIdLength} characters."; + } + + if (String.IsNullOrWhiteSpace(source.Type) || source.Type.Length > EventIngestionV3Limits.MaximumTypeLength) + { + return $"type must contain between 1 and {EventIngestionV3Limits.MaximumTypeLength} characters."; + } + + if (_legacyPipelineOnlyTypes.Contains(source.Type)) + { + return $"type '{source.Type}' is not supported by V3 ingestion; use the V2 endpoint for this legacy stateful event type."; + } + + if (source.Source is not null && source.Source.Length is < 1 or > EventIngestionV3Limits.MaximumSourceLength) + { + return $"source must contain between 1 and {EventIngestionV3Limits.MaximumSourceLength} characters."; + } + + if (source.ExceptionType?.Length > EventIngestionV3Limits.MaximumExceptionTypeLength) + { + return $"exception_type cannot exceed {EventIngestionV3Limits.MaximumExceptionTypeLength} characters."; + } + + if (source.StackTrace?.Length > EventIngestionV3Limits.MaximumStackTraceLength) + { + return $"stack_trace cannot exceed {EventIngestionV3Limits.MaximumStackTraceLength} characters."; + } + + if (source.Stacking is null) + { + return null; + } + + if (source.Stacking.SignatureData is not { Count: > 0 }) + { + return "stacking.signature_data must contain at least one value."; + } + + if (source.Stacking.SignatureData.Any(pair => pair.Value is null)) + { + return "stacking.signature_data values cannot be null."; + } + + return ValidateDictionary( + source.Stacking.SignatureData, + "stacking.signature_data", + value => value.Length <= EventIngestionV3Limits.MaximumMetadataValueLength); + } + + private static string? ValidateRequest(EventIngestionV3Request request) + { + if (request.UserAgent?.Length > EventIngestionV3Limits.MaximumMetadataValueLength) + { + return $"request.user_agent cannot exceed {EventIngestionV3Limits.MaximumMetadataValueLength} characters."; + } + + if (request.HttpMethod?.Length > 32) + { + return "request.http_method cannot exceed 32 characters."; + } + + if (request.Host?.Length > 255) + { + return "request.host cannot exceed 255 characters."; + } + + if (request.Path?.Length > EventIngestionV3Limits.MaximumMetadataValueLength) + { + return $"request.path cannot exceed {EventIngestionV3Limits.MaximumMetadataValueLength} characters."; + } + + if (request.Referrer?.Length > EventIngestionV3Limits.MaximumMetadataValueLength) + { + return $"request.referrer cannot exceed {EventIngestionV3Limits.MaximumMetadataValueLength} characters."; + } + + if (request.ClientIpAddress?.Length > 64) + { + return "request.client_ip_address cannot exceed 64 characters."; + } + + string? dictionaryError = ValidateDictionary(request.Headers, "request.headers", values => values is not null + && values.Length <= EventIngestionV3Limits.MaximumMetadataEntries + && values.All(value => value is not null && value.Length <= EventIngestionV3Limits.MaximumMetadataValueLength)); + if (dictionaryError is not null) + { + return dictionaryError; + } + + dictionaryError = ValidateDictionary(request.Cookies, "request.cookies", value => value.Length <= EventIngestionV3Limits.MaximumMetadataValueLength); + if (dictionaryError is not null) + { + return dictionaryError; + } + + dictionaryError = ValidateDictionary(request.QueryString, "request.query_string", value => value.Length <= EventIngestionV3Limits.MaximumMetadataValueLength); + if (dictionaryError is not null) + { + return dictionaryError; + } + + string? dataError = ValidateJson(request.PostData, "request.post_data"); + return dataError ?? ValidateJsonObject(request.Data, "request.data"); + } + + private static string? ValidateEnvironment(EventIngestionV3Environment environment) + { + string?[] values = + [ + environment.Architecture, + environment.OSName, + environment.OSVersion, + environment.MachineName, + environment.RuntimeVersion, + environment.ProcessName, + environment.ProcessId, + environment.ThreadName, + environment.ThreadId + ]; + if (values.Any(value => value?.Length > EventIngestionV3Limits.MaximumMetadataValueLength)) + { + return $"environment string values cannot exceed {EventIngestionV3Limits.MaximumMetadataValueLength} characters."; + } + + return ValidateJsonObject(environment.Data, "environment.data"); + } + + private static string? ValidateDictionary(IReadOnlyDictionary? dictionary, string path, Func validateValue) + { + if (dictionary is null) + { + return null; + } + + if (dictionary.Count > EventIngestionV3Limits.MaximumMetadataEntries) + { + return $"{path} cannot contain more than {EventIngestionV3Limits.MaximumMetadataEntries} entries."; + } + + if (dictionary.Any(pair => String.IsNullOrEmpty(pair.Key) + || pair.Key.Length > EventIngestionV3Limits.MaximumMetadataKeyLength + || pair.Value is null + || !validateValue(pair.Value))) + { + return $"{path} contains a key or value that exceeds its limit."; + } + + return null; + } + + private static string? ValidateJson(JsonElement? element, string path) + { + if (element is null) + { + return null; + } + + int tokens = 0; + return ValidateJsonValue(element.Value, path, ref tokens); + } + + private static string? ValidateJsonObject(JsonElement? element, string path) + { + if (element is { ValueKind: not JsonValueKind.Object }) + { + return $"{path} must be a JSON object."; + } + + return ValidateJson(element, path); + } + + private static string? ValidateEventData(JsonElement? element) + { + if (element is { ValueKind: not JsonValueKind.Object }) + { + return "data must be a JSON object."; + } + + if (element is null) + { + return null; + } + + foreach (JsonProperty property in element.Value.EnumerateObject()) + { + if (property.Name.StartsWith('@') || _reservedLegacyDataKeys.Contains(property.Name)) + { + return $"data contains reserved top-level key '{property.Name}'. Use the corresponding first-class V3 field instead."; + } + } + + return ValidateJson(element, "data"); + } + + private static string? ValidateJsonValue(JsonElement element, string path, ref int tokens) + { + tokens++; + if (tokens > EventIngestionV3Limits.MaximumDataTokens) + { + return $"{path} cannot contain more than {EventIngestionV3Limits.MaximumDataTokens} JSON values."; + } + + if (element.ValueKind == JsonValueKind.String && element.GetString()?.Length > EventIngestionV3Limits.MaximumDataStringLength) + { + return $"{path} contains a string longer than {EventIngestionV3Limits.MaximumDataStringLength} characters."; + } + + if (element.ValueKind == JsonValueKind.Object) + { + foreach (JsonProperty property in element.EnumerateObject()) + { + if (property.Name.Length > EventIngestionV3Limits.MaximumMetadataKeyLength) + { + return $"{path} contains a property name longer than {EventIngestionV3Limits.MaximumMetadataKeyLength} characters."; + } + + string? error = ValidateJsonValue(property.Value, path, ref tokens); + if (error is not null) + { + return error; + } + } + } + else if (element.ValueKind == JsonValueKind.Array) + { + foreach (JsonElement item in element.EnumerateArray()) + { + string? error = ValidateJsonValue(item, path, ref tokens); + if (error is not null) + { + return error; + } + } + } + + return null; + } + + private static void AddError(EventIngestionV3Response response, string id, string code, string message) + { + if (response.Errors.Count < 100) + { + response.Errors.Add(new EventIngestionV3Error(id, code, message)); + } + } + + private static void MarkRegressionCandidates( + IEnumerable admittedCandidates, + DateTime utcNow, + SemanticVersionParser semanticVersionParser) + { + foreach (var routeGroup in admittedCandidates + .Where(candidate => candidate.Route is { Status: StackStatus.Fixed, DateFixed: not null }) + .GroupBy(candidate => candidate.Fingerprint.SignatureHash)) + { + StackRoute route = routeGroup.First().Route!; + Candidate[] orderedCandidates = routeGroup + .OrderBy(candidate => candidate.Source.Date?.UtcDateTime ?? utcNow) + .ToArray(); + Candidate? regression = null; + if (String.IsNullOrEmpty(route.FixedInVersion)) + { + regression = orderedCandidates.FirstOrDefault(candidate => + (candidate.Source.Date?.UtcDateTime ?? utcNow) > route.DateFixed!.Value); + } + else + { + var fixedVersion = semanticVersionParser.Parse(route.FixedInVersion); + if (fixedVersion is not null) + { + foreach (var versionGroup in orderedCandidates.GroupBy(candidate => candidate.Source.Version)) + { + var version = semanticVersionParser.Parse(versionGroup.Key) ?? semanticVersionParser.Default; + if (version < fixedVersion) + { + continue; + } + + regression = versionGroup.First(); + break; + } + } + } + + if (regression is not null) + { + regression.IsRegressionCandidate = true; + } + } + } + + private static int DiscardUniqueCandidates( + List uniqueCandidates, + Organization organization, + DateTime utcNow, + SemanticVersionParser semanticVersionParser) + { + int discarded = uniqueCandidates.RemoveAll(candidate => + { + DateTime eventDate = candidate.Source.Date?.UtcDateTime ?? utcNow; + double eventAgeInDays = utcNow.Subtract(eventDate).TotalDays; + return eventAgeInDays > 3 + || (organization.RetentionDays > 0 && eventAgeInDays > organization.RetentionDays); + }); + + if (!organization.HasPremiumFeatures) + { + return discarded; + } + + foreach (var routeGroup in uniqueCandidates + .Where(candidate => candidate.Route is { Status: StackStatus.Fixed, DateFixed: not null, FixedInVersion: not null }) + .GroupBy(candidate => candidate.Fingerprint.SignatureHash) + .ToArray()) + { + StackRoute route = routeGroup.First().Route!; + var fixedVersion = semanticVersionParser.Parse(route.FixedInVersion); + if (fixedVersion is null) + { + continue; + } + + foreach (Candidate candidate in routeGroup.ToArray()) + { + var version = semanticVersionParser.Parse(candidate.Source.Version) ?? semanticVersionParser.Default; + if (version >= fixedVersion) + { + continue; + } + + if (uniqueCandidates.Remove(candidate)) + { + discarded++; + } + } + } + + return discarded; + } + + private readonly struct SourceEvent + { + private readonly EventIngestionV3BufferedRecord? _bufferedRecord; + + public SourceEvent(EventIngestionV3Event source) + { + RoutingEvent = source; + } + + public SourceEvent(EventIngestionV3BufferedRecord bufferedRecord) + { + _bufferedRecord = bufferedRecord; + RoutingEvent = bufferedRecord.RoutingEvent; + } + + public EventIngestionV3Event RoutingEvent { get; } + + public EventIngestionV3Event Materialize() => _bufferedRecord?.Materialize() ?? RoutingEvent; + } + + private sealed class Candidate(SourceEvent source, StackFingerprint fingerprint) + { + private readonly SourceEvent _source = source; + + public EventIngestionV3Event Source { get; private set; } = source.RoutingEvent; + public StackFingerprint Fingerprint { get; private set; } = fingerprint; + public StackRoute? Route { get; set; } + public EventIngestionIdentity? Identity { get; set; } + public bool IsRegressionCandidate { get; set; } + + public void Materialize() + { + Source = _source.Materialize(); + if (Source.Stacking is not null) + { + Fingerprint = Fingerprint with { Title = Source.Stacking.Title }; + } + } + } +} diff --git a/src/Exceptionless.Core/Services/EventMaterializer.cs b/src/Exceptionless.Core/Services/EventMaterializer.cs new file mode 100644 index 0000000000..79ceef8478 --- /dev/null +++ b/src/Exceptionless.Core/Services/EventMaterializer.cs @@ -0,0 +1,202 @@ +using System.Text.Json; +using Exceptionless.Core.Extensions; +using Exceptionless.Core.Models; +using Exceptionless.Core.Models.Data; +using Exceptionless.Core.Models.Ingestion; +using Exceptionless.Core.Serialization; + +namespace Exceptionless.Core.Services; + +public interface IEventMaterializer +{ + PersistentEvent Materialize(EventIngestionV3Event source, StackFingerprint fingerprint, Organization organization, Project project); +} + +public sealed class EventMaterializer(StackTraceParser stackTraceParser, TimeProvider timeProvider) : IEventMaterializer +{ + public PersistentEvent Materialize(EventIngestionV3Event source, StackFingerprint fingerprint, Organization organization, Project project) + { + DateTimeOffset utcNow = timeProvider.GetUtcNow(); + var ev = new PersistentEvent + { + Type = source.Type, + Source = source.Source, + Date = source.Date ?? utcNow, + CreatedUtc = utcNow.UtcDateTime, + Message = source.Message, + ReferenceId = source.ReferenceId, + Value = source.Value, + Tags = source.Tags is null ? [] : new TagSet(source.Tags), + Data = ConvertData(source.Data), + OrganizationId = organization.Id, + ProjectId = project.Id + }; + + if (String.Equals(source.Type, Event.KnownTypes.Error, StringComparison.OrdinalIgnoreCase)) + { + StackTraceParseResult? parseResult = String.IsNullOrEmpty(source.StackTrace) + ? null + : stackTraceParser.ParseErrorWithCoverage(source.StackTrace, source.ExceptionType, source.Message); + var error = parseResult?.Error + ?? new Error { Type = source.ExceptionType, Message = source.Message, StackTrace = [] }; + ev.SetError(error); + + // Keep unsupported or only partially understood traces lossless so a newer server-side + // parser can recover sections that this version could not represent. Fully parsed traces + // are not duplicated because stack payloads can be large. + if (!String.IsNullOrEmpty(source.StackTrace) && parseResult is { IsComplete: false }) + { + ev.SetSimpleError(new SimpleError + { + Type = source.ExceptionType, + Message = source.Message, + StackTrace = source.StackTrace + }); + } + } + + if (String.Equals(source.Type, Event.KnownTypes.Error, StringComparison.OrdinalIgnoreCase) || source.Stacking is not null) + { + if (String.IsNullOrWhiteSpace(fingerprint.Title)) + { + ev.SetManualStackingInfo(fingerprint.SignatureData.ToDictionary(pair => pair.Key, pair => pair.Value)); + } + else + { + ev.SetManualStackingInfo(fingerprint.Title, fingerprint.SignatureData.ToDictionary(pair => pair.Key, pair => pair.Value)); + } + } + + if (!String.IsNullOrWhiteSpace(source.Version)) + { + ev.SetVersion(source.Version); + } + + if (!String.IsNullOrWhiteSpace(source.Level)) + { + ev.SetLevel(source.Level.Trim()); + } + + if (source.Client is not null) + { + ev.SetSubmissionClient(new SubmissionClient + { + UserAgent = source.Client.Name.Trim(), + Version = source.Client.Version.Trim() + }); + } + + if (source.User is not null) + { + ev.SetUserIdentity(new UserInfo(source.User.Identity ?? String.Empty, source.User.Name) + { + Data = ConvertData(source.User.Data) + }); + } + + bool includePrivateInformation = project.Configuration.Settings.GetBoolean(SettingsDictionary.KnownKeys.IncludePrivateInformation, true); + if (source.Request is not null) + { + RequestInfo request = Map(source.Request); + var exclusions = RequestInfoExtensions.DefaultDataExclusions + .Union(project.Configuration.Settings.GetStringCollection(SettingsDictionary.KnownKeys.DataExclusions)) + .ToList(); + request.ApplyDataExclusions(exclusions, RequestInfoExtensions.MaximumDataValueLength); + if (!includePrivateInformation) + { + request.ClientIpAddress = null; + request.Cookies?.Clear(); + request.PostData = null; + request.QueryString?.Clear(); + } + + ev.Data![Event.KnownDataKeys.RequestInfo] = request; + } + + if (source.Environment is not null) + { + ev.SetEnvironmentInfo(Map(source.Environment)); + } + + if (utcNow.UtcDateTime < ev.Date.UtcDateTime) + { + ev.Date = utcNow; + } + + ev.Tags?.RemoveExcessTags(); + ev.Message = String.IsNullOrWhiteSpace(ev.Message) ? null : ev.Message.Truncate(2000); + ev.Source = String.IsNullOrWhiteSpace(ev.Source) ? null : ev.Source.Truncate(2000); + if (!ev.HasValidReferenceId()) + { + ev.Data["InvalidReferenceId"] = ev.ReferenceId; + ev.ReferenceId = "invalid-reference-id"; + } + + if (!includePrivateInformation) + { + ev.RemoveUserIdentity(); + if (ev.Data.TryGetValue(Event.KnownDataKeys.EnvironmentInfo, out object? environmentValue) && environmentValue is EnvironmentInfo environment) + { + environment.MachineName = null; + } + } + + if (organization.HasPremiumFeatures) + { + ev.CopyDataToIndex([]); + } + + return ev; + } + + private static RequestInfo Map(EventIngestionV3Request source) => new() + { + UserAgent = source.UserAgent, + HttpMethod = source.HttpMethod, + IsSecure = source.IsSecure, + Host = source.Host, + Port = source.Port, + Path = source.Path, + Referrer = source.Referrer, + ClientIpAddress = source.ClientIpAddress, + Headers = source.Headers, + Cookies = source.Cookies, + QueryString = source.QueryString, + PostData = source.PostData.HasValue ? JsonElementConverter.Convert(source.PostData.Value) : null, + Data = ConvertData(source.Data) + }; + + private static EnvironmentInfo Map(EventIngestionV3Environment source) => new() + { + Architecture = source.Architecture, + OSName = source.OSName, + OSVersion = source.OSVersion, + MachineName = source.MachineName, + RuntimeVersion = source.RuntimeVersion, + ProcessName = source.ProcessName, + ProcessId = source.ProcessId, + ThreadName = source.ThreadName, + ThreadId = source.ThreadId, + ProcessorCount = source.ProcessorCount, + TotalPhysicalMemory = source.TotalPhysicalMemory, + AvailablePhysicalMemory = source.AvailablePhysicalMemory, + ProcessMemorySize = source.ProcessMemorySize, + Data = ConvertData(source.Data) + }; + + private static DataDictionary ConvertData(JsonElement? element) + { + if (element is not { ValueKind: JsonValueKind.Object } value) + { + return []; + } + + var data = new DataDictionary(); + foreach (JsonProperty property in value.EnumerateObject()) + { + data[property.Name] = JsonElementConverter.Convert(property.Value); + } + + return data; + } +} diff --git a/src/Exceptionless.Core/Services/EventPostProcessingStatus.cs b/src/Exceptionless.Core/Services/EventPostProcessingStatus.cs new file mode 100644 index 0000000000..cbc78810af --- /dev/null +++ b/src/Exceptionless.Core/Services/EventPostProcessingStatus.cs @@ -0,0 +1,6 @@ +namespace Exceptionless.Core.Services; + +public sealed record EventPostProcessingStatus( + string ProjectId, + bool IsCompleted, + DateTimeOffset UpdatedUtc); diff --git a/src/Exceptionless.Core/Services/EventPostService.cs b/src/Exceptionless.Core/Services/EventPostService.cs index a025808989..abbaa9c253 100644 --- a/src/Exceptionless.Core/Services/EventPostService.cs +++ b/src/Exceptionless.Core/Services/EventPostService.cs @@ -1,4 +1,6 @@ -using Exceptionless.Core.Queues.Models; +using Exceptionless.Core.Extensions; +using Exceptionless.Core.Queues.Models; +using Foundatio.Caching; using Foundatio.Queues; using Foundatio.Storage; using Microsoft.Extensions.Logging; @@ -7,20 +9,141 @@ namespace Exceptionless.Core.Services; public class EventPostService { + private static readonly TimeSpan _processingTrackingTtl = TimeSpan.FromDays(1); private readonly IQueue _queue; private readonly IFileStorage _storage; + private readonly ICacheClient _cache; private readonly TimeProvider _timeProvider; private readonly ILogger _logger; - public EventPostService(IQueue queue, IFileStorage storage, + public EventPostService(IQueue queue, IFileStorage storage, ICacheClient cache, TimeProvider timeProvider, ILoggerFactory loggerFactory) { _queue = queue; _storage = storage; + _cache = cache; _timeProvider = timeProvider; _logger = loggerFactory.CreateLogger(); } + public async Task InitializeProcessingTrackingAsync(string correlationId, string projectId) + { + ArgumentException.ThrowIfNullOrEmpty(correlationId); + ArgumentException.ThrowIfNullOrEmpty(projectId); + + var status = new EventPostProcessingStatus(projectId, false, _timeProvider.GetUtcNow()); + try + { + bool statusAdded = await _cache.AddAsync(GetProcessingStatusCacheKey(correlationId), status, _processingTrackingTtl); + bool pendingAdded = await _cache.AddAsync(GetProcessingPendingCacheKey(correlationId), 1L, _processingTrackingTtl); + if (statusAdded && pendingAdded) + { + return true; + } + + var existingStatus = await _cache.GetAsync(GetProcessingStatusCacheKey(correlationId)); + var existingPending = await _cache.GetAsync(GetProcessingPendingCacheKey(correlationId)); + return existingStatus is { HasValue: true, Value.ProjectId: var existingProjectId } + && String.Equals(existingProjectId, projectId, StringComparison.Ordinal) + && existingPending is { HasValue: true, Value: >= 0 }; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Unable to initialize event post processing tracking {CorrelationId} for project {ProjectId}", correlationId, projectId); + } + + return false; + } + + public async Task AddPendingProcessingUnitsAsync(EventPost eventPost, int count) + { + if (String.IsNullOrEmpty(eventPost.ProcessingCorrelationId) || count <= 0) + { + return true; + } + + try + { + long pending = await _cache.IncrementAsync( + GetProcessingPendingCacheKey(eventPost.ProcessingCorrelationId), + count, + _processingTrackingTtl); + + // Increment creates a missing key at exactly count. A valid correlation always has + // at least its parent unit, so fail closed instead of completing descendants early. + return pending > count; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Unable to add {Count} pending units to event post processing tracking {CorrelationId}", count, eventPost.ProcessingCorrelationId); + return false; + } + } + + public async Task MarkProcessingCompletedAsync(string queueEntryId, EventPost eventPost, bool completeTracking = true) + { + if (!completeTracking || String.IsNullOrEmpty(eventPost.ProcessingCorrelationId)) + { + return; + } + + try + { + var pendingState = await _cache.GetAsync(GetProcessingPendingCacheKey(eventPost.ProcessingCorrelationId)); + if (!pendingState.HasValue || pendingState.Value <= 0) + { + return; + } + + string completedUnitKey = GetProcessingCompletedUnitCacheKey(eventPost.ProcessingCorrelationId, queueEntryId); + if (!await _cache.AddAsync(completedUnitKey, true, _processingTrackingTtl)) + { + return; + } + + long pending = await _cache.DecrementAsync( + GetProcessingPendingCacheKey(eventPost.ProcessingCorrelationId), + 1, + _processingTrackingTtl); + if (pending > 0) + { + return; + } + + if (pending < 0) + { + _logger.LogWarning("Event post processing tracking {CorrelationId} has an invalid pending count of {PendingCount}", eventPost.ProcessingCorrelationId, pending); + return; + } + + var status = new EventPostProcessingStatus(eventPost.ProjectId, true, _timeProvider.GetUtcNow()); + await _cache.SetAsync(GetProcessingStatusCacheKey(eventPost.ProcessingCorrelationId), status, _processingTrackingTtl); + } + catch (Exception ex) + { + // Observability must never make an otherwise successful event post retry. A partial + // marker update remains non-terminal, causing the benchmark to fail closed on timeout. + _logger.LogWarning(ex, "Unable to complete event post processing unit {QueueEntryId} for tracking {CorrelationId}", queueEntryId, eventPost.ProcessingCorrelationId); + } + } + + public async Task> GetProcessingStatusesAsync(IEnumerable queueEntryIds) + { + string[] ids = queueEntryIds.Distinct(StringComparer.Ordinal).ToArray(); + var keys = ids.ToDictionary(id => id, GetProcessingStatusCacheKey, StringComparer.Ordinal); + var cached = await _cache.GetAllAsync(keys.Values); + var statuses = new Dictionary(StringComparer.Ordinal); + foreach (string id in ids) + { + if (cached.TryGetValue(keys[id], out var value) && value.HasValue) + { + statuses[id] = value.Value; + } + } + + return statuses; + } + public async Task EnqueueAsync(EventPost data, Stream stream, CancellationToken cancellationToken = default) { var result = await SaveAndEnqueueAsync(data, stream, cancellationToken); @@ -56,7 +179,9 @@ public async Task SaveAndEnqueueAsync(EventPost data, St if (!infoSaved) { using (_logger.BeginScope(new ExceptionlessState().Organization(data.OrganizationId).Property(nameof(EventPostInfo), data))) + { _logger.LogError("Unable to save event post info"); + } return EventPostEnqueueResult.Failed; } @@ -64,7 +189,9 @@ public async Task SaveAndEnqueueAsync(EventPost data, St if (!payloadSaved) { using (_logger.BeginScope(new ExceptionlessState().Organization(data.OrganizationId).Property(nameof(EventPostInfo), data))) + { _logger.LogError("Unable to save event post payload"); + } return EventPostEnqueueResult.Failed; } @@ -76,7 +203,9 @@ public async Task SaveAndEnqueueAsync(EventPost data, St public async Task GetEventPostPayloadAsync(string path) { if (String.IsNullOrEmpty(path)) + { return null; + } byte[]? data; try @@ -95,11 +224,15 @@ public async Task SaveAndEnqueueAsync(EventPost data, St public async Task CompleteEventPostAsync(string path, string projectId, DateTime created, bool shouldArchive = true) { if (String.IsNullOrEmpty(path)) + { return false; + } // don't move files that are already in the archive if (path.StartsWith("archive")) + { return true; + } try { @@ -125,10 +258,23 @@ private static string GetArchivePath(DateTime createdUtc, string projectId, stri return Path.Combine("archive", createdUtc.ToString("yy"), createdUtc.ToString("MM"), createdUtc.ToString("dd"), createdUtc.ToString("HH"), createdUtc.ToString("mm"), projectId, fileName); } + private static string GetProcessingStatusCacheKey(string correlationId) + { + return $"event-post-status:{correlationId.ToSHA256()}"; + } + + private static string GetProcessingPendingCacheKey(string correlationId) => + String.Concat(GetProcessingStatusCacheKey(correlationId), ":pending"); + + private static string GetProcessingCompletedUnitCacheKey(string correlationId, string queueEntryId) => + String.Concat(GetProcessingStatusCacheKey(correlationId), ":completed:", queueEntryId.ToSHA256()); + private async Task DeleteSavedEventPostFilesAsync(EventPost data) { if (String.IsNullOrEmpty(data.FilePath)) + { return; + } try { @@ -138,14 +284,18 @@ private async Task DeleteSavedEventPostFilesAsync(EventPost data) }; if (data.ShouldArchive) + { tasks.Add(_storage.DeleteFileAsync(data.FilePath)); + } await Task.WhenAll(tasks); } catch (StorageException ex) { using (_logger.BeginScope(new ExceptionlessState().Organization(data.OrganizationId).Property(nameof(EventPostInfo), data))) + { _logger.LogWarning(ex, "Unable to delete rejected event post payload"); + } } } } diff --git a/src/Exceptionless.Core/Services/IngestionQuotaService.cs b/src/Exceptionless.Core/Services/IngestionQuotaService.cs new file mode 100644 index 0000000000..fc998fdae7 --- /dev/null +++ b/src/Exceptionless.Core/Services/IngestionQuotaService.cs @@ -0,0 +1,22 @@ +using Exceptionless.Core.Models.Ingestion; + +namespace Exceptionless.Core.Services; + +public interface IIngestionQuotaService +{ + Task ReserveAsync(string organizationId, int eventCount); + Task CommitAsync(string organizationId, string projectId, IReadOnlyCollection settlements); + Task ReleaseAsync(EventIngestionReservation reservation); + Task TrackBlockedAsync(string organizationId, string projectId, int eventCount); + Task TrackDiscardedAsync(string organizationId, string projectId, int eventCount); +} + +public sealed class IngestionQuotaService(UsageService usageService) : IIngestionQuotaService +{ + public Task ReserveAsync(string organizationId, int eventCount) => usageService.ReserveEventsAsync(organizationId, eventCount); + public Task CommitAsync(string organizationId, string projectId, IReadOnlyCollection settlements) => + usageService.IncrementTotalAsync(organizationId, projectId, settlements); + public Task ReleaseAsync(EventIngestionReservation reservation) => usageService.ReleaseEventReservationAsync(reservation); + public Task TrackBlockedAsync(string organizationId, string projectId, int eventCount) => usageService.IncrementBlockedAsync(organizationId, projectId, eventCount); + public Task TrackDiscardedAsync(string organizationId, string projectId, int eventCount) => usageService.IncrementDiscardedAsync(organizationId, projectId, eventCount); +} diff --git a/src/Exceptionless.Core/Services/IngestionQuotaStore.cs b/src/Exceptionless.Core/Services/IngestionQuotaStore.cs new file mode 100644 index 0000000000..f4396b5059 --- /dev/null +++ b/src/Exceptionless.Core/Services/IngestionQuotaStore.cs @@ -0,0 +1,125 @@ +using System.Collections.Concurrent; + +namespace Exceptionless.Core.Services; + +public interface IIngestionQuotaStore +{ + Task ReserveAsync( + string organizationId, + string reservationId, + int requestedCount, + int availableCount, + TimeSpan expiresIn, + CancellationToken cancellationToken = default); + + Task ReleaseAsync( + string organizationId, + string reservationId, + CancellationToken cancellationToken = default); +} + +/// +/// Process-local quota reservations for development and tests. Distributed deployments replace +/// this registration with the Redis implementation from Exceptionless.Insulation. +/// +public sealed class InMemoryIngestionQuotaStore(TimeProvider timeProvider) : IIngestionQuotaStore +{ + private readonly ConcurrentDictionary _organizations = new(StringComparer.Ordinal); + + public Task ReserveAsync( + string organizationId, + string reservationId, + int requestedCount, + int availableCount, + TimeSpan expiresIn, + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + ArgumentException.ThrowIfNullOrWhiteSpace(organizationId); + ArgumentException.ThrowIfNullOrWhiteSpace(reservationId); + ArgumentOutOfRangeException.ThrowIfNegative(requestedCount); + ArgumentOutOfRangeException.ThrowIfNegative(availableCount); + if (expiresIn <= TimeSpan.Zero) + { + throw new ArgumentOutOfRangeException(nameof(expiresIn)); + } + + if (requestedCount == 0) + { + return Task.FromResult(0); + } + + var state = _organizations.GetOrAdd(organizationId, static _ => new ReservationState()); + lock (state.SyncRoot) + { + DateTimeOffset utcNow = timeProvider.GetUtcNow(); + RemoveExpired(state, utcNow); + if (state.Reservations.TryGetValue(reservationId, out Reservation existing)) + { + return Task.FromResult(existing.Count); + } + + int admittedCount = (int)Math.Min( + requestedCount, + Math.Max(0L, (long)availableCount - state.ActiveCount)); + if (admittedCount == 0) + { + return Task.FromResult(0); + } + + state.Reservations.Add(reservationId, new Reservation(admittedCount, utcNow.Add(expiresIn))); + state.ActiveCount += admittedCount; + return Task.FromResult(admittedCount); + } + } + + public Task ReleaseAsync( + string organizationId, + string reservationId, + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + ArgumentException.ThrowIfNullOrWhiteSpace(organizationId); + ArgumentException.ThrowIfNullOrWhiteSpace(reservationId); + + if (!_organizations.TryGetValue(organizationId, out ReservationState? state)) + { + return Task.CompletedTask; + } + + lock (state.SyncRoot) + { + RemoveExpired(state, timeProvider.GetUtcNow()); + if (state.Reservations.Remove(reservationId, out Reservation reservation)) + { + state.ActiveCount -= reservation.Count; + } + } + + return Task.CompletedTask; + } + + private static void RemoveExpired(ReservationState state, DateTimeOffset utcNow) + { + string[] expiredReservationIds = state.Reservations + .Where(pair => pair.Value.ExpiresUtc <= utcNow) + .Select(pair => pair.Key) + .ToArray(); + foreach (string reservationId in expiredReservationIds) + { + if (state.Reservations.Remove(reservationId, out Reservation reservation)) + { + state.ActiveCount -= reservation.Count; + } + } + } + + private sealed class ReservationState + { + public object SyncRoot { get; } = new(); + public Dictionary Reservations { get; } = new(StringComparer.Ordinal); + public int ActiveCount { get; set; } + } + + private readonly record struct Reservation(int Count, DateTimeOffset ExpiresUtc); +} diff --git a/src/Exceptionless.Core/Services/IngestionSideEffectExecutor.cs b/src/Exceptionless.Core/Services/IngestionSideEffectExecutor.cs new file mode 100644 index 0000000000..8b94cd5153 --- /dev/null +++ b/src/Exceptionless.Core/Services/IngestionSideEffectExecutor.cs @@ -0,0 +1,243 @@ +using Foundatio.Caching; +using Foundatio.Lock; + +namespace Exceptionless.Core.Services; + +/// +/// Runs ingestion side effects with at-least-once crash semantics and suppresses completed retries. +/// Completion is recorded only after the effect succeeds, so an abandoned worker can never poison +/// a retry before doing the work. +/// +public sealed class IngestionSideEffectExecutor( + ICacheClient cache, + ILockProvider lockProvider, + AppOptions options) +{ + public const string StatisticsStage = "statistics"; + public const string TerminalStage = "notifications"; + public const string ProjectConfiguredStage = "project-configured"; + + private const int StatisticsStageFlag = IngestionStackUsageStore.StatisticsStageFlag; + private const int NotificationsStageFlag = 1 << 1; + + public async Task ExecuteAsync( + string stage, + string projectId, + IReadOnlyCollection identities, + Func, Task> action, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrEmpty(stage); + ArgumentException.ThrowIfNullOrEmpty(projectId); + ArgumentNullException.ThrowIfNull(identities); + ArgumentNullException.ThrowIfNull(action); + + string[] uniqueIdentities = identities + .Where(identity => !String.IsNullOrEmpty(identity)) + .Distinct(StringComparer.Ordinal) + .Order(StringComparer.Ordinal) + .ToArray(); + if (uniqueIdentities.Length == 0) + { + return 0; + } + + int stageFlag = GetStageFlag(stage); + if (stageFlag == 0) + { + return await ExecuteStandaloneStageAsync(stage, uniqueIdentities, action, cancellationToken); + } + + var pendingStates = await GetPendingStatesAsync(stageFlag, projectId, uniqueIdentities); + if (pendingStates.Count == 0) + { + return 0; + } + + string[] lockKeys = pendingStates.Keys.Select(identity => GetStateLockKey(projectId, identity)).ToArray(); + await using (ILock locks = await lockProvider.AcquireAsync( + lockKeys, + cancellationToken: cancellationToken)) + { + // A competing worker may have completed while this worker waited for its locks. + pendingStates = await GetPendingStatesAsync(stageFlag, projectId, pendingStates.Keys); + if (pendingStates.Count == 0) + { + return 0; + } + + await action(pendingStates.Keys.ToArray()); + + // Incrementing by the single missing bit is an atomic OR because this stage is + // serialized by the per-event lock. Redis statistics settlement also atomically ORs + // its bit, so either operation order preserves both flags without a stale read/write. + long[] completedStates = await Task.WhenAll(pendingStates.Keys.Select(identity => + cache.IncrementAsync( + GetStateKey(projectId, identity), + stageFlag, + options.EventIngestionV3.IdempotencyWindow))); + if (completedStates.Any(state => (((long)state) & stageFlag) == 0)) + { + throw new InvalidOperationException($"Unable to record completion for ingestion side-effect stage '{stage}'."); + } + + return pendingStates.Count; + } + } + + public async Task> GetCompletedIdentitiesAsync( + string stage, + string projectId, + IReadOnlyCollection identities) + { + ArgumentException.ThrowIfNullOrEmpty(stage); + ArgumentException.ThrowIfNullOrEmpty(projectId); + ArgumentNullException.ThrowIfNull(identities); + + string[] uniqueIdentities = identities + .Where(identity => !String.IsNullOrEmpty(identity)) + .Distinct(StringComparer.Ordinal) + .ToArray(); + if (uniqueIdentities.Length == 0) + { + return new HashSet(StringComparer.Ordinal); + } + + int stageFlag = GetStageFlag(stage); + if (stageFlag == 0) + { + if (!String.Equals(stage, ProjectConfiguredStage, StringComparison.Ordinal)) + { + throw new ArgumentOutOfRangeException(nameof(stage), stage, "Unknown ingestion side-effect stage."); + } + + return await GetStandaloneCompletedIdentitiesAsync(stage, uniqueIdentities); + } + + var keysByIdentity = uniqueIdentities.ToDictionary( + identity => identity, + identity => GetStateKey(projectId, identity), + StringComparer.Ordinal); + var states = await cache.GetAllAsync(keysByIdentity.Values); + return uniqueIdentities + .Where(identity => states.TryGetValue(keysByIdentity[identity], out var state) + && state is { HasValue: true } + && (state.Value & stageFlag) == stageFlag) + .ToHashSet(StringComparer.Ordinal); + } + + private async Task ExecuteStandaloneStageAsync( + string stage, + IReadOnlyCollection identities, + Func, Task> action, + CancellationToken cancellationToken) + { + if (!String.Equals(stage, ProjectConfiguredStage, StringComparison.Ordinal)) + { + throw new ArgumentOutOfRangeException(nameof(stage), stage, "Unknown ingestion side-effect stage."); + } + + var pendingIdentities = await GetStandalonePendingIdentitiesAsync(stage, identities); + if (pendingIdentities.Count == 0) + { + return 0; + } + + string[] lockKeys = pendingIdentities.Select(identity => GetStandaloneLockKey(stage, identity)).ToArray(); + await using (ILock locks = await lockProvider.AcquireAsync( + lockKeys, + cancellationToken: cancellationToken)) + { + // A competing worker may have completed while this worker waited for its locks. + pendingIdentities = await GetStandalonePendingIdentitiesAsync(stage, pendingIdentities); + if (pendingIdentities.Count == 0) + { + return 0; + } + + await action(pendingIdentities); + + var completed = pendingIdentities.ToDictionary( + identity => GetStandaloneCompletionKey(stage, identity), + _ => true, + StringComparer.Ordinal); + int completedCount = await cache.SetAllAsync(completed, options.EventIngestionV3.IdempotencyWindow); + if (completedCount != completed.Count) + { + throw new InvalidOperationException($"Unable to record completion for ingestion side-effect stage '{stage}'."); + } + + return pendingIdentities.Count; + } + } + + private async Task> GetStandaloneCompletedIdentitiesAsync( + string stage, + IReadOnlyCollection identities) + { + var keysByIdentity = identities.ToDictionary( + identity => identity, + identity => GetStandaloneCompletionKey(stage, identity), + StringComparer.Ordinal); + var states = await cache.GetAllAsync(keysByIdentity.Values); + return identities + .Where(identity => states.TryGetValue(keysByIdentity[identity], out var state) && state is { HasValue: true, Value: true }) + .ToHashSet(StringComparer.Ordinal); + } + + private async Task> GetPendingStatesAsync(int stageFlag, string projectId, IEnumerable identities) + { + var keysByIdentity = identities.ToDictionary(identity => identity, identity => GetStateKey(projectId, identity), StringComparer.Ordinal); + var states = await cache.GetAllAsync(keysByIdentity.Values); + var pending = new Dictionary(keysByIdentity.Count, StringComparer.Ordinal); + foreach (var pair in keysByIdentity) + { + var state = states[pair.Value]; + int stateValue = state.HasValue ? state.Value : 0; + if ((stateValue & stageFlag) == 0) + { + pending[pair.Key] = stateValue; + } + } + + return pending; + } + + private async Task> GetStandalonePendingIdentitiesAsync(string stage, IReadOnlyCollection identities) + { + string[] completionKeys = identities.Select(identity => GetStandaloneCompletionKey(stage, identity)).ToArray(); + var states = await cache.GetAllAsync(completionKeys); + var pending = new List(identities.Count); + int index = 0; + foreach (string identity in identities) + { + if (!states[completionKeys[index]].HasValue) + { + pending.Add(identity); + } + + index++; + } + + return pending; + } + + private static int GetStageFlag(string stage) => stage switch + { + StatisticsStage => StatisticsStageFlag, + TerminalStage => NotificationsStageFlag, + _ => 0 + }; + + private static string GetStateKey(string projectId, string identity) => + IngestionStackUsageStore.GetStateKey(projectId, identity); + + private static string GetStateLockKey(string projectId, string identity) => + IngestionStackUsageStore.GetStateLockKey(projectId, identity); + + private static string GetStandaloneCompletionKey(string stage, string identity) => + String.Concat("ingest-v3:sideeffects:", stage, ":", identity); + + private static string GetStandaloneLockKey(string stage, string identity) => + String.Concat("ingest-v3:sideeffects:lock:", stage, ":", identity); +} diff --git a/src/Exceptionless.Core/Services/IngestionStackUsageStore.cs b/src/Exceptionless.Core/Services/IngestionStackUsageStore.cs new file mode 100644 index 0000000000..17dc0b7bea --- /dev/null +++ b/src/Exceptionless.Core/Services/IngestionStackUsageStore.cs @@ -0,0 +1,401 @@ +using Foundatio.Caching; +using Foundatio.Lock; + +namespace Exceptionless.Core.Services; + +public sealed record IngestionStackUsage( + string EventId, + string OrganizationId, + string ProjectId, + string StackId, + DateTime OccurrenceDateUtc); + +public sealed record StackUsageSummary( + string OrganizationId, + string ProjectId, + string StackId, + DateTime MinimumOccurrenceDateUtc, + DateTime MaximumOccurrenceDateUtc, + int Count); + +public sealed record StackUsageClaim( + string OrganizationId, + string ProjectId, + string StackId, + DateTime MinimumOccurrenceDateUtc, + DateTime MaximumOccurrenceDateUtc, + int Count, + long SettlementSequence, + long LeaseToken); + +public interface IIngestionStackUsageStore +{ + Task> SettleAsync( + IReadOnlyCollection usages, + CancellationToken cancellationToken = default); + + Task> ClaimPendingAsync( + int maximumCount, + CancellationToken cancellationToken = default); + + Task AcknowledgeAsync( + IReadOnlyCollection claims, + CancellationToken cancellationToken = default); +} + +/// +/// Process-local statistics settlement used with the in-memory cache provider. The private +/// completion ledger is the authority if publishing the shared processing-state bit fails, so +/// a retry repairs the bit without applying the stack usage a second time. +/// +public sealed class InMemoryIngestionStackUsageStore( + ICacheClient cache, + ILockProvider lockProvider, + AppOptions options, + TimeProvider timeProvider) : IIngestionStackUsageStore +{ + private readonly SemaphoreSlim _gate = new(1, 1); + private readonly Dictionary _completedEvents = new(StringComparer.Ordinal); + private readonly Dictionary _pendingUsages = []; + private readonly Dictionary _inFlightUsages = []; + private long _nextSettlementSequence; + private int _takeCursor = -1; + + public async Task> SettleAsync( + IReadOnlyCollection usages, + CancellationToken cancellationToken = default) + { + var normalized = IngestionStackUsageStore.Normalize(usages); + if (normalized.Count == 0) + { + return []; + } + + string[] lockKeys = normalized + .Select(usage => IngestionStackUsageStore.GetStateLockKey(usage.ProjectId, usage.EventId)) + .ToArray(); + await using ILock stateLocks = await lockProvider.AcquireAsync(lockKeys, cancellationToken: cancellationToken); + await _gate.WaitAsync(cancellationToken); + try + { + DateTimeOffset now = timeProvider.GetUtcNow(); + RemoveExpiredCompletions(now); + + string[] stateKeys = normalized + .Select(usage => IngestionStackUsageStore.GetStateKey(usage.ProjectId, usage.EventId)) + .ToArray(); + var states = await cache.GetAllAsync(stateKeys); + var newlySettled = new List(normalized.Count); + var stateUpdates = new Dictionary(normalized.Count, StringComparer.Ordinal); + DateTimeOffset expiresAt = now.Add(options.EventIngestionV3.IdempotencyWindow); + + foreach (var usage in normalized) + { + string stateKey = IngestionStackUsageStore.GetStateKey(usage.ProjectId, usage.EventId); + var cachedState = states[stateKey]; + int state = cachedState.HasValue ? cachedState.Value : 0; + bool isCompleted = _completedEvents.ContainsKey(stateKey) + || (state & IngestionStackUsageStore.StatisticsStageFlag) != 0; + + if (!isCompleted) + { + _completedEvents[stateKey] = expiresAt; + AddPendingUsage(usage); + newlySettled.Add(usage); + } + else if (!_completedEvents.ContainsKey(stateKey)) + { + _completedEvents[stateKey] = expiresAt; + } + + if ((state & IngestionStackUsageStore.StatisticsStageFlag) == 0) + { + stateUpdates[stateKey] = state | IngestionStackUsageStore.StatisticsStageFlag; + } + } + + if (stateUpdates.Count > 0) + { + int completedCount = await cache.SetAllAsync(stateUpdates, options.EventIngestionV3.IdempotencyWindow); + if (completedCount != stateUpdates.Count) + { + throw new InvalidOperationException("Unable to record ingestion stack-statistics completion."); + } + } + + return IngestionStackUsageStore.Summarize(newlySettled); + } + finally + { + _gate.Release(); + } + } + + public async Task> ClaimPendingAsync( + int maximumCount, + CancellationToken cancellationToken = default) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maximumCount); + await _gate.WaitAsync(cancellationToken); + try + { + DateTimeOffset now = timeProvider.GetUtcNow(); + TimeSpan claimLease = options.EventIngestionV3.StackUsageClaimLease > TimeSpan.Zero + ? options.EventIngestionV3.StackUsageClaimLease + : TimeSpan.FromMinutes(1); + DateTimeOffset leaseExpiresAt = now.Add(claimLease); + var result = new List(Math.Min(maximumCount, _pendingUsages.Count + _inFlightUsages.Count)); + + foreach (var pair in _inFlightUsages + .Where(pair => pair.Value.LeaseExpiresAt <= now) + .OrderBy(pair => pair.Value.LeaseExpiresAt) + .ThenBy(pair => pair.Key.ProjectId, StringComparer.Ordinal) + .ThenBy(pair => pair.Key.StackId, StringComparer.Ordinal) + .Take(maximumCount)) + { + pair.Value.LeaseExpiresAt = leaseExpiresAt; + result.Add(pair.Value.ToClaim(pair.Key)); + } + + if (result.Count >= maximumCount) + { + return result; + } + + StackUsageKey[][] partitions = _pendingUsages.Keys + .Where(key => !_inFlightUsages.ContainsKey(key)) + .GroupBy(key => (key.OrganizationId, key.ProjectId)) + .OrderBy(group => group.Key.ProjectId, StringComparer.Ordinal) + .Select(group => group.OrderBy(key => key.StackId, StringComparer.Ordinal).ToArray()) + .ToArray(); + if (partitions.Length == 0) + { + return result; + } + + int startIndex = (int)((uint)Interlocked.Increment(ref _takeCursor) % (uint)partitions.Length); + partitions = Enumerable.Range(0, partitions.Length) + .Select(offset => partitions[(startIndex + offset) % partitions.Length]) + .ToArray(); + + int remainingClaimCapacity = maximumCount - result.Count; + var keys = new List(Math.Min(remainingClaimCapacity, _pendingUsages.Count)); + for (int partitionIndex = 0; partitionIndex < partitions.Length && keys.Count < remainingClaimCapacity; partitionIndex++) + { + int remainingCapacity = remainingClaimCapacity - keys.Count; + int remainingPartitions = partitions.Length - partitionIndex; + int partitionQuota = Math.Max(1, remainingCapacity / remainingPartitions); + keys.AddRange(partitions[partitionIndex].Take(partitionQuota)); + } + + foreach (var key in keys) + { + var usage = _pendingUsages[key]; + _pendingUsages.Remove(key); + long settlementSequence = GetNextSettlementSequence(now); + var inFlight = new InFlightStackUsage( + usage.MinimumOccurrenceDateUtc, + usage.MaximumOccurrenceDateUtc, + usage.Count, + settlementSequence, + leaseExpiresAt); + _inFlightUsages[key] = inFlight; + result.Add(inFlight.ToClaim(key)); + } + + return result; + } + finally + { + _gate.Release(); + } + } + + public async Task AcknowledgeAsync( + IReadOnlyCollection claims, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(claims); + if (claims.Count == 0) + { + return; + } + + await _gate.WaitAsync(cancellationToken); + try + { + foreach (var claim in claims) + { + var key = new StackUsageKey(claim.OrganizationId, claim.ProjectId, claim.StackId); + if (_inFlightUsages.TryGetValue(key, out var current) + && current.SettlementSequence == claim.SettlementSequence) + { + _inFlightUsages.Remove(key); + } + } + } + finally + { + _gate.Release(); + } + } + + private long GetNextSettlementSequence(DateTimeOffset now) + { + long clockSequence = checked(now.ToUnixTimeMilliseconds() * 1000L); + _nextSettlementSequence = Math.Max(checked(_nextSettlementSequence + 1), clockSequence); + return _nextSettlementSequence; + } + + private void AddPendingUsage(IngestionStackUsage usage) + { + AddPendingUsage(new StackUsageSummary( + usage.OrganizationId, + usage.ProjectId, + usage.StackId, + usage.OccurrenceDateUtc, + usage.OccurrenceDateUtc, + 1)); + } + + private void AddPendingUsage(StackUsageSummary usage) + { + var key = new StackUsageKey(usage.OrganizationId, usage.ProjectId, usage.StackId); + if (_pendingUsages.TryGetValue(key, out var current)) + { + current.Count += usage.Count; + if (usage.MinimumOccurrenceDateUtc < current.MinimumOccurrenceDateUtc) + { + current.MinimumOccurrenceDateUtc = usage.MinimumOccurrenceDateUtc; + } + + if (usage.MaximumOccurrenceDateUtc > current.MaximumOccurrenceDateUtc) + { + current.MaximumOccurrenceDateUtc = usage.MaximumOccurrenceDateUtc; + } + } + else + { + _pendingUsages[key] = new MutableStackUsage( + usage.MinimumOccurrenceDateUtc, + usage.MaximumOccurrenceDateUtc, + usage.Count); + } + } + + private void RemoveExpiredCompletions(DateTimeOffset now) + { + foreach (string eventId in _completedEvents + .Where(pair => pair.Value <= now) + .Select(pair => pair.Key) + .ToArray()) + { + _completedEvents.Remove(eventId); + } + } + + private sealed class MutableStackUsage(DateTime minimumOccurrenceDateUtc, DateTime maximumOccurrenceDateUtc, int count) + { + public DateTime MinimumOccurrenceDateUtc { get; set; } = minimumOccurrenceDateUtc; + public DateTime MaximumOccurrenceDateUtc { get; set; } = maximumOccurrenceDateUtc; + public int Count { get; set; } = count; + + public StackUsageSummary ToSummary(StackUsageKey key) => new( + key.OrganizationId, + key.ProjectId, + key.StackId, + MinimumOccurrenceDateUtc, + MaximumOccurrenceDateUtc, + Count); + } + + private sealed class InFlightStackUsage( + DateTime minimumOccurrenceDateUtc, + DateTime maximumOccurrenceDateUtc, + int count, + long settlementSequence, + DateTimeOffset leaseExpiresAt) + { + public DateTime MinimumOccurrenceDateUtc { get; } = minimumOccurrenceDateUtc; + public DateTime MaximumOccurrenceDateUtc { get; } = maximumOccurrenceDateUtc; + public int Count { get; } = count; + public long SettlementSequence { get; } = settlementSequence; + public DateTimeOffset LeaseExpiresAt { get; set; } = leaseExpiresAt; + + public StackUsageClaim ToClaim(StackUsageKey key) => new( + key.OrganizationId, + key.ProjectId, + key.StackId, + MinimumOccurrenceDateUtc, + MaximumOccurrenceDateUtc, + Count, + SettlementSequence, + SettlementSequence); + } +} + +public static class IngestionStackUsageStore +{ + public const int StatisticsStageFlag = 1 << 0; + + public static string GetStateKey(string projectId, string eventId) => + String.Concat("ingest-v3:{", projectId, "}:sideeffects:state:", eventId); + + public static string GetStateLockKey(string projectId, string eventId) => + String.Concat("ingest-v3:{", projectId, "}:sideeffects:lock:state:", eventId); + + internal static IReadOnlyList Normalize(IReadOnlyCollection usages) + { + ArgumentNullException.ThrowIfNull(usages); + if (usages.Count == 0) + { + return []; + } + + var unique = new Dictionary<(string ProjectId, string EventId), IngestionStackUsage>(usages.Count); + foreach (var usage in usages) + { + ArgumentException.ThrowIfNullOrWhiteSpace(usage.EventId); + ArgumentException.ThrowIfNullOrWhiteSpace(usage.OrganizationId); + ArgumentException.ThrowIfNullOrWhiteSpace(usage.ProjectId); + ArgumentException.ThrowIfNullOrWhiteSpace(usage.StackId); + + var normalized = usage with + { + OccurrenceDateUtc = usage.OccurrenceDateUtc.Kind switch + { + DateTimeKind.Utc => usage.OccurrenceDateUtc, + DateTimeKind.Local => usage.OccurrenceDateUtc.ToUniversalTime(), + _ => DateTime.SpecifyKind(usage.OccurrenceDateUtc, DateTimeKind.Utc) + } + }; + var identity = (usage.ProjectId, usage.EventId); + if (unique.TryGetValue(identity, out var existing) && existing != normalized) + { + throw new InvalidOperationException($"Event '{usage.EventId}' has conflicting stack-statistics data."); + } + + unique[identity] = normalized; + } + + return unique.Values + .OrderBy(usage => usage.ProjectId, StringComparer.Ordinal) + .ThenBy(usage => usage.EventId, StringComparer.Ordinal) + .ToArray(); + } + + internal static IReadOnlyCollection Summarize(IEnumerable usages) + { + return usages + .GroupBy(usage => new StackUsageKey(usage.OrganizationId, usage.ProjectId, usage.StackId)) + .Select(group => new StackUsageSummary( + group.Key.OrganizationId, + group.Key.ProjectId, + group.Key.StackId, + group.Min(usage => usage.OccurrenceDateUtc), + group.Max(usage => usage.OccurrenceDateUtc), + group.Count())) + .OrderBy(usage => usage.StackId, StringComparer.Ordinal) + .ToArray(); + } +} diff --git a/src/Exceptionless.Core/Services/StackFingerprintService.cs b/src/Exceptionless.Core/Services/StackFingerprintService.cs new file mode 100644 index 0000000000..4b03381d81 --- /dev/null +++ b/src/Exceptionless.Core/Services/StackFingerprintService.cs @@ -0,0 +1,187 @@ +using System.Buffers; +using System.Buffers.Binary; +using System.Security.Cryptography; +using System.Text; +using Exceptionless.Core.Extensions; +using Exceptionless.Core.Models; +using Exceptionless.Core.Models.Data; +using Exceptionless.Core.Models.Ingestion; + +namespace Exceptionless.Core.Services; + +public interface IStackFingerprintService +{ + StackFingerprint Create(EventIngestionV3Event source, Organization organization, Project project); +} + +public sealed class StackFingerprintService(StackTraceParser stackTraceParser) : IStackFingerprintService +{ + private static readonly string[] _defaultNonUserNamespaces = ["System", "Microsoft"]; + private static readonly string[] _defaultCommonMethods = ["DataContext.SubmitChanges", "Entities.SaveChanges"]; + + public StackFingerprint Create(EventIngestionV3Event source, Organization organization, Project project) + { + if (source.Stacking?.SignatureData is { Count: > 0 } manualSignature) + { + var data = manualSignature + .OrderBy(pair => pair.Key, StringComparer.Ordinal) + .ToDictionary(pair => pair.Key, pair => pair.Value, StringComparer.Ordinal); + return new StackFingerprint(GetManualSignatureHash(data), data, source.Stacking.Title); + } + + if (!String.Equals(source.Type, Event.KnownTypes.Error, StringComparison.OrdinalIgnoreCase)) + { + var data = new Dictionary(2); + AddIfNotEmpty(data, "Type", source.Type); + AddIfNotEmpty(data, "Source", source.Source); + return new StackFingerprint(data.Values.ToSHA1(), data); + } + + var signature = new Dictionary(2); + string? exceptionType = source.ExceptionType; + string? method = null; + string? fallbackTraceHash = null; + + if (!String.IsNullOrWhiteSpace(source.StackTrace)) + { + string[] userNamespaces = GetSetting(organization, project, "UserNamespaces")?.SplitAndTrim([',']) ?? []; + string[] commonMethods = GetSetting(organization, project, "CommonMethods")?.SplitAndTrim([',']) ?? _defaultCommonMethods; + + stackTraceParser.TryFindFrame( + source.StackTrace, + frame => IsUserFrame(frame, userNamespaces, commonMethods), + out var firstFrame, + out var userFrame, + out string? selectedExceptionType); + + exceptionType = selectedExceptionType ?? exceptionType; + + var target = userFrame ?? firstFrame; + if (target is not null) + { + method = target.GetSignature(); + } + else + { + fallbackTraceHash = NormalizeFallback(source.StackTrace).ToSHA1(); + AppDiagnostics.IngestionV3ParserFallbacks.Add(1); + } + } + + AddIfNotEmpty(signature, "ExceptionType", exceptionType); + AddIfNotEmpty(signature, "Method", method); + AddIfNotEmpty(signature, "StackTrace", fallbackTraceHash); + + if (signature.Count == 0) + { + AddIfNotEmpty(signature, "Type", source.Type); + } + + return new StackFingerprint(signature.Values.ToSHA1(), signature); + } + + private static string? GetSetting(Organization organization, Project project, string key) + { + return project.Data?.GetString(key) ?? organization.Data?.GetString(key); + } + + private static string NormalizeFallback(string stackTrace) + { + var normalized = new StringBuilder(stackTrace.Length); + bool inDigits = false; + bool inWhitespace = false; + foreach (char value in stackTrace) + { + if (Char.IsDigit(value)) + { + if (!inDigits) + { + normalized.Append('#'); + } + + inDigits = true; + inWhitespace = false; + continue; + } + + inDigits = false; + if (Char.IsWhiteSpace(value)) + { + if (!inWhitespace && normalized.Length > 0) + { + normalized.Append(' '); + } + + inWhitespace = true; + continue; + } + + inWhitespace = false; + normalized.Append(value); + } + + return normalized.ToString().Trim(); + } + + private static string GetManualSignatureHash(IReadOnlyDictionary signature) + { + using var hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA1); + AppendHashValue(hash, "exceptionless-v3-manual-stack-v1"); + foreach (var pair in signature) + { + AppendHashValue(hash, pair.Key); + AppendHashValue(hash, pair.Value); + } + + return Convert.ToHexString(hash.GetHashAndReset()).ToLowerInvariant(); + } + + private static void AppendHashValue(IncrementalHash hash, string value) + { + int byteCount = Encoding.UTF8.GetByteCount(value); + Span length = stackalloc byte[sizeof(int)]; + BinaryPrimitives.WriteInt32LittleEndian(length, byteCount); + hash.AppendData(length); + + byte[]? rented = null; + Span bytes = byteCount <= 256 + ? stackalloc byte[byteCount] + : (rented = ArrayPool.Shared.Rent(byteCount)); + try + { + int written = Encoding.UTF8.GetBytes(value, bytes); + hash.AppendData(bytes[..written]); + } + finally + { + if (rented is not null) + { + ArrayPool.Shared.Return(rented); + } + } + } + + private static bool IsUserFrame(StackFrame frame, string[] userNamespaces, string[] commonMethods) + { + if (String.IsNullOrEmpty(frame.Name)) + { + return false; + } + + string? frameNamespace = frame.DeclaringNamespace; + bool isUserNamespace = String.IsNullOrEmpty(frameNamespace) + || (userNamespaces.Length == 0 + ? !_defaultNonUserNamespaces.Any(frameNamespace.StartsWith) + : userNamespaces.Any(frameNamespace.StartsWith)); + + return isUserNamespace && !commonMethods.Any(frame.GetSignature().Contains); + } + + private static void AddIfNotEmpty(IDictionary values, string key, string? value) + { + if (!String.IsNullOrWhiteSpace(value)) + { + values[key] = value; + } + } +} diff --git a/src/Exceptionless.Core/Services/StackRouteResolver.cs b/src/Exceptionless.Core/Services/StackRouteResolver.cs new file mode 100644 index 0000000000..56dde63e40 --- /dev/null +++ b/src/Exceptionless.Core/Services/StackRouteResolver.cs @@ -0,0 +1,324 @@ +using Exceptionless.Core.Models; +using Exceptionless.Core.Models.Ingestion; +using Exceptionless.Core.Repositories; +using Foundatio.Caching; +using Foundatio.Lock; +using Foundatio.Repositories; + +namespace Exceptionless.Core.Services; + +public interface IStackRouteResolver +{ + Task> ResolveAsync(string projectId, IReadOnlyCollection signatureHashes, CancellationToken cancellationToken = default); + Task UpdateAsync(string projectId, string signatureHash, StackRoute route); + Task RemoveAsync(string projectId, string signatureHash); + Task TryMarkRegressedAsync(StackRoute route, string eventId, CancellationToken cancellationToken = default); +} + +public interface IStackRouteCache +{ + Task>> GetAllAsync(IEnumerable keys); + Task GetProjectGenerationAsync(string projectId); + Task AdvanceProjectGenerationAsync(string projectId); + Task SetAsync(string key, StackRouteCacheEntry entry, TimeSpan expiresIn); + Task SetAuthoritativeAsync(string key, StackRouteCacheEntry entry, TimeSpan expiresIn); + Task SetAllAsync(IReadOnlyDictionary entries, TimeSpan expiresIn); + Task SetAllAuthoritativeAsync(IReadOnlyDictionary entries, TimeSpan expiresIn); + Task RemoveAsync(string key, TimeSpan expiresIn); + Task RemoveAllAsync(IReadOnlyCollection keys, TimeSpan expiresIn); +} + +public sealed class StackRouteCache(ICacheClient cache, ILockProvider lockProvider, TimeProvider timeProvider) : IStackRouteCache +{ + public Task>> GetAllAsync(IEnumerable keys) => cache.GetAllAsync(keys); + + public async Task GetProjectGenerationAsync(string projectId) + { + var generation = await cache.GetAsync(GetProjectGenerationKey(projectId)); + return generation.HasValue ? generation.Value : 0; + } + + public Task AdvanceProjectGenerationAsync(string projectId) => + cache.IncrementAsync(GetProjectGenerationKey(projectId), 1); + + public Task SetAsync(string key, StackRouteCacheEntry entry, TimeSpan expiresIn) => + SetAllAsync(new Dictionary(1, StringComparer.Ordinal) { [key] = entry }, expiresIn); + + public Task SetAuthoritativeAsync(string key, StackRouteCacheEntry entry, TimeSpan expiresIn) => + SetAllAuthoritativeAsync(new Dictionary(1, StringComparer.Ordinal) { [key] = entry }, expiresIn); + + public Task SetAllAsync(IReadOnlyDictionary entries, TimeSpan expiresIn) => + UpdateGroupedAsync(entries, expiresIn, authoritative: false); + + public Task SetAllAuthoritativeAsync(IReadOnlyDictionary entries, TimeSpan expiresIn) => + UpdateGroupedAsync(entries, expiresIn, authoritative: true); + + public Task RemoveAsync(string key, TimeSpan expiresIn) => RemoveAllAsync([key], expiresIn); + + public Task RemoveAllAsync(IReadOnlyCollection keys, TimeSpan expiresIn) + { + if (keys.Count == 0) + { + return Task.CompletedTask; + } + + var tombstones = keys + .Distinct(StringComparer.Ordinal) + .ToDictionary( + key => key, + _ => new StackRouteCacheEntry(false, timeProvider.GetUtcNow().UtcDateTime.Ticks), + StringComparer.Ordinal); + return UpdateGroupedAsync(tombstones, expiresIn, authoritative: true); + } + + private Task UpdateGroupedAsync( + IReadOnlyDictionary entries, + TimeSpan expiresIn, + bool authoritative) + { + if (entries.Count == 0) + { + return Task.CompletedTask; + } + + return Task.WhenAll(entries + .GroupBy(pair => GetLockKey(pair.Key), StringComparer.Ordinal) + .Select(group => UpdateGroupAsync(group.Key, group, expiresIn, authoritative))); + } + + private async Task UpdateGroupAsync( + string lockKey, + IEnumerable> entries, + TimeSpan expiresIn, + bool authoritative) + { + var requested = entries.ToDictionary(pair => pair.Key, pair => pair.Value, StringComparer.Ordinal); + bool acquired = await lockProvider.TryUsingAsync(lockKey, async () => + { + var current = await cache.GetAllAsync(requested.Keys); + var updates = new Dictionary(requested.Count, StringComparer.Ordinal); + foreach (var pair in requested) + { + bool hasCurrent = current.TryGetValue(pair.Key, out var cached) && cached.HasValue; + long currentVersion = hasCurrent ? cached!.Value.Version : 0; + if (!authoritative) + { + if (!hasCurrent || pair.Value.Version > currentVersion) + { + updates[pair.Key] = pair.Value; + } + + continue; + } + + long version = Math.Max(pair.Value.Version, currentVersion + 1); + updates[pair.Key] = pair.Value with { Version = version }; + } + + if (updates.Count == 0) + { + return; + } + + int updated = await cache.SetAllAsync(updates, expiresIn); + if (updated != updates.Count) + { + throw new InvalidOperationException("Unable to update every stack route cache entry."); + } + }, TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(10)); + + if (!acquired) + { + throw new InvalidOperationException($"Unable to update stack route cache scope '{lockKey}'."); + } + } + + private static string GetLockKey(string key) + { + int signatureSeparator = key.LastIndexOf(':'); + if (signatureSeparator <= 0) + { + throw new ArgumentException("The stack route cache key is invalid.", nameof(key)); + } + + // Route keys end in the signature hash. One generation-scoped project lock allows a cold + // microbatch to compare and fill every route with two bulk cache calls instead of taking + // a distributed lock and issuing a read/write command for each distinct signature. + return String.Concat("lock:", key.AsSpan(0, signatureSeparator)); + } + private static string GetProjectGenerationKey(string projectId) => String.Concat("stack-route:v3:generation:", projectId); +} + +public sealed class StackRouteResolver( + IStackRepository stackRepository, + IStackRouteCache routeCache, + ILockProvider lockProvider, + AppOptions options) : IStackRouteResolver +{ + public async Task> ResolveAsync(string projectId, IReadOnlyCollection signatureHashes, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + using var activity = AppDiagnostics.StartActivity("Ingestion V3 Stack Route Resolve"); + using var timer = AppDiagnostics.IngestionV3RouteResolutionTime.StartTimer(); + string[] distinctHashes = signatureHashes.Distinct(StringComparer.Ordinal).ToArray(); + AppDiagnostics.IngestionV3RouteLocalDeduplicated.Add(signatureHashes.Count - distinctHashes.Length); + if (distinctHashes.Length == 0) + { + return new Dictionary(); + } + + long projectGeneration = await routeCache.GetProjectGenerationAsync(projectId); + var cacheKeys = distinctHashes.Select(hash => GetCacheKey(projectId, hash, projectGeneration)).ToArray(); + var cached = await routeCache.GetAllAsync(cacheKeys); + cancellationToken.ThrowIfCancellationRequested(); + var routes = new Dictionary(distinctHashes.Length, StringComparer.Ordinal); + var misses = new List(); + + for (int i = 0; i < distinctHashes.Length; i++) + { + string hash = distinctHashes[i]; + string key = cacheKeys[i]; + if (!cached.TryGetValue(key, out var cacheValue) || !cacheValue.HasValue) + { + AppDiagnostics.IngestionV3RouteCacheMisses.Add(1); + misses.Add(hash); + continue; + } + + StackRoute? route = cacheValue.Value.ToRoute(); + if (route is not null) + { + AppDiagnostics.IngestionV3RouteCacheHits.Add(1); + routes[hash] = route; + } + else + { + AppDiagnostics.IngestionV3RouteNegativeHits.Add(1); + } + } + + if (misses.Count == 0) + { + if (await routeCache.GetProjectGenerationAsync(projectId) != projectGeneration) + { + return await ResolveAsync(projectId, distinctHashes, cancellationToken); + } + + return routes; + } + + AppDiagnostics.IngestionV3RouteRepositoryLookups.Add(misses.Count); + var loaded = await stackRepository.GetStackRoutesBySignatureHashAsync(projectId, misses); + cancellationToken.ThrowIfCancellationRequested(); + var positiveEntries = new Dictionary(misses.Count); + var negativeEntries = new Dictionary(misses.Count); + foreach (string hash in misses) + { + if (loaded.TryGetValue(hash, out var route)) + { + routes[hash] = route; + positiveEntries[GetCacheKey(projectId, hash, projectGeneration)] = StackRouteCacheEntry.FromRoute(route); + } + else + { + negativeEntries[GetCacheKey(projectId, hash, projectGeneration)] = new StackRouteCacheEntry(false, 0); + } + } + + if (positiveEntries.Count > 0) + { + await routeCache.SetAllAsync(positiveEntries, options.EventIngestionV3.StackRouteCacheDuration); + } + + if (negativeEntries.Count > 0) + { + await routeCache.SetAllAsync(negativeEntries, options.EventIngestionV3.NegativeStackRouteCacheDuration); + } + + // A status update may have won while the repository lookup was in flight. Re-read + // the versioned entries so this request observes the same winner as later requests. + var effectiveEntries = await routeCache.GetAllAsync(misses.Select(hash => GetCacheKey(projectId, hash, projectGeneration))); + foreach (string hash in misses) + { + if (!effectiveEntries.TryGetValue(GetCacheKey(projectId, hash, projectGeneration), out var effective) || !effective.HasValue) + { + continue; + } + + StackRoute? effectiveRoute = effective.Value.ToRoute(); + if (effectiveRoute is null) + { + routes.Remove(hash); + } + else + { + routes[hash] = effectiveRoute; + } + } + + // A project-wide delete can rotate the generation while the repository lookup is + // in flight. The old fill is isolated, and this request must also re-resolve against + // the new generation before it is allowed to route an event. + if (await routeCache.GetProjectGenerationAsync(projectId) != projectGeneration) + { + return await ResolveAsync(projectId, distinctHashes, cancellationToken); + } + + return routes; + } + + public async Task UpdateAsync(string projectId, string signatureHash, StackRoute route) + { + long projectGeneration = await routeCache.GetProjectGenerationAsync(projectId); + await routeCache.SetAuthoritativeAsync(GetCacheKey(projectId, signatureHash, projectGeneration), StackRouteCacheEntry.FromRoute(route), options.EventIngestionV3.StackRouteCacheDuration); + } + + public async Task RemoveAsync(string projectId, string signatureHash) + { + long projectGeneration = await routeCache.GetProjectGenerationAsync(projectId); + await routeCache.RemoveAsync(GetCacheKey(projectId, signatureHash, projectGeneration), options.EventIngestionV3.StackRouteCacheDuration); + } + + public async Task TryMarkRegressedAsync(StackRoute route, string eventId, CancellationToken cancellationToken = default) + { + bool transitioned = false; + bool acquired = await lockProvider.TryUsingAsync(String.Concat("stack-regression:", route.StackId), async () => + { + var stack = await stackRepository.GetByIdAsync(route.StackId); + if (stack is null + || stack.Status != StackStatus.Fixed + || stack.DateFixed != route.DateFixed + || !String.Equals(stack.FixedInVersion, route.FixedInVersion, StringComparison.Ordinal)) + { + return; + } + + stack.Status = StackStatus.Regressed; + stack.RegressionEventId = eventId; + await stackRepository.SaveAsync(stack, o => o.ImmediateConsistency().Cache()); + await UpdateAsync(stack.ProjectId, stack.SignatureHash, CreateRoute(stack)); + transitioned = true; + }, TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(10)); + + cancellationToken.ThrowIfCancellationRequested(); + if (!acquired) + { + throw new InvalidOperationException($"Unable to update regression state for stack '{route.StackId}'."); + } + + return transitioned; + } + + public static StackRoute CreateRoute(Stack stack) => new( + stack.Id, + stack.Status, + stack.UpdatedUtc.Ticks, + stack.FixedInVersion, + stack.DateFixed, + stack.OccurrencesAreCritical, + stack.RegressionEventId, + stack.IngestionFirstEventId); + + internal static string GetCacheKey(string projectId, string signatureHash, long projectGeneration = 0) => + String.Concat("stack-route:v3:v1:", projectId, ":", projectGeneration, ":", signatureHash); +} diff --git a/src/Exceptionless.Core/Services/StackService.cs b/src/Exceptionless.Core/Services/StackService.cs index 84671040ea..e2de04bbcc 100644 --- a/src/Exceptionless.Core/Services/StackService.cs +++ b/src/Exceptionless.Core/Services/StackService.cs @@ -1,3 +1,4 @@ +using System.Collections.Concurrent; using Exceptionless.Core.Repositories; using Foundatio.Caching; using Microsoft.Extensions.Logging; @@ -15,13 +16,25 @@ public class StackService private readonly ILogger _logger; private readonly IStackRepository _stackRepository; private readonly ICacheClient _cache; + private readonly IIngestionStackUsageStore _ingestionStackUsageStore; private readonly TimeProvider _timeProvider; private readonly TimeSpan _expireTimeout = TimeSpan.FromHours(12); - - public StackService(IStackRepository stackRepository, ICacheClient cache, TimeProvider timeProvider, ILoggerFactory loggerFactory) + private readonly int _bulkBatchSize; + private readonly int _maximumStackUsageConcurrency; + + public StackService( + IStackRepository stackRepository, + ICacheClient cache, + IIngestionStackUsageStore ingestionStackUsageStore, + AppOptions options, + TimeProvider timeProvider, + ILoggerFactory loggerFactory) { _stackRepository = stackRepository; _cache = cache; + _ingestionStackUsageStore = ingestionStackUsageStore; + _bulkBatchSize = Math.Max(options.BulkBatchSize, 1); + _maximumStackUsageConcurrency = Math.Clamp(Math.Max(options.EventIngestionV3.MaximumStackUsageConcurrency, 1), 1, 64); _timeProvider = timeProvider; _logger = loggerFactory.CreateLogger() ?? NullLogger.Instance; } @@ -32,7 +45,9 @@ public async Task IncrementStackUsageAsync(string organizationId, string project ArgumentException.ThrowIfNullOrEmpty(projectId); ArgumentException.ThrowIfNullOrEmpty(stackId); if (count <= 0) + { return; + } await Task.WhenAll( _cache.ListAddAsync(GetStackOccurrenceSetCacheKey(), new StackUsageKey(organizationId, projectId, stackId)), @@ -43,70 +58,147 @@ await Task.WhenAll( } public async Task SaveStackUsagesAsync(bool sendNotifications = true, CancellationToken cancellationToken = default) + { + await SaveLegacyStackUsagesAsync(sendNotifications, cancellationToken); + await SaveIngestionStackUsagesAsync(sendNotifications, cancellationToken); + } + + internal async Task SaveLegacyStackUsagesAsync(bool sendNotifications = true, CancellationToken cancellationToken = default) { string occurrenceSetCacheKey = GetStackOccurrenceSetCacheKey(); var stackUsageSet = await _cache.GetListAsync(occurrenceSetCacheKey); - if (!stackUsageSet.HasValue) - return; - - foreach (var usage in stackUsageSet.Value) + if (stackUsageSet.HasValue) { - if (cancellationToken.IsCancellationRequested) - break; - - string organizationId = usage.OrganizationId; - string projectId = usage.ProjectId; - string stackId = usage.StackId; - - var removeFromSetTask = _cache.ListRemoveAsync(occurrenceSetCacheKey, new StackUsageKey(organizationId, projectId, stackId)); - string countCacheKey = GetStackOccurrenceCountCacheKey(stackId); - var countTask = _cache.GetAsync(countCacheKey, 0); - string minDateCacheKey = GetStackOccurrenceMinDateCacheKey(stackId); - var minDateTask = _cache.GetUnixTimeMillisecondsAsync(minDateCacheKey, _timeProvider.GetUtcNow().UtcDateTime); - string maxDateCacheKey = GetStackOccurrenceMaxDateCacheKey(stackId); - var maxDateTask = _cache.GetUnixTimeMillisecondsAsync(maxDateCacheKey, _timeProvider.GetUtcNow().UtcDateTime); - - await Task.WhenAll( - removeFromSetTask, - countTask, - minDateTask, - maxDateTask - ); - - int occurrenceCount = (int)countTask.Result; - if (occurrenceCount <= 0) + foreach (var usage in stackUsageSet.Value) { - await _cache.RemoveAllAsync([minDateCacheKey, maxDateCacheKey]); - continue; - } + if (cancellationToken.IsCancellationRequested) + { + break; + } + + string organizationId = usage.OrganizationId; + string projectId = usage.ProjectId; + string stackId = usage.StackId; + + var removeFromSetTask = _cache.ListRemoveAsync(occurrenceSetCacheKey, new StackUsageKey(organizationId, projectId, stackId)); + string countCacheKey = GetStackOccurrenceCountCacheKey(stackId); + var countTask = _cache.GetAsync(countCacheKey, 0); + string minDateCacheKey = GetStackOccurrenceMinDateCacheKey(stackId); + var minDateTask = _cache.GetUnixTimeMillisecondsAsync(minDateCacheKey, _timeProvider.GetUtcNow().UtcDateTime); + string maxDateCacheKey = GetStackOccurrenceMaxDateCacheKey(stackId); + var maxDateTask = _cache.GetUnixTimeMillisecondsAsync(maxDateCacheKey, _timeProvider.GetUtcNow().UtcDateTime); + + await Task.WhenAll( + removeFromSetTask, + countTask, + minDateTask, + maxDateTask + ); + + int occurrenceCount = (int)countTask.Result; + if (occurrenceCount <= 0) + { + await _cache.RemoveAllAsync([minDateCacheKey, maxDateCacheKey]); + continue; + } - await Task.WhenAll( - _cache.RemoveAllAsync([minDateCacheKey, maxDateCacheKey]), - _cache.DecrementAsync(countCacheKey, occurrenceCount, _expireTimeout) - ); + await Task.WhenAll( + _cache.RemoveAllAsync([minDateCacheKey, maxDateCacheKey]), + _cache.DecrementAsync(countCacheKey, occurrenceCount, _expireTimeout) + ); - var occurrenceMinDate = minDateTask.Result; - var occurrenceMaxDate = maxDateTask.Result; - bool shouldRetry = false; - try - { - if (!await _stackRepository.IncrementEventCounterAsync(organizationId, projectId, stackId, occurrenceMinDate, occurrenceMaxDate, occurrenceCount, sendNotifications)) + var occurrenceMinDate = minDateTask.Result; + var occurrenceMaxDate = maxDateTask.Result; + bool shouldRetry = false; + try { - shouldRetry = true; - await IncrementStackUsageAsync(organizationId, projectId, stackId, occurrenceMinDate, occurrenceMaxDate, occurrenceCount); + if (!await _stackRepository.IncrementEventCounterAsync(organizationId, projectId, stackId, occurrenceMinDate, occurrenceMaxDate, occurrenceCount, sendNotifications)) + { + shouldRetry = true; + await IncrementStackUsageAsync(organizationId, projectId, stackId, occurrenceMinDate, occurrenceMaxDate, occurrenceCount); + } + else + { + _logger.LogTrace("Increment event count {OccurrenceCount} for organization:{OrganizationId} project:{ProjectId} stack:{StackId} with Min Date:{OccurrenceMinDate} Max Date:{OccurrenceMaxDate}", occurrenceCount, organizationId, projectId, stackId, occurrenceMinDate, occurrenceMaxDate); + } } - else + catch (Exception ex) { - _logger.LogTrace("Increment event count {OccurrenceCount} for organization:{OrganizationId} project:{ProjectId} stack:{StackId} with Min Date:{OccurrenceMinDate} Max Date:{OccurrenceMaxDate}", occurrenceCount, organizationId, projectId, stackId, occurrenceMinDate, occurrenceMaxDate); + _logger.LogError(ex, "Error incrementing event count for organization: {OrganizationId} project:{ProjectId} stack:{StackId}", organizationId, projectId, stackId); + if (!shouldRetry) + { + await IncrementStackUsageAsync(organizationId, projectId, stackId, occurrenceMinDate, occurrenceMaxDate, occurrenceCount); + } } } - catch (Exception ex) - { - _logger.LogError(ex, "Error incrementing event count for organization: {OrganizationId} project:{ProjectId} stack:{StackId}", organizationId, projectId, stackId); - if (!shouldRetry) + } + + } + + public async Task SaveIngestionStackUsagesAsync(bool sendNotifications = true, CancellationToken cancellationToken = default) + { + var claims = await _ingestionStackUsageStore.ClaimPendingAsync(_bulkBatchSize, cancellationToken); + if (claims.Count == 0) + { + return; + } + + var acknowledged = new ConcurrentBag(); + try + { + await Parallel.ForEachAsync( + claims, + new ParallelOptions { - await IncrementStackUsageAsync(organizationId, projectId, stackId, occurrenceMinDate, occurrenceMaxDate, occurrenceCount); - } + CancellationToken = cancellationToken, + MaxDegreeOfParallelism = _maximumStackUsageConcurrency + }, + async (claim, claimCancellationToken) => + { + try + { + claimCancellationToken.ThrowIfCancellationRequested(); + await _stackRepository.ApplyIngestionStackUsageAsync( + claim.OrganizationId, + claim.ProjectId, + claim.StackId, + claim.MinimumOccurrenceDateUtc, + claim.MaximumOccurrenceDateUtc, + claim.Count, + claim.SettlementSequence, + sendNotifications); + acknowledged.Add(claim); + _logger.LogTrace( + "Applied V3 stack-usage settlement {SettlementSequence} with {OccurrenceCount} occurrences for organization:{OrganizationId} project:{ProjectId} stack:{StackId} with Min Date:{OccurrenceMinDate} Max Date:{OccurrenceMaxDate}", + claim.SettlementSequence, + claim.Count, + claim.OrganizationId, + claim.ProjectId, + claim.StackId, + claim.MinimumOccurrenceDateUtc, + claim.MaximumOccurrenceDateUtc); + } + catch (OperationCanceledException) when (claimCancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + _logger.LogError( + ex, + "Error applying V3 stack-usage settlement {SettlementSequence} for organization:{OrganizationId} project:{ProjectId} stack:{StackId}", + claim.SettlementSequence, + claim.OrganizationId, + claim.ProjectId, + claim.StackId); + } + }); + } + finally + { + if (!acknowledged.IsEmpty) + { + await _ingestionStackUsageStore.AcknowledgeAsync(acknowledged.ToArray(), CancellationToken.None); } } } diff --git a/src/Exceptionless.Core/Services/StackTraceParser.cs b/src/Exceptionless.Core/Services/StackTraceParser.cs new file mode 100644 index 0000000000..0def393b92 --- /dev/null +++ b/src/Exceptionless.Core/Services/StackTraceParser.cs @@ -0,0 +1,413 @@ +using Exceptionless.Core.Models; +using Exceptionless.Core.Models.Data; + +namespace Exceptionless.Core.Services; + +public sealed class StackTraceParser +{ + public StackFrameCollection Parse(string stackTrace) + { + var frames = new StackFrameCollection(); + ReadOnlySpan remaining = stackTrace.AsSpan(); + while (!remaining.IsEmpty) + { + int newline = remaining.IndexOf('\n'); + ReadOnlySpan line = newline >= 0 ? remaining[..newline] : remaining; + remaining = newline >= 0 ? remaining[(newline + 1)..] : []; + + if (TryParseFrame(line, out var frame)) + { + frames.Add(frame); + } + } + + return frames; + } + + public Error ParseError(string stackTrace, string? exceptionType, string? message) + { + return ParseErrorWithCoverage(stackTrace, exceptionType, message).Error; + } + + public StackTraceParseResult ParseErrorWithCoverage(string stackTrace, string? exceptionType, string? message) + { + var error = new Error + { + Type = exceptionType, + Message = message, + StackTrace = [] + }; + InnerError current = error; + var parents = new Stack(); + bool isComplete = true; + + ReadOnlySpan remaining = stackTrace.AsSpan(); + while (!remaining.IsEmpty) + { + int newline = remaining.IndexOf('\n'); + ReadOnlySpan line = newline >= 0 ? remaining[..newline] : remaining; + remaining = newline >= 0 ? remaining[(newline + 1)..] : []; + + if (TryParseInnerExceptionHeader(line, out string? innerType, out string? innerMessage)) + { + var inner = new InnerError { Type = innerType, Message = innerMessage, StackTrace = [] }; + current.Inner = inner; + parents.Push(current); + current = inner; + continue; + } + + ReadOnlySpan trimmedLine = line.Trim(); + if (trimmedLine.StartsWith("--- End of inner exception", StringComparison.OrdinalIgnoreCase)) + { + if (parents.TryPop(out var parent)) + { + current = parent; + } + + continue; + } + + if (TryParseFrame(line, out var frame)) + { + current.StackTrace ??= []; + current.StackTrace!.Add(frame); + continue; + } + + if (!trimmedLine.IsEmpty && !IsOuterExceptionHeader(trimmedLine, exceptionType)) + { + isComplete = false; + } + } + + return new StackTraceParseResult(error, isComplete); + } + + public bool TryFindFrame( + string stackTrace, + Func predicate, + out StackFrame? firstFrame, + out StackFrame? matchingFrame, + out string? selectedExceptionType) + { + firstFrame = null; + matchingFrame = null; + selectedExceptionType = null; + + // Preserve the exception nesting while retaining only the two frames needed for + // fingerprinting. ErrorSignature examines user frames from the innermost exception + // outward, then falls back to the innermost exception's first frame. A flat scan can + // incorrectly combine an inner exception type with an outer user frame. + var root = new FingerprintSegment(null); + var segments = new List { root }; + var parents = new Stack(); + FingerprintSegment current = root; + + ReadOnlySpan remaining = stackTrace.AsSpan(); + while (!remaining.IsEmpty) + { + int newline = remaining.IndexOf('\n'); + ReadOnlySpan line = newline >= 0 ? remaining[..newline] : remaining; + remaining = newline >= 0 ? remaining[(newline + 1)..] : []; + + if (TryParseInnerExceptionHeader(line, out string? innerType, out _)) + { + var inner = new FingerprintSegment(innerType); + segments.Add(inner); + parents.Push(current); + current = inner; + continue; + } + + ReadOnlySpan trimmedLine = line.Trim(); + if (trimmedLine.StartsWith("--- End of inner exception", StringComparison.OrdinalIgnoreCase)) + { + if (parents.TryPop(out var parent)) + { + current = parent; + } + + continue; + } + + if (!TryParseFrame(line, out var frame)) + { + continue; + } + + current.FirstFrame ??= frame; + if (current.MatchingFrame is null && predicate(frame)) + { + current.MatchingFrame = frame; + } + } + + for (int index = segments.Count - 1; index >= 0; index--) + { + if (segments[index].MatchingFrame is null) + { + continue; + } + + matchingFrame = segments[index].MatchingFrame; + selectedExceptionType = segments[index].ExceptionType; + break; + } + + FingerprintSegment innermost = segments[^1]; + firstFrame = innermost.FirstFrame; + if (matchingFrame is null) + { + selectedExceptionType = innermost.ExceptionType; + } + + return firstFrame is not null || matchingFrame is not null; + } + + internal static bool TryParseFrame(ReadOnlySpan rawLine, out StackFrame frame) + { + frame = null!; + ReadOnlySpan line = rawLine.Trim(); + if (line.IsEmpty) + { + return false; + } + + if (line.StartsWith("File \"", StringComparison.Ordinal)) + { + return TryParsePythonFrame(line, out frame); + } + + ReadOnlySpan methodPart; + ReadOnlySpan locationPart = []; + if (line.StartsWith("at ", StringComparison.OrdinalIgnoreCase)) + { + line = line[3..].TrimStart(); + methodPart = line; + } + else if (line.StartsWith("#", StringComparison.Ordinal)) + { + int space = line.IndexOf(' '); + if (space < 0) + { + return false; + } + + line = line[(space + 1)..].TrimStart(); + methodPart = line; + } + else + { + int atSign = line.IndexOf('@'); + if (atSign <= 0) + { + return false; + } + + methodPart = line[..atSign]; + locationPart = line[(atSign + 1)..]; + } + + int inIndex = methodPart.IndexOf(" in ", StringComparison.Ordinal); + if (locationPart.IsEmpty && inIndex > 0) + { + locationPart = methodPart[(inIndex + 4)..]; + methodPart = methodPart[..inIndex]; + } + else if (locationPart.IsEmpty && methodPart.EndsWith(')')) + { + int openParen = methodPart.LastIndexOf('('); + if (openParen > 0) + { + ReadOnlySpan candidate = methodPart[(openParen + 1)..^1]; + if (candidate.Contains(':') || candidate.Contains('/') || candidate.Contains(".java", StringComparison.OrdinalIgnoreCase)) + { + methodPart = methodPart[..openParen]; + locationPart = candidate; + } + } + } + + methodPart = StripParameters(methodPart.Trim()); + if (methodPart.StartsWith("async ", StringComparison.Ordinal)) + { + methodPart = methodPart[6..].TrimStart(); + } + + if (methodPart.IsEmpty || methodPart.SequenceEqual("")) + { + return false; + } + + frame = CreateFrame(methodPart); + ApplyLocation(frame, locationPart); + return !String.IsNullOrEmpty(frame.Name); + } + + private static bool TryParsePythonFrame(ReadOnlySpan line, out StackFrame frame) + { + frame = null!; + int fileEnd = line[6..].IndexOf('"'); + if (fileEnd < 0) + { + return false; + } + + fileEnd += 6; + string fileName = line[6..fileEnd].ToString(); + ReadOnlySpan remainder = line[(fileEnd + 1)..]; + int inIndex = remainder.IndexOf(" in ", StringComparison.Ordinal); + ReadOnlySpan method = inIndex >= 0 ? remainder[(inIndex + 4)..].Trim() : ""; + + frame = CreateFrame(method); + frame.FileName = fileName; + + int lineIndex = remainder.IndexOf("line ", StringComparison.Ordinal); + if (lineIndex >= 0) + { + ReadOnlySpan number = remainder[(lineIndex + 5)..]; + int comma = number.IndexOf(','); + if (comma >= 0) + { + number = number[..comma]; + } + + if (Int32.TryParse(number, out int lineNumber)) + { + frame.LineNumber = lineNumber; + } + } + + return true; + } + + private static bool TryParseInnerExceptionHeader(ReadOnlySpan rawLine, out string? exceptionType, out string? message) + { + exceptionType = null; + message = null; + ReadOnlySpan line = rawLine.Trim(); + if (line.StartsWith("Caused by:", StringComparison.OrdinalIgnoreCase)) + { + line = line[10..].TrimStart(); + } + else + { + int arrow = line.IndexOf("---> ", StringComparison.Ordinal); + if (arrow < 0) + { + return false; + } + + line = line[(arrow + 5)..].TrimStart(); + } + + int separator = line.IndexOf(':'); + ReadOnlySpan type = separator >= 0 ? line[..separator].Trim() : line; + if (type.IsEmpty || type.Contains(' ')) + { + return false; + } + + exceptionType = type.ToString(); + if (separator >= 0) + { + ReadOnlySpan messageValue = line[(separator + 1)..].Trim(); + if (!messageValue.IsEmpty) + { + message = messageValue.ToString(); + } + } + + return true; + } + + private static bool IsOuterExceptionHeader(ReadOnlySpan line, string? exceptionType) + { + if (String.IsNullOrWhiteSpace(exceptionType) + || !line.StartsWith(exceptionType.AsSpan(), StringComparison.Ordinal)) + { + return false; + } + + ReadOnlySpan remainder = line[exceptionType.Length..]; + return remainder.IsEmpty || remainder[0] == ':'; + } + + private static ReadOnlySpan StripParameters(ReadOnlySpan method) + { + int openParen = method.IndexOf('('); + return openParen > 0 ? method[..openParen] : method; + } + + private static StackFrame CreateFrame(ReadOnlySpan method) + { + int lastDot = method.LastIndexOf('.'); + if (lastDot < 0) + { + return new StackFrame { Name = method.ToString() }; + } + + ReadOnlySpan name = method[(lastDot + 1)..]; + ReadOnlySpan declaring = method[..lastDot]; + int typeDot = declaring.LastIndexOf('.'); + + return new StackFrame + { + Name = name.ToString(), + DeclaringType = typeDot >= 0 ? declaring[(typeDot + 1)..].ToString() : declaring.ToString(), + DeclaringNamespace = typeDot >= 0 ? declaring[..typeDot].ToString() : null + }; + } + + private static void ApplyLocation(StackFrame frame, ReadOnlySpan location) + { + location = location.Trim(); + if (location.IsEmpty || location.SequenceEqual("Unknown Source") || location.SequenceEqual("Native Method")) + { + return; + } + + int lineMarker = location.LastIndexOf(":line ", StringComparison.Ordinal); + if (lineMarker >= 0) + { + frame.FileName = location[..lineMarker].ToString(); + if (Int32.TryParse(location[(lineMarker + 6)..], out int lineNumber)) + { + frame.LineNumber = lineNumber; + } + + return; + } + + int lastColon = location.LastIndexOf(':'); + if (lastColon > 0 && Int32.TryParse(location[(lastColon + 1)..], out int finalNumber)) + { + ReadOnlySpan beforeFinal = location[..lastColon]; + int previousColon = beforeFinal.LastIndexOf(':'); + if (previousColon > 0 && Int32.TryParse(beforeFinal[(previousColon + 1)..], out int lineNumber)) + { + frame.FileName = beforeFinal[..previousColon].ToString(); + frame.LineNumber = lineNumber; + frame.Column = finalNumber; + } + else + { + frame.FileName = beforeFinal.ToString(); + frame.LineNumber = finalNumber; + } + return; + } + + frame.FileName = location.ToString(); + } + + private sealed class FingerprintSegment(string? exceptionType) + { + public string? ExceptionType { get; } = exceptionType; + public StackFrame? FirstFrame { get; set; } + public StackFrame? MatchingFrame { get; set; } + } +} + +public sealed record StackTraceParseResult(Error Error, bool IsComplete); diff --git a/src/Exceptionless.Core/Services/UsageService.cs b/src/Exceptionless.Core/Services/UsageService.cs index 4a12491675..4c8dfe2b8b 100644 --- a/src/Exceptionless.Core/Services/UsageService.cs +++ b/src/Exceptionless.Core/Services/UsageService.cs @@ -1,9 +1,11 @@ using Exceptionless.Core.Extensions; using Exceptionless.Core.Messaging.Models; using Exceptionless.Core.Models; +using Exceptionless.Core.Models.Ingestion; using Exceptionless.Core.Repositories; using Exceptionless.DateTimeExtensions; using Foundatio.Caching; +using Foundatio.Lock; using Foundatio.Messaging; using Foundatio.Repositories; using Microsoft.Extensions.Logging; @@ -15,22 +17,32 @@ public class UsageService private readonly IOrganizationRepository _organizationRepository; private readonly IProjectRepository _projectRepository; private readonly ICacheClient _cache; + private readonly IIngestionQuotaStore _ingestionQuotaStore; private readonly IMessagePublisher _messagePublisher; private readonly NotificationService _notificationService; + private readonly ILockProvider _lockProvider; private readonly TimeProvider _timeProvider; + private readonly TimeSpan _idempotencyWindow; + private readonly TimeSpan _requestTimeout; private readonly ILogger _logger; private readonly TimeSpan _bucketSize = TimeSpan.FromMinutes(5); - public UsageService(IOrganizationRepository organizationRepository, IProjectRepository projectRepository, ICacheClient cache, IMessagePublisher messagePublisher, + public UsageService(IOrganizationRepository organizationRepository, IProjectRepository projectRepository, ICacheClient cache, IIngestionQuotaStore ingestionQuotaStore, IMessagePublisher messagePublisher, NotificationService notificationService, + ILockProvider lockProvider, + AppOptions options, TimeProvider timeProvider, ILoggerFactory loggerFactory) { _organizationRepository = organizationRepository; _projectRepository = projectRepository; _cache = cache; + _ingestionQuotaStore = ingestionQuotaStore; _messagePublisher = messagePublisher; _notificationService = notificationService; + _lockProvider = lockProvider; + _idempotencyWindow = options.EventIngestionV3.IdempotencyWindow; + _requestTimeout = options.EventIngestionV3.RequestTimeout; _timeProvider = timeProvider; _logger = loggerFactory.CreateLogger(); } @@ -51,80 +63,98 @@ private async Task SavePendingOrganizationUsageAsync(DateTime utcNow) // last usage save is the last time we processed usage var lastUsageSaveCache = await _cache.GetAsync("usage:last-organization-save"); if (lastUsageSaveCache.HasValue) + { lastUsageSave = lastUsageSaveCache.Value.Add(_bucketSize); + } _logger.LogInformation("Saving organization usage starting from: {LastUsageSave}...", lastUsageSave); var bucketUtc = lastUsageSave; var currentBucketUtc = utcNow.Floor(_bucketSize); + var processingCutoffUtc = currentBucketUtc.Subtract(_bucketSize); - if (bucketUtc == currentBucketUtc) + if (bucketUtc >= processingCutoffUtc) + { return; - - // ideally, we would be popping a single org id off this list at a time in case something happens while we are processing these - var organizationIdsValue = await _cache.GetListAsync(GetOrganizationSetKey(bucketUtc)); + } // do not process current bucket, should only be processing buckets whose window of time are complete - while (bucketUtc < currentBucketUtc) + while (bucketUtc < processingCutoffUtc) { - if (organizationIdsValue.HasValue) + DateTime pendingBucketUtc = bucketUtc; + await DrainPendingUsageIdsAsync(GetOrganizationSetKey(pendingBucketUtc), async organizationId => { - // Should we wait to remove this in case there is a failure? We should just remove the organization id once processed. - await _cache.RemoveAsync(GetOrganizationSetKey(bucketUtc)); + await using var usageLock = await _lockProvider.TryAcquireAsync( + GetUsageBucketLockKey(pendingBucketUtc, organizationId), + TimeSpan.FromMinutes(1), + TimeSpan.FromSeconds(30)); + if (usageLock is null) + { + throw new InvalidOperationException($"Unable to acquire usage bucket lock for organization '{organizationId}'."); + } - foreach (string? organizationId in organizationIdsValue.Value) + var organization = await _organizationRepository.GetByIdAsync(organizationId); + if (organization is null) { - var organization = await _organizationRepository.GetByIdAsync(organizationId); - if (organization is null) - continue; - - _logger.LogInformation("Saving org ({OrganizationId}-{OrganizationName}) event usage for time bucket: {BucketUtc}...", organizationId, organization.Name, bucketUtc); - - var bucketTotal = await _cache.GetAsync(GetBucketTotalCacheKey(bucketUtc, organizationId)); - var bucketBlocked = await _cache.GetAsync(GetBucketBlockedCacheKey(bucketUtc, organizationId)); - var bucketDiscarded = await _cache.GetAsync(GetBucketDiscardedCacheKey(bucketUtc, organizationId)); - var bucketTooBig = await _cache.GetAsync(GetBucketTooBigCacheKey(bucketUtc, organizationId)); - var bucketDeleted = await _cache.GetAsync(GetBucketDeletedCacheKey(bucketUtc, organizationId)); - - bool hasIngestion = (bucketTotal?.Value ?? 0) > 0 || (bucketBlocked?.Value ?? 0) > 0 || (bucketDiscarded?.Value ?? 0) > 0 || (bucketTooBig?.Value ?? 0) > 0; - if (hasIngestion) - organization.LastEventDateUtc = _timeProvider.GetUtcNow().UtcDateTime; - - var usage = organization.GetUsage(bucketUtc, _timeProvider); - usage.Limit = organization.GetMaxEventsPerMonthWithBonus(_timeProvider); - usage.Total += bucketTotal?.Value ?? 0; - usage.Blocked += bucketBlocked?.Value ?? 0; - usage.Discarded += bucketDiscarded?.Value ?? 0; - usage.TooBig += bucketTooBig?.Value ?? 0; - usage.Deleted += bucketDeleted?.Value ?? 0; - - var hourlyUsage = organization.GetHourlyUsage(bucketUtc); - hourlyUsage.Total += bucketTotal?.Value ?? 0; - hourlyUsage.Blocked += bucketBlocked?.Value ?? 0; - hourlyUsage.Discarded += bucketDiscarded?.Value ?? 0; - hourlyUsage.TooBig += bucketTooBig?.Value ?? 0; - hourlyUsage.Deleted += bucketDeleted?.Value ?? 0; - - organization.TrimUsage(_timeProvider); - - await _cache.RemoveAllAsync(new[] { - GetBucketTotalCacheKey(bucketUtc, organizationId), - GetBucketBlockedCacheKey(bucketUtc, organizationId), - GetBucketDiscardedCacheKey(bucketUtc, organizationId), - GetBucketTooBigCacheKey(bucketUtc, organizationId), - GetBucketDeletedCacheKey(bucketUtc, organizationId), - GetThrottledKey(bucketUtc, organizationId) - }); - - await _cache.SetAsync(GetTotalCacheKey(utcNow, organizationId), usage.Total, TimeSpan.FromHours(8)); - await _organizationRepository.SaveAsync(organization); + return; + } + + _logger.LogInformation("Saving organization ({OrganizationId}-{OrganizationName}) event usage for time bucket: {BucketUtc}...", organizationId, organization.Name, pendingBucketUtc); + + var processed = await _cache.GetAsync(GetV3BucketProcessedKey(pendingBucketUtc, organizationId)); + if (processed is { HasValue: true, Value: true }) + { + await CompleteOrganizationUsageBucketAsync(organization, pendingBucketUtc, utcNow); + return; } - } + + if (organization.LastAppliedUsageBucketUtc >= pendingBucketUtc) + { + await _cache.SetAsync(GetV3BucketProcessedKey(pendingBucketUtc, organizationId), true, _idempotencyWindow); + await CompleteOrganizationUsageBucketAsync(organization, pendingBucketUtc, utcNow); + return; + } + + int bucketTotal = await GetBucketTotalAsync(pendingBucketUtc, organizationId); + var bucketBlocked = await _cache.GetAsync(GetBucketBlockedCacheKey(pendingBucketUtc, organizationId)); + var bucketDiscarded = await _cache.GetAsync(GetBucketDiscardedCacheKey(pendingBucketUtc, organizationId)); + var bucketTooBig = await _cache.GetAsync(GetBucketTooBigCacheKey(pendingBucketUtc, organizationId)); + var bucketDeleted = await _cache.GetAsync(GetBucketDeletedCacheKey(pendingBucketUtc, organizationId)); + + bool hasIngestion = bucketTotal > 0 || (bucketBlocked?.Value ?? 0) > 0 || (bucketDiscarded?.Value ?? 0) > 0 || (bucketTooBig?.Value ?? 0) > 0; + if (hasIngestion) + { + organization.LastEventDateUtc = _timeProvider.GetUtcNow().UtcDateTime; + } + + var usage = organization.GetUsage(pendingBucketUtc, _timeProvider); + usage.Limit = organization.GetMaxEventsPerMonthWithBonus(_timeProvider); + usage.Total += bucketTotal; + usage.Blocked += bucketBlocked?.Value ?? 0; + usage.Discarded += bucketDiscarded?.Value ?? 0; + usage.TooBig += bucketTooBig?.Value ?? 0; + usage.Deleted += bucketDeleted?.Value ?? 0; + + var hourlyUsage = organization.GetHourlyUsage(pendingBucketUtc); + hourlyUsage.Total += bucketTotal; + hourlyUsage.Blocked += bucketBlocked?.Value ?? 0; + hourlyUsage.Discarded += bucketDiscarded?.Value ?? 0; + hourlyUsage.TooBig += bucketTooBig?.Value ?? 0; + hourlyUsage.Deleted += bucketDeleted?.Value ?? 0; + + organization.TrimUsage(_timeProvider); + + // Persist the authoritative marker with the totals. A retry after this save can + // finish Redis acknowledgement and cleanup without applying the bucket twice. + organization.LastAppliedUsageBucketUtc = pendingBucketUtc; + await _organizationRepository.SaveAsync(organization); + await _cache.SetAsync(GetV3BucketProcessedKey(pendingBucketUtc, organizationId), true, _idempotencyWindow); + await CompleteOrganizationUsageBucketAsync(organization, pendingBucketUtc, utcNow); + }); await _cache.SetAsync("usage:last-organization-save", bucketUtc, TimeSpan.FromHours(8)); bucketUtc = bucketUtc.Add(_bucketSize); - organizationIdsValue = await _cache.GetListAsync(GetOrganizationSetKey(bucketUtc)); } } @@ -136,85 +166,162 @@ private async Task SavePendingProjectUsageAsync(DateTime utcNow) // last usage save is the last time we processed usage var lastUsageSaveCache = await _cache.GetAsync("usage:last-project-save"); if (lastUsageSaveCache.HasValue) + { lastUsageSave = lastUsageSaveCache.Value.Add(_bucketSize); + } _logger.LogInformation("Saving project usage starting from: {LastUsageSave}...", lastUsageSave); var bucketUtc = lastUsageSave; var currentBucketUtc = utcNow.Floor(_bucketSize); + var processingCutoffUtc = currentBucketUtc.Subtract(_bucketSize); - if (bucketUtc == currentBucketUtc) + if (bucketUtc >= processingCutoffUtc) + { return; - - // ideally, we would be popping a single org id off this list at a time in case something happens while we are processing these - var projectIdsValue = await _cache.GetListAsync(GetProjectSetKey(bucketUtc)); + } // do not process current bucket, should only be processing buckets whose window of time are complete - while (bucketUtc < currentBucketUtc) + while (bucketUtc < processingCutoffUtc) { - if (projectIdsValue.HasValue) + DateTime pendingBucketUtc = bucketUtc; + await DrainPendingUsageIdsAsync(GetProjectSetKey(pendingBucketUtc), async projectId => { - await _cache.RemoveAsync(GetProjectSetKey(bucketUtc)); + var project = await _projectRepository.GetByIdAsync(projectId); + if (project is null) + { + return; + } - foreach (string? projectId in projectIdsValue.Value) + await using var usageLock = await _lockProvider.TryAcquireAsync( + GetUsageBucketLockKey(pendingBucketUtc, project.OrganizationId, projectId), + TimeSpan.FromMinutes(1), + TimeSpan.FromSeconds(30)); + if (usageLock is null) { - var project = await _projectRepository.GetByIdAsync(projectId); - if (project is null) - continue; - - _logger.LogInformation("Saving project ({ProjectId}-{ProjectName}) event usage for time bucket: {BucketUtc}...", projectId, project.Name, bucketUtc); - - var bucketTotal = await _cache.GetAsync(GetBucketTotalCacheKey(bucketUtc, project.OrganizationId, projectId)); - var bucketBlocked = await _cache.GetAsync(GetBucketBlockedCacheKey(bucketUtc, project.OrganizationId, projectId)); - var bucketDiscarded = await _cache.GetAsync(GetBucketDiscardedCacheKey(bucketUtc, project.OrganizationId, projectId)); - var bucketTooBig = await _cache.GetAsync(GetBucketTooBigCacheKey(bucketUtc, project.OrganizationId, projectId)); - var bucketDeleted = await _cache.GetAsync(GetBucketDeletedCacheKey(bucketUtc, project.OrganizationId, projectId)); - - bool hasIngestion = (bucketTotal?.Value ?? 0) > 0 || (bucketBlocked?.Value ?? 0) > 0 || (bucketDiscarded?.Value ?? 0) > 0 || (bucketTooBig?.Value ?? 0) > 0; - if (hasIngestion) - project.LastEventDateUtc = _timeProvider.GetUtcNow().UtcDateTime; - - (string OrganizationId, Organization? Organization) context = (OrganizationId: project.OrganizationId, Organization: null); - int maxEventsPerMonth = await GetMaxEventsPerMonthAsync(context); - - var usage = project.GetUsage(bucketUtc); - usage.Limit = maxEventsPerMonth; - usage.Total += bucketTotal?.Value ?? 0; - usage.Blocked += bucketBlocked?.Value ?? 0; - usage.Discarded += bucketDiscarded?.Value ?? 0; - usage.TooBig += bucketTooBig?.Value ?? 0; - usage.Deleted += bucketDeleted?.Value ?? 0; - - var hourlyUsage = project.GetHourlyUsage(bucketUtc); - hourlyUsage.Total += bucketTotal?.Value ?? 0; - hourlyUsage.Blocked += bucketBlocked?.Value ?? 0; - hourlyUsage.Discarded += bucketDiscarded?.Value ?? 0; - hourlyUsage.TooBig += bucketTooBig?.Value ?? 0; - hourlyUsage.Deleted += bucketDeleted?.Value ?? 0; - - project.TrimUsage(_timeProvider); - - await _cache.RemoveAllAsync(new[] { - GetBucketTotalCacheKey(bucketUtc, project.OrganizationId, projectId), - GetBucketDiscardedCacheKey(bucketUtc, project.OrganizationId, projectId), - GetBucketBlockedCacheKey(bucketUtc, project.OrganizationId, projectId), - GetBucketTooBigCacheKey(bucketUtc, project.OrganizationId, projectId), - GetBucketDeletedCacheKey(bucketUtc, project.OrganizationId, projectId) - }); - - await _cache.SetAsync(GetTotalCacheKey(utcNow, project.OrganizationId, projectId), usage.Total, TimeSpan.FromHours(8)); - - await _projectRepository.SaveAsync(project); + throw new InvalidOperationException($"Unable to acquire usage bucket lock for project '{projectId}'."); + } + + // The project may have been saved while this worker waited for the lock. + project = await _projectRepository.GetByIdAsync(projectId); + if (project is null) + { + return; + } + + _logger.LogInformation("Saving project ({ProjectId}-{ProjectName}) event usage for time bucket: {BucketUtc}...", projectId, project.Name, pendingBucketUtc); + + var processed = await _cache.GetAsync(GetV3BucketProcessedKey(pendingBucketUtc, project.OrganizationId, projectId)); + if (processed is { HasValue: true, Value: true }) + { + await CompleteProjectUsageBucketAsync(project, pendingBucketUtc, utcNow); + return; + } + + if (project.LastAppliedUsageBucketUtc >= pendingBucketUtc) + { + await _cache.SetAsync(GetV3BucketProcessedKey(pendingBucketUtc, project.OrganizationId, projectId), true, _idempotencyWindow); + await CompleteProjectUsageBucketAsync(project, pendingBucketUtc, utcNow); + return; } - } + + int bucketTotal = await GetBucketTotalAsync(pendingBucketUtc, project.OrganizationId, projectId); + var bucketBlocked = await _cache.GetAsync(GetBucketBlockedCacheKey(pendingBucketUtc, project.OrganizationId, projectId)); + var bucketDiscarded = await _cache.GetAsync(GetBucketDiscardedCacheKey(pendingBucketUtc, project.OrganizationId, projectId)); + var bucketTooBig = await _cache.GetAsync(GetBucketTooBigCacheKey(pendingBucketUtc, project.OrganizationId, projectId)); + var bucketDeleted = await _cache.GetAsync(GetBucketDeletedCacheKey(pendingBucketUtc, project.OrganizationId, projectId)); + + bool hasIngestion = bucketTotal > 0 || (bucketBlocked?.Value ?? 0) > 0 || (bucketDiscarded?.Value ?? 0) > 0 || (bucketTooBig?.Value ?? 0) > 0; + if (hasIngestion) + { + project.LastEventDateUtc = _timeProvider.GetUtcNow().UtcDateTime; + } + + (string OrganizationId, Organization? Organization) context = (OrganizationId: project.OrganizationId, Organization: null); + int maxEventsPerMonth = await GetMaxEventsPerMonthAsync(context); + + var usage = project.GetUsage(pendingBucketUtc); + usage.Limit = maxEventsPerMonth; + usage.Total += bucketTotal; + usage.Blocked += bucketBlocked?.Value ?? 0; + usage.Discarded += bucketDiscarded?.Value ?? 0; + usage.TooBig += bucketTooBig?.Value ?? 0; + usage.Deleted += bucketDeleted?.Value ?? 0; + + var hourlyUsage = project.GetHourlyUsage(pendingBucketUtc); + hourlyUsage.Total += bucketTotal; + hourlyUsage.Blocked += bucketBlocked?.Value ?? 0; + hourlyUsage.Discarded += bucketDiscarded?.Value ?? 0; + hourlyUsage.TooBig += bucketTooBig?.Value ?? 0; + hourlyUsage.Deleted += bucketDeleted?.Value ?? 0; + + project.TrimUsage(_timeProvider); + + project.LastAppliedUsageBucketUtc = pendingBucketUtc; + await _projectRepository.SaveAsync(project); + await _cache.SetAsync(GetV3BucketProcessedKey(pendingBucketUtc, project.OrganizationId, projectId), true, _idempotencyWindow); + await CompleteProjectUsageBucketAsync(project, pendingBucketUtc, utcNow); + }); await _cache.SetAsync("usage:last-project-save", bucketUtc, TimeSpan.FromHours(8)); bucketUtc = bucketUtc.Add(_bucketSize); - projectIdsValue = await _cache.GetListAsync(GetProjectSetKey(bucketUtc)); } } + private async Task DrainPendingUsageIdsAsync(string discoveryKey, Func saveAsync) + { + while (true) + { + var pendingIds = await _cache.GetListAsync(discoveryKey); + if (!pendingIds.HasValue || pendingIds.Value.Count == 0) + { + return; + } + + foreach (string pendingId in pendingIds.Value.Distinct(StringComparer.Ordinal)) + { + if (!String.IsNullOrEmpty(pendingId)) + { + await saveAsync(pendingId); + } + + // A discovery id is acknowledged only after its durable save, processed marker, + // and cache cleanup all succeed. Failed and not-yet-visited ids remain for retry. + await _cache.ListRemoveAsync(discoveryKey, [pendingId]); + } + } + } + + private Task CompleteOrganizationUsageBucketAsync(Organization organization, DateTime bucketUtc, DateTime utcNow) + { + var usage = organization.GetUsage(bucketUtc, _timeProvider); + return Task.WhenAll( + _cache.RemoveAllAsync(new[] { + GetBucketTotalCacheKey(bucketUtc, organization.Id), + GetBucketBlockedCacheKey(bucketUtc, organization.Id), + GetBucketDiscardedCacheKey(bucketUtc, organization.Id), + GetBucketTooBigCacheKey(bucketUtc, organization.Id), + GetBucketDeletedCacheKey(bucketUtc, organization.Id), + GetThrottledKey(bucketUtc, organization.Id) + }), + _cache.SetAsync(GetTotalCacheKey(utcNow, organization.Id), usage.Total, TimeSpan.FromHours(8))); + } + + private Task CompleteProjectUsageBucketAsync(Project project, DateTime bucketUtc, DateTime utcNow) + { + var usage = project.GetUsage(bucketUtc); + return Task.WhenAll( + _cache.RemoveAllAsync(new[] { + GetBucketTotalCacheKey(bucketUtc, project.OrganizationId, project.Id), + GetBucketDiscardedCacheKey(bucketUtc, project.OrganizationId, project.Id), + GetBucketBlockedCacheKey(bucketUtc, project.OrganizationId, project.Id), + GetBucketTooBigCacheKey(bucketUtc, project.OrganizationId, project.Id), + GetBucketDeletedCacheKey(bucketUtc, project.OrganizationId, project.Id) + }), + _cache.SetAsync(GetTotalCacheKey(utcNow, project.OrganizationId, project.Id), usage.Total, TimeSpan.FromHours(8))); + } + public async Task HandleOrganizationChangeAsync(Organization modified, Organization original) { var utcNow = _timeProvider.GetUtcNow().UtcDateTime; @@ -224,14 +331,18 @@ public async Task HandleOrganizationChangeAsync(Organization modified, Organizat int modifiedMaxEvents = modified.GetMaxEventsPerMonthWithBonus(_timeProvider); int originalMaxEvents = original.GetMaxEventsPerMonthWithBonus(_timeProvider); if (modifiedMaxEvents == originalMaxEvents) + { return; + } bool isMonthlyLimitIncrease = modifiedMaxEvents < 0 || (originalMaxEvents >= 0 && modifiedMaxEvents > originalMaxEvents); if (isMonthlyLimitIncrease) { // A higher monthly limit only resets monthly notification state when it actually ends the current overage. if (!modified.IsOverMonthlyLimit(_timeProvider)) + { await _notificationService.RemoveOrganizationNotificationSentAsync(modified.Id, isOverMonthlyLimit: true); + } await _cache.RemoveAsync(GetThrottledKey(utcNow, modified.Id)); return; @@ -245,17 +356,21 @@ public async Task HandleOrganizationChangeAsync(Organization modified, Organizat await _messagePublisher.PublishAsync(new PlanOverage { OrganizationId = modified.Id }); } - var bucketTotal = await _cache.GetAsync(GetBucketTotalCacheKey(utcNow, modified.Id)); - if (!bucketTotal.HasValue) + int bucketTotal = await GetBucketTotalAsync(utcNow, modified.Id); + if (bucketTotal == 0) + { return; + } int bucketLimit = GetBucketEventLimit(modifiedMaxEvents); // unlimited if (bucketLimit < 0) + { return; + } - if (bucketTotal.Value >= bucketLimit) + if (bucketTotal >= bucketLimit) { await _messagePublisher.PublishAsync(new PlanOverage { OrganizationId = modified.Id, IsHourly = true }); await _cache.SetAsync(GetThrottledKey(utcNow, modified.Id), true, TimeSpan.FromMinutes(5)); @@ -279,7 +394,9 @@ private async ValueTask GetMaxEventsPerMonthAsync((string OrganizationId, O else { if (context.Organization is null) + { context.Organization = await _organizationRepository.GetByIdAsync(context.OrganizationId, o => o.Cache()); + } if (context.Organization is not null) { @@ -301,7 +418,9 @@ public async Task GetUsageAsync(string organizationId, string // last usage save is the last time we processed usage var lastUsageSaveCache = await _cache.GetAsync(projectId is null ? "usage:last-organization-save" : "usage:last-project-save"); if (lastUsageSaveCache.HasValue) + { lastUsageSave = lastUsageSaveCache.Value.Add(_bucketSize); + } var bucketUtc = lastUsageSave; var currentBucketUtc = utcNow.Floor(_bucketSize); @@ -312,7 +431,9 @@ public async Task GetUsageAsync(string organizationId, string { var organization = await _organizationRepository.GetByIdAsync(organizationId, o => o.Cache()); if (organization is null) + { throw new UsageServiceException($"Organization '{organizationId}' not found."); + } organization.TrimUsage(_timeProvider); @@ -327,7 +448,9 @@ public async Task GetUsageAsync(string organizationId, string { var project = await _projectRepository.GetByIdAsync(projectId, o => o.Cache()); if (project is null) + { throw new UsageServiceException($"Project '{projectId}' not found."); + } project.TrimUsage(_timeProvider); @@ -342,9 +465,9 @@ public async Task GetUsageAsync(string organizationId, string while (bucketUtc <= currentBucketUtc) { // get current bucket counters - var bucketTotal = await _cache.GetAsync(GetBucketTotalCacheKey(bucketUtc, organizationId, projectId)); - usage.CurrentUsage.Total += bucketTotal?.Value ?? 0; - usage.CurrentHourUsage.Total += bucketTotal?.Value ?? 0; + int bucketTotal = await GetBucketTotalAsync(bucketUtc, organizationId, projectId); + usage.CurrentUsage.Total += bucketTotal; + usage.CurrentHourUsage.Total += bucketTotal; var bucketBlocked = await _cache.GetAsync(GetBucketBlockedCacheKey(bucketUtc, organizationId, projectId)); usage.CurrentUsage.Blocked += bucketBlocked?.Value ?? 0; @@ -369,18 +492,32 @@ public async Task GetUsageAsync(string organizationId, string } public async Task GetEventsLeftAsync(string organizationId) + { + int maxEventsPerMonth = await GetMaxEventsPerMonthAsync(organizationId); + return await GetEventsLeftAsync(organizationId, maxEventsPerMonth); + } + + private async Task GetEventsLeftAsync(string organizationId, int maxEventsPerMonth) { var utcNow = _timeProvider.GetUtcNow().UtcDateTime; (string OrganizationId, Organization? Organization) context = (OrganizationId: organizationId, Organization: null); - int maxEventsPerMonth = await GetMaxEventsPerMonthAsync(context); // check for unlimited (-1) events if (maxEventsPerMonth < 0) + { return Int32.MaxValue; + } + + // These values are independent Redis reads. Issue them together because this method runs + // under the short quota-decision lock for V3 ingestion. + var currentTotalCacheTask = _cache.GetAsync(GetTotalCacheKey(utcNow, organizationId)); + var bucketTotalTask = GetBucketTotalAsync(utcNow, organizationId); + var previousBucketTotalTask = GetBucketTotalAsync(utcNow.Subtract(_bucketSize), organizationId); + await Task.WhenAll(currentTotalCacheTask, bucketTotalTask, previousBucketTotalTask); int currentTotal; - var currentTotalCache = await _cache.GetAsync(GetTotalCacheKey(utcNow, organizationId)); + var currentTotalCache = await currentTotalCacheTask; if (currentTotalCache.HasValue) { currentTotal = currentTotalCache.Value; @@ -388,10 +525,14 @@ public async Task GetEventsLeftAsync(string organizationId) else { if (context.Organization is null) + { context.Organization = await _organizationRepository.GetByIdAsync(organizationId, o => o.Cache()); + } if (context.Organization is null) + { throw new UsageServiceException($"Organization '{organizationId}' not found."); + } currentTotal = context.Organization.GetCurrentUsage(_timeProvider).Total; await _cache.SetAsync(GetTotalCacheKey(utcNow, organizationId), currentTotal, TimeSpan.FromHours(8)); @@ -399,33 +540,85 @@ public async Task GetEventsLeftAsync(string organizationId) // if already over limit, return if (currentTotal >= maxEventsPerMonth) + { return 0; + } // get current bucket counter and add it to total - var bucketTotal = await _cache.GetAsync(GetBucketTotalCacheKey(utcNow, organizationId)); - if (bucketTotal.HasValue) - currentTotal += bucketTotal.Value; + int bucketTotal = await bucketTotalTask; + currentTotal += bucketTotal; // get previous bucket counter and add it to total since it might not be saved yet - var previousBucketTotal = await _cache.GetAsync(GetBucketTotalCacheKey(utcNow.Subtract(_bucketSize), organizationId)); - if (previousBucketTotal.HasValue) - currentTotal += previousBucketTotal.Value; + int previousBucketTotal = await previousBucketTotalTask; + currentTotal += previousBucketTotal; // check to see if adding this bucket puts the org over the limit if (currentTotal >= maxEventsPerMonth) + { return 0; + } // get a bucket level limit to help spread the events out more evenly (allows bursting) int bucketLimit = GetBucketEventLimit(maxEventsPerMonth); - int eventsLeftInBucket = bucketLimit - (bucketTotal?.Value ?? 0); + int eventsLeftInBucket = bucketLimit - bucketTotal; + int eventsLeftInMonth = maxEventsPerMonth - currentTotal; - return Math.Max(eventsLeftInBucket, 0); + return Math.Max(Math.Min(eventsLeftInBucket, eventsLeftInMonth), 0); + } + + public async Task ReserveEventsAsync(string organizationId, int eventCount) + { + string reservationId = Guid.NewGuid().ToString("N"); + if (eventCount <= 0) + { + return new EventIngestionReservation(reservationId, organizationId, 0); + } + + // Availability and the Redis lease must be one serialized decision. Otherwise a delayed + // caller can retain an old availability snapshot until a prior request has committed and + // released its lease, then reuse that capacity. The lock is scoped per organization, so + // unrelated tenants and scale-out replicas continue independently. + await using var quotaLock = await _lockProvider.TryAcquireAsync( + String.Concat("usage:quota-reservation:", organizationId), + TimeSpan.FromMinutes(1), + TimeSpan.FromSeconds(30)); + if (quotaLock is null) + { + throw new InvalidOperationException($"Unable to acquire the ingestion quota lock for organization '{organizationId}'."); + } + + int maxEventsPerMonth = await GetMaxEventsPerMonthAsync(organizationId); + if (maxEventsPerMonth < 0) + { + return EventIngestionReservation.Unlimited(organizationId, eventCount); + } + + int eventsLeft = await GetEventsLeftAsync(organizationId, maxEventsPerMonth); + int admittedCount = await _ingestionQuotaStore.ReserveAsync( + organizationId, + reservationId, + eventCount, + eventsLeft, + TimeSpan.FromMinutes(10)); + return new EventIngestionReservation(reservationId, organizationId, admittedCount); + } + + public async Task ReleaseEventReservationAsync(EventIngestionReservation reservation) + { + if (reservation.Count <= 0 || reservation.IsUnlimited) + { + return; + } + + await _ingestionQuotaStore.ReleaseAsync(reservation.OrganizationId, reservation.Id); } public async Task IncrementTotalAsync(string organizationId, string projectId, int eventCount = 1) { if (eventCount <= 0) + { return; + } var utcNow = _timeProvider.GetUtcNow().UtcDateTime; @@ -443,7 +636,9 @@ public async Task IncrementTotalAsync(string organizationId, string projectId, i { long monthTotal = currentTotalCache.Value + bucketTotal; if (monthTotal >= maxEventsPerMonth && monthTotal - maxEventsPerMonth < eventCount) + { await _messagePublisher.PublishAsync(new PlanOverage { OrganizationId = organizationId }); + } } if (bucketTotal >= bucketLimit && bucketTotal - bucketLimit < eventCount) @@ -454,10 +649,111 @@ public async Task IncrementTotalAsync(string organizationId, string projectId, i } } + public async Task IncrementTotalAsync(string organizationId, string projectId, IReadOnlyCollection settlements) + { + if (settlements.Count == 0) + { + return; + } + + // Only the writer that successfully creates a durable event may submit a settlement. + // Keep a final age guard so delayed recovery cannot reconstruct a closed bucket. This + // intentionally fails open for billing rather than keeping a per-event Redis ledger. + DateTime settlementCutoffUtc = _timeProvider.GetUtcNow().UtcDateTime.Subtract(_idempotencyWindow); + foreach (var bucket in settlements + .Where(settlement => settlement.CreatedUtc > settlementCutoffUtc) + .DistinctBy(settlement => settlement.EventId, StringComparer.Ordinal) + .GroupBy(settlement => settlement.CreatedUtc.Floor(_bucketSize))) + { + EventUsageSettlement[] batchSettlements = bucket.ToArray(); + DateTime nowUtc = _timeProvider.GetUtcNow().UtcDateTime; + DateTime currentBucketUtc = nowUtc.Floor(_bucketSize); + bool isCurrentBucket = bucket.Key == currentBucketUtc; + bool isActivePreviousBucket = bucket.Key == currentBucketUtc.Subtract(_bucketSize) + && batchSettlements.All(settlement => nowUtc.Subtract(settlement.CreatedUtc) <= _requestTimeout); + int eventCount = batchSettlements.Length; + if (isCurrentBucket || isActivePreviousBucket) + { + // Current and previous buckets have a full grace window before the saver can + // close them. Keep this hot path lock-free and O(1): register discovery before + // incrementing scalar counters so a crash can undercount but cannot orphan usage. + await Task.WhenAll( + _cache.ListAddAsync(GetOrganizationSetKey(bucket.Key), organizationId, TimeSpan.FromHours(8)), + _cache.ListAddAsync(GetProjectSetKey(bucket.Key), projectId, TimeSpan.FromHours(8))); + long[] updatedTotals = await Task.WhenAll( + _cache.IncrementAsync(GetBucketTotalCacheKey(bucket.Key, organizationId), eventCount, TimeSpan.FromHours(8)), + _cache.IncrementAsync(GetBucketTotalCacheKey(bucket.Key, organizationId, projectId), eventCount, TimeSpan.FromHours(8))); + await HandleV3UsageAddedAsync(organizationId, eventCount, bucket.Key, updatedTotals[0]); + continue; + } + + string[] usageLockKeys = + [ + GetUsageBucketLockKey(bucket.Key, organizationId), + GetUsageBucketLockKey(bucket.Key, organizationId, projectId) + ]; + await using var usageLock = await _lockProvider.TryAcquireAsync( + usageLockKeys, + TimeSpan.FromMinutes(1), + TimeSpan.FromSeconds(30)); + if (usageLock is null) + { + throw new InvalidOperationException($"Unable to acquire usage bucket locks for project '{projectId}'."); + } + + var organizationProcessedTask = _cache.GetAsync(GetV3BucketProcessedKey(bucket.Key, organizationId)); + var projectProcessedTask = _cache.GetAsync(GetV3BucketProcessedKey(bucket.Key, organizationId, projectId)); + await Task.WhenAll(organizationProcessedTask, projectProcessedTask); + var organizationProcessed = await organizationProcessedTask; + var projectProcessed = await projectProcessedTask; + bool isOrganizationProcessed = organizationProcessed.HasValue && organizationProcessed.Value; + bool isProjectProcessed = projectProcessed.HasValue && projectProcessed.Value; + DateTime organizationBucketUtc = isOrganizationProcessed ? currentBucketUtc : bucket.Key; + DateTime projectBucketUtc = isProjectProcessed ? currentBucketUtc : bucket.Key; + + await Task.WhenAll( + _cache.ListAddAsync(GetOrganizationSetKey(organizationBucketUtc), organizationId, TimeSpan.FromHours(8)), + _cache.ListAddAsync(GetProjectSetKey(projectBucketUtc), projectId, TimeSpan.FromHours(8))); + long[] totals = await Task.WhenAll( + _cache.IncrementAsync(GetBucketTotalCacheKey(organizationBucketUtc, organizationId), eventCount, TimeSpan.FromHours(8)), + _cache.IncrementAsync(GetBucketTotalCacheKey(projectBucketUtc, organizationId, projectId), eventCount, TimeSpan.FromHours(8))); + + await HandleV3UsageAddedAsync(organizationId, eventCount, organizationBucketUtc, totals[0]); + } + } + + private async Task HandleV3UsageAddedAsync(string organizationId, int eventCount, DateTime bucketUtc, long bucketTotal) + { + int maxEventsPerMonth = await GetMaxEventsPerMonthAsync(organizationId); + if (maxEventsPerMonth < 0) + { + return; + } + + int bucketLimit = GetBucketEventLimit(maxEventsPerMonth); + var currentTotalCache = await _cache.GetAsync(GetTotalCacheKey(bucketUtc, organizationId)); + if (currentTotalCache.HasValue) + { + long monthTotal = currentTotalCache.Value + bucketTotal; + if (monthTotal >= maxEventsPerMonth && monthTotal - maxEventsPerMonth < eventCount) + { + await _messagePublisher.PublishAsync(new PlanOverage { OrganizationId = organizationId }); + } + } + + if (bucketTotal >= bucketLimit && bucketTotal - bucketLimit < eventCount) + { + await _messagePublisher.PublishAsync(new PlanOverage { OrganizationId = organizationId, IsHourly = true }); + await _cache.SetAsync(GetThrottledKey(bucketUtc, organizationId), true, TimeSpan.FromMinutes(5)); + } + } + public async Task IncrementBlockedAsync(string organizationId, string? projectId, int eventCount = 1) { if (eventCount <= 0) + { return; + } var utcNow = _timeProvider.GetUtcNow().UtcDateTime; @@ -466,7 +762,9 @@ public async Task IncrementBlockedAsync(string organizationId, string? projectId await _cache.ListAddAsync(GetOrganizationSetKey(utcNow), organizationId, TimeSpan.FromHours(8)); if (projectId is not null) + { await _cache.ListAddAsync(GetProjectSetKey(utcNow), projectId, TimeSpan.FromHours(8)); + } AppDiagnostics.EventsBlocked.Add(eventCount); } @@ -476,7 +774,9 @@ public async Task IncrementBlockedAsync(string organizationId, string? projectId public async Task IncrementDiscardedAsync(string organizationId, string projectId, int eventCount = 1) { if (eventCount <= 0) + { return; + } var utcNow = _timeProvider.GetUtcNow().UtcDateTime; @@ -498,7 +798,9 @@ public async Task IncrementTooBigAsync(string organizationId, string? projectId) await _cache.ListAddAsync(GetOrganizationSetKey(utcNow), organizationId, TimeSpan.FromHours(8)); if (projectId is not null) + { await _cache.ListAddAsync(GetProjectSetKey(utcNow), projectId, TimeSpan.FromHours(8)); + } AppDiagnostics.PostTooBig.Add(1); } @@ -506,7 +808,9 @@ public async Task IncrementTooBigAsync(string organizationId, string? projectId) public async Task IncrementDeletedAsync(string organizationId, string? projectId, int eventCount = 1) { if (eventCount <= 0) + { return; + } var utcNow = _timeProvider.GetUtcNow().UtcDateTime; @@ -529,12 +833,16 @@ public async Task IncrementDeletedAsync(string organizationId, string? projectId private int GetBucketEventLimit(int maxEventsPerMonth) { if (maxEventsPerMonth < 5000) + { return maxEventsPerMonth; + } var utcNow = _timeProvider.GetUtcNow().UtcDateTime; var timeLeftInMonth = utcNow.EndOfMonth() - utcNow; if (timeLeftInMonth < TimeSpan.FromDays(1)) + { return maxEventsPerMonth; + } double bucketsLeftInMonth = timeLeftInMonth / _bucketSize; @@ -547,7 +855,9 @@ private string GetTotalCacheKey(DateTime utcTime, string organizationId, string? int bucket = GetTotalBucket(utcTime); if (String.IsNullOrEmpty(projectId)) + { return $"usage:total:{bucket}:{organizationId}:total"; + } return $"usage:total:{bucket}:{organizationId}:{projectId}:total"; } @@ -557,17 +867,43 @@ private string GetBucketTotalCacheKey(DateTime utcTime, string organizationId, s int bucket = GetCurrentBucket(utcTime); if (String.IsNullOrEmpty(projectId)) + { return $"usage:{bucket}:{organizationId}:total"; + } return $"usage:{bucket}:{organizationId}:{projectId}:total"; } + private async Task GetBucketTotalAsync(DateTime utcTime, string organizationId, string? projectId = null) + { + var total = await _cache.GetAsync(GetBucketTotalCacheKey(utcTime, organizationId, projectId)); + return total.HasValue ? total.Value : 0; + } + + private string GetV3BucketProcessedKey(DateTime utcTime, string organizationId, string? projectId = null) + { + int bucket = GetCurrentBucket(utcTime); + return String.IsNullOrEmpty(projectId) + ? $"usage:{bucket}:{organizationId}:total:v3:processed" + : $"usage:{bucket}:{organizationId}:{projectId}:total:v3:processed"; + } + + private string GetUsageBucketLockKey(DateTime utcTime, string organizationId, string? projectId = null) + { + int bucket = GetCurrentBucket(utcTime); + return String.IsNullOrEmpty(projectId) + ? $"usage:{bucket}:{organizationId}:v3:lock" + : $"usage:{bucket}:{organizationId}:{projectId}:v3:lock"; + } + private string GetBucketBlockedCacheKey(DateTime utcTime, string organizationId, string? projectId = null) { int bucket = GetCurrentBucket(utcTime); if (String.IsNullOrEmpty(projectId)) + { return $"usage:{bucket}:{organizationId}:blocked"; + } return $"usage:{bucket}:{organizationId}:{projectId}:blocked"; } @@ -577,7 +913,9 @@ private string GetBucketDiscardedCacheKey(DateTime utcTime, string organizationI int bucket = GetCurrentBucket(utcTime); if (String.IsNullOrEmpty(projectId)) + { return $"usage:{bucket}:{organizationId}:discarded"; + } return $"usage:{bucket}:{organizationId}:{projectId}:discarded"; } @@ -587,7 +925,9 @@ private string GetBucketTooBigCacheKey(DateTime utcTime, string organizationId, int bucket = GetCurrentBucket(utcTime); if (String.IsNullOrEmpty(projectId)) + { return $"usage:{bucket}:{organizationId}:toobig"; + } return $"usage:{bucket}:{organizationId}:{projectId}:toobig"; } @@ -597,7 +937,9 @@ private string GetBucketDeletedCacheKey(DateTime utcTime, string organizationId, int bucket = GetCurrentBucket(utcTime); if (String.IsNullOrEmpty(projectId)) + { return $"usage:{bucket}:{organizationId}:deleted"; + } return $"usage:{bucket}:{organizationId}:{projectId}:deleted"; } diff --git a/src/Exceptionless.Core/Utility/AppDiagnostics.cs b/src/Exceptionless.Core/Utility/AppDiagnostics.cs index c081925b6c..0a12d0fc5d 100644 --- a/src/Exceptionless.Core/Utility/AppDiagnostics.cs +++ b/src/Exceptionless.Core/Utility/AppDiagnostics.cs @@ -30,7 +30,9 @@ public static class AppDiagnostics public static void Counter(string name, int value = 1) { if (!_counters.TryGetValue(_metricsPrefix + name, out var counter)) + { counter = _counters.GetOrAdd(_metricsPrefix + name, key => Meter.CreateCounter(key)); + } counter.Add(value); } @@ -38,7 +40,9 @@ public static void Counter(string name, int value = 1) public static void Gauge(string name, double value) { if (!_gauges.TryGetValue(_metricsPrefix + name, out var gauge)) + { gauge = _gauges.GetOrAdd(_metricsPrefix + name, key => new GaugeInfo(Meter, key)); + } gauge.Value = value; } @@ -46,7 +50,9 @@ public static void Gauge(string name, double value) public static void Timer(string name, int milliseconds) { if (!_timers.TryGetValue(_metricsPrefix + name, out var timer)) + { timer = _timers.GetOrAdd(_metricsPrefix + name, key => Meter.CreateHistogram(key, "ms")); + } timer.Record(milliseconds); } @@ -54,7 +60,9 @@ public static void Timer(string name, int milliseconds) public static IDisposable StartTimer(string name) { if (!_timers.TryGetValue(_metricsPrefix + name, out var timer)) + { timer = _timers.GetOrAdd(_metricsPrefix + name, key => Meter.CreateHistogram(key, "ms")); + } return timer.StartTimer(); } @@ -62,19 +70,25 @@ public static IDisposable StartTimer(string name) public static async Task TimeAsync(Func action, string name) { using (StartTimer(name)) + { await action(); + } } public static void Time(Action action, string name) { using (StartTimer(name)) + { action(); + } } public static async Task TimeAsync(Func> func, string name) { using (StartTimer(name)) + { return await func(); + } } private class GaugeInfo @@ -102,6 +116,35 @@ public GaugeInfo(Meter meter, string name) internal static readonly Counter EventsRetryErrors = Meter.CreateCounter("ex.events.retry.errors", description: "Events where retry processing got an error"); internal static readonly Histogram EventsFieldCount = Meter.CreateHistogram("ex.events.field.count", description: "Number of fields per event"); + internal static readonly Counter IngestionV3Received = Meter.CreateCounter("ex.ingestion.v3.received", description: "V3 events received"); + internal static readonly Counter IngestionV3Persisted = Meter.CreateCounter("ex.ingestion.v3.persisted", description: "V3 events durably persisted"); + internal static readonly Counter IngestionV3Discarded = Meter.CreateCounter("ex.ingestion.v3.discarded", description: "V3 events terminated by a discard rule"); + internal static readonly Counter IngestionV3Duplicate = Meter.CreateCounter("ex.ingestion.v3.duplicate", description: "V3 duplicate events acknowledged"); + internal static readonly Counter IngestionV3Blocked = Meter.CreateCounter("ex.ingestion.v3.blocked", description: "V3 events blocked by billable quota"); + internal static readonly Counter IngestionV3Invalid = Meter.CreateCounter("ex.ingestion.v3.invalid", description: "Invalid V3 events"); + internal static readonly Counter IngestionV3RouteCacheHits = Meter.CreateCounter("ex.ingestion.v3.route.cache_hits"); + internal static readonly Counter IngestionV3RouteCacheMisses = Meter.CreateCounter("ex.ingestion.v3.route.cache_misses"); + internal static readonly Counter IngestionV3RouteNegativeHits = Meter.CreateCounter("ex.ingestion.v3.route.negative_hits"); + internal static readonly Counter IngestionV3ParserFallbacks = Meter.CreateCounter("ex.ingestion.v3.parser.fallbacks", description: "V3 stack traces that used normalized raw-trace fingerprinting"); + internal static readonly Counter IngestionV3Failures = Meter.CreateCounter("ex.ingestion.v3.failures", description: "V3 requests that failed before acknowledgement"); + internal static readonly Counter IngestionV3RouteLocalDeduplicated = Meter.CreateCounter("ex.ingestion.v3.route.local_deduplicated"); + internal static readonly Counter IngestionV3RouteRepositoryLookups = Meter.CreateCounter("ex.ingestion.v3.route.repository_lookups"); + internal static readonly Counter IngestionV3WriteReconciliations = Meter.CreateCounter("ex.ingestion.v3.write.reconciliations"); + internal static readonly Counter IngestionV3UsageCommitted = Meter.CreateCounter("ex.ingestion.v3.usage.committed", description: "V3 newly persisted events committed to billable usage"); + internal static readonly Counter IngestionV3AmbiguousSettlementsSkipped = Meter.CreateCounter("ex.ingestion.v3.usage.ambiguous_skipped", description: "V3 durable events skipped for billing after an ambiguous write outcome to prevent double charging"); + internal static readonly Histogram IngestionV3MicroBatchSize = Meter.CreateHistogram("ex.ingestion.v3.microbatch.size", unit: "events"); + internal static readonly Histogram IngestionV3CompressedSize = Meter.CreateHistogram("ex.ingestion.v3.compressed.size", unit: "bytes"); + internal static readonly Histogram IngestionV3DecompressedSize = Meter.CreateHistogram("ex.ingestion.v3.decompressed.size", unit: "bytes"); + internal static readonly Histogram IngestionV3FingerprintTime = Meter.CreateHistogram("ex.ingestion.v3.fingerprint.duration", unit: "ms"); + internal static readonly Histogram IngestionV3RouteResolutionTime = Meter.CreateHistogram("ex.ingestion.v3.route.duration", unit: "ms"); + internal static readonly Histogram IngestionV3QuotaTime = Meter.CreateHistogram("ex.ingestion.v3.quota.duration", unit: "ms"); + internal static readonly Histogram IngestionV3MaterializationTime = Meter.CreateHistogram("ex.ingestion.v3.materialize.duration", unit: "ms"); + internal static readonly Histogram IngestionV3WriteTime = Meter.CreateHistogram("ex.ingestion.v3.write.duration", unit: "ms"); + internal static readonly Histogram IngestionV3OutboxWriteTime = Meter.CreateHistogram("ex.ingestion.v3.outbox.duration", unit: "ms"); + internal static readonly Histogram IngestionV3SettlementTime = Meter.CreateHistogram("ex.ingestion.v3.settlement.duration", unit: "ms"); + internal static readonly UpDownCounter IngestionV3ActiveStreams = Meter.CreateUpDownCounter("ex.ingestion.v3.active_streams"); + internal static readonly UpDownCounter IngestionV3InFlightEvents = Meter.CreateUpDownCounter("ex.ingestion.v3.in_flight_events"); + internal static readonly Counter PostsParsed = Meter.CreateCounter("ex.posts.parsed", description: "Post batch submission parsed"); internal static readonly Histogram PostsEventCount = Meter.CreateHistogram("ex.posts.eventcount", description: "Number of events in post batch submission"); internal static readonly Histogram PostsSize = Meter.CreateHistogram("ex.posts.size", description: "Size of post batch submission", unit: "bytes"); @@ -137,19 +180,25 @@ public static IDisposable StartTimer(this Histogram histogram) public static async Task TimeAsync(this Histogram histogram, Func action) { using (histogram.StartTimer()) + { await action(); + } } public static void Time(this Histogram histogram, Action action) { using (histogram.StartTimer()) + { action(); + } } public static async Task TimeAsync(this Histogram histogram, Func> func) { using (histogram.StartTimer()) + { return await func(); + } } } @@ -168,7 +217,9 @@ public HistogramTimer(Histogram histogram) public void Dispose() { if (_disposed) + { return; + } _disposed = true; _stopWatch.Stop(); diff --git a/src/Exceptionless.Insulation/Bootstrapper.cs b/src/Exceptionless.Insulation/Bootstrapper.cs index 5ce4f4f7b3..0b245da9a1 100644 --- a/src/Exceptionless.Insulation/Bootstrapper.cs +++ b/src/Exceptionless.Insulation/Bootstrapper.cs @@ -9,6 +9,7 @@ using Exceptionless.Core.Jobs.Elastic; using Exceptionless.Core.Mail; using Exceptionless.Core.Queues.Models; +using Exceptionless.Core.Services; using Exceptionless.Core.Utility; using Exceptionless.Insulation.Geo; using Exceptionless.Insulation.HealthChecks; @@ -44,10 +45,14 @@ public static void RegisterServices(IServiceCollection services, AppOptions appO client.Configuration.UseLogger(new SelfLogLogger()); if (!String.IsNullOrEmpty(appOptions.Version)) + { client.Configuration.SetVersion(appOptions.Version); + } if (String.IsNullOrEmpty(appOptions.InternalProjectId)) + { client.Configuration.Enabled = false; + } client.Configuration.UseInMemoryStorage(); client.Configuration.UseReferenceIds(); @@ -57,10 +62,14 @@ public static void RegisterServices(IServiceCollection services, AppOptions appO } if (!String.IsNullOrEmpty(appOptions.GoogleGeocodingApiKey)) + { services.ReplaceSingleton(s => new GoogleGeocodeService(appOptions.GoogleGeocodingApiKey)); + } if (!String.IsNullOrEmpty(appOptions.MaxMindGeoIpKey)) + { services.ReplaceSingleton(); + } RegisterCache(services, appOptions.CacheOptions); RegisterMessageBus(services, appOptions.MessageBusOptions); @@ -70,7 +79,9 @@ public static void RegisterServices(IServiceCollection services, AppOptions appO RegisterHealthChecks(services); if (!String.IsNullOrEmpty(appOptions.EmailOptions.SmtpHost)) + { services.ReplaceSingleton(); + } } private static IHealthChecksBuilder RegisterHealthChecks(IServiceCollection services) @@ -97,7 +108,8 @@ private static IHealthChecksBuilder RegisterHealthChecks(IServiceCollection serv .AddAutoNamedCheck("AllJobs") .AddAutoNamedCheck("AllJobs") .AddAutoNamedCheck("AllJobs") - .AddAutoNamedCheck("AllJobs"); + .AddAutoNamedCheck("AllJobs") + .AddAutoNamedCheck("AllJobs"); } private static void RegisterCache(IServiceCollection container, CacheOptions options) @@ -107,10 +119,22 @@ private static void RegisterCache(IServiceCollection container, CacheOptions opt container.ReplaceSingleton(s => GetRedisConnection(options.ConnectionString!, s.GetRequiredService())); if (!String.IsNullOrEmpty(options.Scope)) + { container.ReplaceSingleton(s => new ScopedCacheClient(CreateRedisCacheClient(s), options.Scope)); + } else + { container.ReplaceSingleton(CreateRedisCacheClient); - + } + + container.ReplaceSingleton(s => new RedisIngestionQuotaStore( + s.GetRequiredService(), + s.GetRequiredService(), + options.Scope)); + container.ReplaceSingleton(s => new RedisIngestionStackUsageStore( + s.GetRequiredService(), + s.GetRequiredService(), + options.Scope)); container.ReplaceSingleton(); } } @@ -221,7 +245,9 @@ private static void RegisterStorage(IServiceCollection container, StorageOptions }); if (!String.IsNullOrWhiteSpace(options.Scope)) + { storage = new ScopedFileStorage(storage, options.Scope); + } return storage; }); @@ -325,7 +351,9 @@ private static AWSCredentials GetAWSCredentials(IDictionary dat string accessKey = data.GetString("accesskey"); string secretKey = data.GetString("secretkey"); if (!String.IsNullOrEmpty(accessKey) && !String.IsNullOrEmpty(secretKey)) + { return new BasicAWSCredentials(accessKey, secretKey); + } return DefaultAWSCredentialsIdentityResolver.GetCredentials(); } diff --git a/src/Exceptionless.Insulation/Properties/AssemblyInfo.cs b/src/Exceptionless.Insulation/Properties/AssemblyInfo.cs new file mode 100644 index 0000000000..5be2f7fe08 --- /dev/null +++ b/src/Exceptionless.Insulation/Properties/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Exceptionless.Tests")] diff --git a/src/Exceptionless.Insulation/Redis/RedisIngestionQuotaStore.cs b/src/Exceptionless.Insulation/Redis/RedisIngestionQuotaStore.cs new file mode 100644 index 0000000000..026cdbbba9 --- /dev/null +++ b/src/Exceptionless.Insulation/Redis/RedisIngestionQuotaStore.cs @@ -0,0 +1,161 @@ +using Exceptionless.Core.Services; +using StackExchange.Redis; + +namespace Exceptionless.Insulation.Redis; + +/// +/// Atomically tracks active ingestion capacity leases for one organization. Expired-lease cleanup +/// is bounded per call so a long-idle tenant cannot create an unbounded Redis script execution. +/// +public sealed class RedisIngestionQuotaStore( + IConnectionMultiplexer connectionMultiplexer, + TimeProvider timeProvider, + string? scope) : IIngestionQuotaStore +{ + private const int CleanupLimit = 1000; + private const string ReserveScript = """ + local active = tonumber(redis.call('GET', KEYS[3]) or '0') + local expired = redis.call('ZRANGEBYSCORE', KEYS[2], '-inf', ARGV[1], 'LIMIT', 0, ARGV[7]) + for _, reservationId in ipairs(expired) do + local count = tonumber(redis.call('HGET', KEYS[1], reservationId) or '0') + active = active - count + redis.call('HDEL', KEYS[1], reservationId) + redis.call('ZREM', KEYS[2], reservationId) + end + + if active < 0 then + active = 0 + end + + if #expired > 0 then + if redis.call('ZCARD', KEYS[2]) == 0 then + redis.call('DEL', KEYS[1], KEYS[2], KEYS[3]) + active = 0 + else + redis.call('SET', KEYS[3], active, 'KEEPTTL') + end + end + + local existing = redis.call('HGET', KEYS[1], ARGV[2]) + if existing then + return tonumber(existing) + end + + local remaining = tonumber(ARGV[4]) - active + local admitted = tonumber(ARGV[3]) + if remaining < admitted then + admitted = remaining + end + if admitted < 0 then + admitted = 0 + end + + if admitted > 0 then + redis.call('HSET', KEYS[1], ARGV[2], admitted) + redis.call('ZADD', KEYS[2], ARGV[5], ARGV[2]) + active = active + admitted + redis.call('SET', KEYS[3], active) + redis.call('PEXPIRE', KEYS[1], ARGV[8]) + redis.call('PEXPIRE', KEYS[2], ARGV[8]) + redis.call('PEXPIRE', KEYS[3], ARGV[8]) + end + + return admitted + """; + + private const string ReleaseScript = """ + local count = redis.call('HGET', KEYS[1], ARGV[1]) + if not count then + return 0 + end + + count = tonumber(count) + redis.call('HDEL', KEYS[1], ARGV[1]) + redis.call('ZREM', KEYS[2], ARGV[1]) + local active = tonumber(redis.call('GET', KEYS[3]) or '0') - count + if active <= 0 or redis.call('ZCARD', KEYS[2]) == 0 then + redis.call('DEL', KEYS[1], KEYS[2], KEYS[3]) + else + redis.call('SET', KEYS[3], active, 'KEEPTTL') + end + + return count + """; + + private readonly IDatabase _database = connectionMultiplexer.GetDatabase(); + private readonly string _scopePrefix = String.IsNullOrWhiteSpace(scope) ? String.Empty : String.Concat(scope, ":"); + + public async Task ReserveAsync( + string organizationId, + string reservationId, + int requestedCount, + int availableCount, + TimeSpan expiresIn, + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + ArgumentException.ThrowIfNullOrWhiteSpace(organizationId); + ArgumentException.ThrowIfNullOrWhiteSpace(reservationId); + ArgumentOutOfRangeException.ThrowIfNegative(requestedCount); + ArgumentOutOfRangeException.ThrowIfNegative(availableCount); + if (expiresIn <= TimeSpan.Zero) + { + throw new ArgumentOutOfRangeException(nameof(expiresIn)); + } + + if (requestedCount == 0) + { + return 0; + } + + RedisKey[] keys = GetKeys(organizationId); + long nowMilliseconds = timeProvider.GetUtcNow().ToUnixTimeMilliseconds(); + long ttlMilliseconds = Math.Max(1, (long)Math.Ceiling(expiresIn.TotalMilliseconds)); + long stateTtlMilliseconds = checked(ttlMilliseconds * 2); + RedisValue[] values = + [ + nowMilliseconds, + reservationId, + requestedCount, + availableCount, + nowMilliseconds + ttlMilliseconds, + ttlMilliseconds, + CleanupLimit, + stateTtlMilliseconds + ]; + RedisResult result = await _database.ScriptEvaluateAsync(ReserveScript, keys, values); + return checked((int)(long)result); + } + + public Task ReleaseAsync( + string organizationId, + string reservationId, + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + ArgumentException.ThrowIfNullOrWhiteSpace(organizationId); + ArgumentException.ThrowIfNullOrWhiteSpace(reservationId); + + return _database.ScriptEvaluateAsync( + ReleaseScript, + GetKeys(organizationId), + [reservationId]); + } + + private RedisKey[] GetKeys(string organizationId) => GetKeys(_scopePrefix, organizationId); + + internal static RedisKey[] GetKeys(string scopePrefix, string organizationId) + { + // Outstanding work must survive bucket and finite plan-limit changes. Otherwise a caller + // admitted just before a boundary can disappear from the active total and a second caller + // can reuse the same monthly capacity. The organization hash tag keeps all atomic state in + // one Redis Cluster slot. + string keyPrefix = $"{scopePrefix}usage:reservations:v5:{{{organizationId}}}"; + return + [ + String.Concat(keyPrefix, ":leases"), + String.Concat(keyPrefix, ":expirations"), + String.Concat(keyPrefix, ":active") + ]; + } +} diff --git a/src/Exceptionless.Insulation/Redis/RedisIngestionStackUsageStore.cs b/src/Exceptionless.Insulation/Redis/RedisIngestionStackUsageStore.cs new file mode 100644 index 0000000000..1e2389b87b --- /dev/null +++ b/src/Exceptionless.Insulation/Redis/RedisIngestionStackUsageStore.cs @@ -0,0 +1,674 @@ +using System.Globalization; +using Exceptionless.Core; +using Exceptionless.Core.Services; +using StackExchange.Redis; + +namespace Exceptionless.Insulation.Redis; + +/// +/// Atomically deduplicates V3 statistics by event identity and drains stack aggregates through +/// leased, idempotent settlements. A settlement remains recoverable until Elasticsearch applies +/// it and the caller explicitly acknowledges it. +/// +public sealed class RedisIngestionStackUsageStore( + IConnectionMultiplexer connectionMultiplexer, + AppOptions options, + string? scope) : IIngestionStackUsageStore +{ + private const int RegistryShardCount = 16; + private const int MaximumProjectsPerClaim = 64; + private const string SettleScript = """ + local stateTtl = tonumber(ARGV[1]) + local aggregateTtl = tonumber(ARGV[2]) + local eventCount = #KEYS - 10 + local accepted = {} + local counts = {} + local minimums = {} + local maximums = {} + local hasAccepted = false + + for index = 1, eventCount do + local stateKey = KEYS[index + 10] + local state = tonumber(redis.call('GET', stateKey) or '0') + if bit.band(state, 1) == 0 then + local argumentIndex = 3 + ((index - 1) * 2) + local stackId = ARGV[argumentIndex] + local occurrence = tonumber(ARGV[argumentIndex + 1]) + accepted[index] = 1 + hasAccepted = true + counts[stackId] = (counts[stackId] or 0) + 1 + if not minimums[stackId] or occurrence < minimums[stackId] then + minimums[stackId] = occurrence + end + if not maximums[stackId] or occurrence > maximums[stackId] then + maximums[stackId] = occurrence + end + redis.call('SET', stateKey, bit.bor(state, 1), 'PX', stateTtl) + else + accepted[index] = 0 + end + end + + if hasAccepted then + for stackId, count in pairs(counts) do + redis.call('HINCRBY', KEYS[2], stackId, count) + if redis.call('HEXISTS', KEYS[6], stackId) == 0 then + redis.call('SADD', KEYS[1], stackId) + end + local currentMinimum = redis.call('HGET', KEYS[3], stackId) + if not currentMinimum or minimums[stackId] < tonumber(currentMinimum) then + redis.call('HSET', KEYS[3], stackId, minimums[stackId]) + end + local currentMaximum = redis.call('HGET', KEYS[4], stackId) + if not currentMaximum or maximums[stackId] > tonumber(currentMaximum) then + redis.call('HSET', KEYS[4], stackId, maximums[stackId]) + end + end + for keyIndex = 1, 10 do + redis.call('PEXPIRE', KEYS[keyIndex], aggregateTtl) + end + end + + return accepted + """; + + private const string ClaimRegistryScript = """ + local currentTime = redis.call('TIME') + local now = (tonumber(currentTime[1]) * 1000) + math.floor(tonumber(currentTime[2]) / 1000) + local leaseUntil = now + tonumber(ARGV[2]) + local members = redis.call('ZRANGEBYSCORE', KEYS[1], '-inf', now, 'LIMIT', 0, tonumber(ARGV[1])) + local result = {} + for _, member in ipairs(members) do + local leaseToken = redis.call('INCR', KEYS[4]) + redis.call('ZADD', KEYS[1], 'XX', leaseUntil, member) + redis.call('HDEL', KEYS[2], member) + redis.call('HSET', KEYS[3], member, leaseToken) + result[#result + 1] = member + result[#result + 1] = leaseToken + end + return result + """; + + private const string ClaimPendingScript = """ + local maximumCount = tonumber(ARGV[1]) + local leaseDuration = tonumber(ARGV[2]) + local aggregateTtl = tonumber(ARGV[3]) + local currentTime = redis.call('TIME') + local now = (tonumber(currentTime[1]) * 1000) + math.floor(tonumber(currentTime[2]) / 1000) + local leaseUntil = now + leaseDuration + local claims = {} + local claimCount = 0 + + local expired = redis.call('ZRANGEBYSCORE', KEYS[5], '-inf', now, 'LIMIT', 0, maximumCount) + for _, stackId in ipairs(expired) do + local sequence = redis.call('HGET', KEYS[6], stackId) + local count = redis.call('HGET', KEYS[7], stackId) + local minimum = redis.call('HGET', KEYS[8], stackId) + local maximum = redis.call('HGET', KEYS[9], stackId) + if sequence and count and minimum and maximum then + redis.call('ZADD', KEYS[5], leaseUntil, stackId) + claims[#claims + 1] = stackId + claims[#claims + 1] = sequence + claims[#claims + 1] = count + claims[#claims + 1] = minimum + claims[#claims + 1] = maximum + claimCount = claimCount + 1 + else + redis.call('ZREM', KEYS[5], stackId) + redis.call('HDEL', KEYS[6], stackId) + redis.call('HDEL', KEYS[7], stackId) + redis.call('HDEL', KEYS[8], stackId) + redis.call('HDEL', KEYS[9], stackId) + if redis.call('HEXISTS', KEYS[2], stackId) == 1 then + redis.call('SADD', KEYS[1], stackId) + end + end + end + + local remaining = maximumCount - claimCount + if remaining > 0 then + local pending = redis.call('SPOP', KEYS[1], remaining) + for _, stackId in ipairs(pending) do + local count = redis.call('HGET', KEYS[2], stackId) + local minimum = redis.call('HGET', KEYS[3], stackId) + local maximum = redis.call('HGET', KEYS[4], stackId) + if count and minimum and maximum and redis.call('HEXISTS', KEYS[6], stackId) == 0 then + local clockSequence = now * 1000 + local currentSequence = tonumber(redis.call('GET', KEYS[10]) or '0') + local sequence = currentSequence + 1 + if clockSequence > sequence then + sequence = clockSequence + end + local sequenceValue = string.format('%.0f', sequence) + redis.call('SET', KEYS[10], sequenceValue) + redis.call('HSET', KEYS[6], stackId, sequenceValue) + redis.call('HSET', KEYS[7], stackId, count) + redis.call('HSET', KEYS[8], stackId, minimum) + redis.call('HSET', KEYS[9], stackId, maximum) + redis.call('HDEL', KEYS[2], stackId) + redis.call('HDEL', KEYS[3], stackId) + redis.call('HDEL', KEYS[4], stackId) + redis.call('ZADD', KEYS[5], leaseUntil, stackId) + claims[#claims + 1] = stackId + claims[#claims + 1] = sequenceValue + claims[#claims + 1] = count + claims[#claims + 1] = minimum + claims[#claims + 1] = maximum + claimCount = claimCount + 1 + elseif count and minimum and maximum then + redis.call('SADD', KEYS[1], stackId) + else + redis.call('HDEL', KEYS[2], stackId) + redis.call('HDEL', KEYS[3], stackId) + redis.call('HDEL', KEYS[4], stackId) + end + end + end + + local pendingCount = redis.call('SCARD', KEYS[1]) + local inFlightCount = redis.call('ZCARD', KEYS[5]) + local nextDue = -1 + if pendingCount > 0 then + nextDue = now + elseif inFlightCount > 0 then + local nextClaim = redis.call('ZRANGE', KEYS[5], 0, 0, 'WITHSCORES') + if #nextClaim == 2 then + nextDue = tonumber(nextClaim[2]) + end + end + if pendingCount > 0 or inFlightCount > 0 then + for keyIndex = 1, 10 do + redis.call('PEXPIRE', KEYS[keyIndex], aggregateTtl) + end + end + + local result = { pendingCount, inFlightCount, nextDue, now } + for _, value in ipairs(claims) do + result[#result + 1] = value + end + return result + """; + + private const string AcknowledgeScript = """ + local aggregateTtl = tonumber(ARGV[1]) + local claimCount = (#ARGV - 1) / 2 + for index = 1, claimCount do + local argumentIndex = 2 + ((index - 1) * 2) + local stackId = ARGV[argumentIndex] + local sequence = ARGV[argumentIndex + 1] + local currentSequence = redis.call('HGET', KEYS[6], stackId) + if currentSequence and currentSequence == sequence then + redis.call('ZREM', KEYS[5], stackId) + redis.call('HDEL', KEYS[6], stackId) + redis.call('HDEL', KEYS[7], stackId) + redis.call('HDEL', KEYS[8], stackId) + redis.call('HDEL', KEYS[9], stackId) + if redis.call('HEXISTS', KEYS[2], stackId) == 1 then + redis.call('SADD', KEYS[1], stackId) + end + end + end + + local currentTime = redis.call('TIME') + local now = (tonumber(currentTime[1]) * 1000) + math.floor(tonumber(currentTime[2]) / 1000) + local pendingCount = redis.call('SCARD', KEYS[1]) + local inFlightCount = redis.call('ZCARD', KEYS[5]) + local nextDue = -1 + if pendingCount > 0 then + nextDue = now + elseif inFlightCount > 0 then + local nextClaim = redis.call('ZRANGE', KEYS[5], 0, 0, 'WITHSCORES') + if #nextClaim == 2 then + nextDue = tonumber(nextClaim[2]) + end + end + if pendingCount > 0 or inFlightCount > 0 then + for keyIndex = 1, 10 do + redis.call('PEXPIRE', KEYS[keyIndex], aggregateTtl) + end + end + return { pendingCount, inFlightCount, nextDue, now } + """; + + private const string ReserveRegistryScript = """ + local currentTime = redis.call('TIME') + local now = (tonumber(currentTime[1]) * 1000) + math.floor(tonumber(currentTime[2]) / 1000) + local current = redis.call('ZSCORE', KEYS[1], ARGV[1]) + local reservation = redis.call('HGET', KEYS[2], ARGV[1]) + if reservation then + return { reservation, current or reservation } + end + if current then + return { '', current } + end + local reservationUntil = now + tonumber(ARGV[2]) + local token = string.format('%.0f', reservationUntil) + redis.call('ZADD', KEYS[1], reservationUntil, ARGV[1]) + redis.call('HSET', KEYS[2], ARGV[1], token) + return { token, token } + """; + + private const string ActivateRegistryScript = """ + local member = ARGV[1] + local token = ARGV[2] + local reservation = redis.call('HGET', KEYS[2], member) + local currentTime = redis.call('TIME') + local now = (tonumber(currentTime[1]) * 1000) + math.floor(tonumber(currentTime[2]) / 1000) + if token ~= '' and reservation and reservation == token then + redis.call('HDEL', KEYS[2], member) + end + redis.call('HDEL', KEYS[3], member) + redis.call('ZADD', KEYS[1], now, member) + return 1 + """; + + private const string FinalizeRegistryScript = """ + local currentLease = redis.call('HGET', KEYS[3], ARGV[1]) + if not currentLease or currentLease ~= ARGV[2] then + return 0 + end + redis.call('HDEL', KEYS[3], ARGV[1]) + local nextDue = tonumber(ARGV[3]) + if nextDue < 0 then + redis.call('HDEL', KEYS[2], ARGV[1]) + return redis.call('ZREM', KEYS[1], ARGV[1]) + end + redis.call('ZADD', KEYS[1], nextDue, ARGV[1]) + return 1 + """; + + private readonly IDatabase _database = connectionMultiplexer.GetDatabase(); + private readonly string _scopePrefix = String.IsNullOrWhiteSpace(scope) ? String.Empty : String.Concat(scope.Trim(), ":"); + private readonly TimeSpan _claimLease = NormalizeClaimLease(options.EventIngestionV3.StackUsageClaimLease); + private readonly TimeSpan _aggregateExpiration = GetAggregateExpiration(options.EventIngestionV3); + private int _registryCursor = -1; + + public async Task> SettleAsync( + IReadOnlyCollection usages, + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + var normalized = IngestionStackUsageStore.Normalize(usages); + if (normalized.Count == 0) + { + return []; + } + + var first = normalized[0]; + if (normalized.Any(usage => !String.Equals(usage.ProjectId, first.ProjectId, StringComparison.Ordinal) + || !String.Equals(usage.OrganizationId, first.OrganizationId, StringComparison.Ordinal))) + { + throw new ArgumentException("An ingestion stack-statistics settlement must contain one project.", nameof(usages)); + } + + RegistryReservation reservation = await ReserveRegistryAsync(first.OrganizationId, first.ProjectId); + RedisKey[] keys = GetKeys(_scopePrefix, first.ProjectId, normalized.Select(usage => usage.EventId)); + var values = new RedisValue[2 + (normalized.Count * 2)]; + values[0] = GetExpirationMilliseconds(options.EventIngestionV3.IdempotencyWindow); + values[1] = GetExpirationMilliseconds(_aggregateExpiration); + for (int index = 0; index < normalized.Count; index++) + { + values[2 + (index * 2)] = normalized[index].StackId; + values[3 + (index * 2)] = ToUnixTimeMilliseconds(normalized[index].OccurrenceDateUtc); + } + + RedisResult result = await _database.ScriptEvaluateAsync(SettleScript, keys, values); + var resultValues = (RedisResult[]?)result ?? []; + if (resultValues.Length != normalized.Count) + { + throw new InvalidOperationException("Redis returned an invalid ingestion stack-statistics settlement result."); + } + + var newlySettled = new List(normalized.Count); + for (int index = 0; index < normalized.Count; index++) + { + if ((long)resultValues[index] == 1) + { + newlySettled.Add(normalized[index]); + } + } + // Always activate after the atomic settlement, including an idempotent retry that + // accepted no new events. A previous attempt may have committed the aggregate and + // stopped before activation while an older worker still owns a stale registry lease. + await ActivateRegistryAsync(reservation); + return IngestionStackUsageStore.Summarize(newlySettled); + } + + public async Task> ClaimPendingAsync( + int maximumCount, + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maximumCount); + + IReadOnlyList partitions = await ClaimRegistryPartitionsAsync( + Math.Min(maximumCount, MaximumProjectsPerClaim), + cancellationToken); + if (partitions.Count == 0) + { + return []; + } + + var claims = new List(Math.Min(maximumCount, partitions.Count)); + for (int partitionIndex = 0; partitionIndex < partitions.Count && claims.Count < maximumCount; partitionIndex++) + { + cancellationToken.ThrowIfCancellationRequested(); + var partition = partitions[partitionIndex]; + int remainingCapacity = maximumCount - claims.Count; + int remainingPartitions = partitions.Count - partitionIndex; + int partitionQuota = Math.Max(1, remainingCapacity / remainingPartitions); + RedisResult result = await _database.ScriptEvaluateAsync( + ClaimPendingScript, + GetAggregateKeys(_scopePrefix, partition.ProjectId), + [partitionQuota, GetExpirationMilliseconds(_claimLease), GetExpirationMilliseconds(_aggregateExpiration)]); + var values = (RedisResult[]?)result ?? []; + AggregateState state = ParseAggregateState(values, 4, "claim"); + if ((values.Length - 4) % 5 != 0) + { + throw new InvalidOperationException("Redis returned an invalid pending stack-statistics claim result."); + } + + for (int index = 4; index < values.Length; index += 5) + { + claims.Add(new StackUsageClaim( + partition.OrganizationId, + partition.ProjectId, + (string)values[index]!, + FromUnixTimeMilliseconds((long)values[index + 3]), + FromUnixTimeMilliseconds((long)values[index + 4]), + checked((int)(long)values[index + 2]), + (long)values[index + 1], + partition.LeaseToken)); + } + + if (values.Length == 4) + { + await FinalizeRegistryAsync(partition, state); + } + } + + return claims; + } + + public async Task AcknowledgeAsync( + IReadOnlyCollection claims, + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + ArgumentNullException.ThrowIfNull(claims); + if (claims.Count == 0) + { + return; + } + + foreach (var group in claims.GroupBy(claim => (claim.OrganizationId, claim.ProjectId, claim.LeaseToken))) + { + cancellationToken.ThrowIfCancellationRequested(); + StackUsageClaim[] projectClaims = group.ToArray(); + var values = new RedisValue[1 + (projectClaims.Length * 2)]; + values[0] = GetExpirationMilliseconds(_aggregateExpiration); + for (int index = 0; index < projectClaims.Length; index++) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(projectClaims[index].SettlementSequence); + values[1 + (index * 2)] = projectClaims[index].StackId; + values[2 + (index * 2)] = projectClaims[index].SettlementSequence; + } + + RedisResult result = await _database.ScriptEvaluateAsync( + AcknowledgeScript, + GetAggregateKeys(_scopePrefix, group.Key.ProjectId), + values); + AggregateState state = ParseAggregateState((RedisResult[]?)result ?? [], 4, "acknowledgement"); + await FinalizeRegistryAsync( + new RegistryPartition( + GetRegistryKey(_scopePrefix, group.Key.ProjectId), + GetRegistryReservationKey(_scopePrefix, group.Key.ProjectId), + GetRegistryLeaseKey(_scopePrefix, group.Key.ProjectId), + EncodeRegistryField(group.Key.OrganizationId, group.Key.ProjectId), + group.Key.OrganizationId, + group.Key.ProjectId, + group.Key.LeaseToken), + state); + } + } + + internal static RedisKey[] GetKeys(string scopePrefix, string projectId, IEnumerable eventIds) + { + return GetAggregateKeys(scopePrefix, projectId) + .Concat(eventIds.Select(eventId => (RedisKey)String.Concat(scopePrefix, IngestionStackUsageStore.GetStateKey(projectId, eventId)))) + .ToArray(); + } + + internal static RedisKey[] GetAggregateKeys(string scopePrefix, string projectId) + { + string prefix = String.Concat(scopePrefix, "ingest-v3:{", projectId, "}:stack-usage:"); + return + [ + String.Concat(prefix, "pending"), + String.Concat(prefix, "counts"), + String.Concat(prefix, "minimums"), + String.Concat(prefix, "maximums"), + String.Concat(prefix, "inflight-expirations"), + String.Concat(prefix, "inflight-sequences"), + String.Concat(prefix, "inflight-counts"), + String.Concat(prefix, "inflight-minimums"), + String.Concat(prefix, "inflight-maximums"), + String.Concat(prefix, "settlement-sequence") + ]; + } + + internal static RedisKey[] GetRegistryKeys(string scopePrefix) => + Enumerable.Range(0, RegistryShardCount) + .Select(shard => (RedisKey)$"{scopePrefix}ingest-v3:{{stack-usage-registry-{shard:D2}}}:projects") + .ToArray(); + + internal static RedisKey[] GetRegistryReservationKeys(string scopePrefix) => + GetRegistryKeys(scopePrefix) + .Select(key => (RedisKey)String.Concat(key.ToString(), ":reservations")) + .ToArray(); + + internal static RedisKey[] GetRegistryLeaseKeys(string scopePrefix) => + GetRegistryKeys(scopePrefix) + .Select(key => (RedisKey)String.Concat(key.ToString(), ":leases")) + .ToArray(); + + internal static RedisKey[] GetRegistryCounterKeys(string scopePrefix) => + GetRegistryKeys(scopePrefix) + .Select(key => (RedisKey)String.Concat(key.ToString(), ":counter")) + .ToArray(); + + private async Task> ClaimRegistryPartitionsAsync( + int maximumCount, + CancellationToken cancellationToken) + { + RedisKey[] registryKeys = GetRegistryKeys(_scopePrefix); + RedisKey[] reservationKeys = GetRegistryReservationKeys(_scopePrefix); + RedisKey[] leaseKeys = GetRegistryLeaseKeys(_scopePrefix); + RedisKey[] counterKeys = GetRegistryCounterKeys(_scopePrefix); + int startShard = (int)((uint)Interlocked.Increment(ref _registryCursor) % RegistryShardCount); + var partitions = new List(maximumCount); + for (int offset = 0; offset < RegistryShardCount && partitions.Count < maximumCount; offset++) + { + cancellationToken.ThrowIfCancellationRequested(); + int shard = (startShard + offset) % RegistryShardCount; + RedisKey registryKey = registryKeys[shard]; + RedisResult result = await _database.ScriptEvaluateAsync( + ClaimRegistryScript, + [registryKey, reservationKeys[shard], leaseKeys[shard], counterKeys[shard]], + [maximumCount - partitions.Count, GetExpirationMilliseconds(_claimLease)]); + var values = (RedisResult[]?)result ?? []; + if (values.Length % 2 != 0) + { + throw new InvalidOperationException("Redis returned an invalid stack-statistics registry claim result."); + } + + for (int index = 0; index < values.Length; index += 2) + { + partitions.Add(DecodeRegistryMember(registryKey, (string)values[index]!, (long)values[index + 1])); + } + } + return partitions; + } + + private async Task ReserveRegistryAsync(string organizationId, string projectId) + { + RedisKey registryKey = GetRegistryKey(_scopePrefix, projectId); + RedisKey reservationKey = GetRegistryReservationKey(_scopePrefix, projectId); + string member = EncodeRegistryField(organizationId, projectId); + RedisResult result = await _database.ScriptEvaluateAsync( + ReserveRegistryScript, + [registryKey, reservationKey], + [member, GetExpirationMilliseconds(_claimLease)]); + var values = (RedisResult[]?)result ?? []; + if (values.Length != 2) + { + throw new InvalidOperationException("Redis returned an invalid stack-statistics registry reservation result."); + } + + return new RegistryReservation(registryKey, reservationKey, member, (string?)values[0] ?? String.Empty); + } + + private Task ActivateRegistryAsync(RegistryReservation reservation) + { + return _database.ScriptEvaluateAsync( + ActivateRegistryScript, + [reservation.RegistryKey, reservation.ReservationKey, GetRegistryLeaseKey(reservation.RegistryKey)], + [reservation.RegistryMember, reservation.Token]); + } + + private Task FinalizeRegistryAsync(RegistryPartition partition, AggregateState state) + { + long nextDue = state.PendingCount > 0 ? state.Now : state.NextDue; + return _database.ScriptEvaluateAsync( + FinalizeRegistryScript, + [partition.RegistryKey, partition.ReservationKey, partition.LeaseKey], + [partition.RegistryMember, partition.LeaseToken, nextDue]); + } + + private static AggregateState ParseAggregateState(RedisResult[] values, int minimumLength, string operation) + { + if (values.Length < minimumLength) + { + throw new InvalidOperationException($"Redis returned an invalid stack-statistics {operation} result."); + } + + return new AggregateState( + (long)values[0], + (long)values[1], + (long)values[2], + (long)values[3]); + } + + private static RedisKey GetRegistryKey(string scopePrefix, string projectId) => + GetRegistryKeys(scopePrefix)[GetRegistryShard(projectId)]; + + private static RedisKey GetRegistryReservationKey(string scopePrefix, string projectId) => + GetRegistryReservationKeys(scopePrefix)[GetRegistryShard(projectId)]; + + private static RedisKey GetRegistryLeaseKey(string scopePrefix, string projectId) => + GetRegistryLeaseKeys(scopePrefix)[GetRegistryShard(projectId)]; + + private static RedisKey GetRegistryLeaseKey(RedisKey registryKey) => + String.Concat(registryKey.ToString(), ":leases"); + + private static int GetRegistryShard(string projectId) + { + uint hash = 2166136261; + foreach (char character in projectId) + { + hash = (hash ^ character) * 16777619; + } + + return (int)(hash % RegistryShardCount); + } + + private static string EncodeRegistryField(string organizationId, string projectId) => + String.Concat( + organizationId.Length.ToString(CultureInfo.InvariantCulture), ":", organizationId, + projectId.Length.ToString(CultureInfo.InvariantCulture), ":", projectId); + + private static RegistryPartition DecodeRegistryMember(RedisKey registryKey, string value, long leaseToken) + { + int offset = 0; + string organizationId = ReadPart(value, ref offset); + string projectId = ReadPart(value, ref offset); + if (offset != value.Length) + { + throw new InvalidOperationException("Redis returned an invalid stack-statistics registry member."); + } + + return new RegistryPartition( + registryKey, + String.Concat(registryKey.ToString(), ":reservations"), + GetRegistryLeaseKey(registryKey), + value, + organizationId, + projectId, + leaseToken); + } + + private static string ReadPart(string value, ref int offset) + { + int separator = value.IndexOf(':', offset); + if (separator < 0 + || !Int32.TryParse(value.AsSpan(offset, separator - offset), NumberStyles.None, CultureInfo.InvariantCulture, out int length) + || length < 1 + || separator + 1 + length > value.Length) + { + throw new InvalidOperationException("Redis returned an invalid stack-statistics registry member."); + } + offset = separator + 1; + string part = value.Substring(offset, length); + offset += length; + return part; + } + + private static TimeSpan NormalizeClaimLease(TimeSpan value) => value > TimeSpan.Zero ? value : TimeSpan.FromMinutes(1); + + private static TimeSpan GetAggregateExpiration(EventIngestionV3Options ingestionOptions) + { + TimeSpan idempotencyWindow = ingestionOptions.IdempotencyWindow > TimeSpan.Zero + ? ingestionOptions.IdempotencyWindow + : TimeSpan.FromDays(7); + TimeSpan claimLease = NormalizeClaimLease(ingestionOptions.StackUsageClaimLease); + long safetyTicks = checked(claimLease.Ticks + TimeSpan.FromDays(1).Ticks); + if (idempotencyWindow.Ticks > TimeSpan.MaxValue.Ticks - safetyTicks) + { + return TimeSpan.MaxValue; + } + + return idempotencyWindow.Add(TimeSpan.FromTicks(safetyTicks)); + } + + private static long GetExpirationMilliseconds(TimeSpan value) => Math.Max(1, checked((long)Math.Ceiling(value.TotalMilliseconds))); + + private static long ToUnixTimeMilliseconds(DateTime value) + { + DateTime utcValue = value.Kind switch + { + DateTimeKind.Utc => value, + DateTimeKind.Local => value.ToUniversalTime(), + _ => DateTime.SpecifyKind(value, DateTimeKind.Utc) + }; + return new DateTimeOffset(utcValue).ToUnixTimeMilliseconds(); + } + + private static DateTime FromUnixTimeMilliseconds(long value) => DateTimeOffset.FromUnixTimeMilliseconds(value).UtcDateTime; + + private sealed record RegistryPartition( + RedisKey RegistryKey, + RedisKey ReservationKey, + RedisKey LeaseKey, + string RegistryMember, + string OrganizationId, + string ProjectId, + long LeaseToken); + + private sealed record RegistryReservation( + RedisKey RegistryKey, + RedisKey ReservationKey, + string RegistryMember, + string Token); + + private readonly record struct AggregateState(long PendingCount, long InFlightCount, long NextDue, long Now); +} diff --git a/src/Exceptionless.Job/JobRunnerOptions.cs b/src/Exceptionless.Job/JobRunnerOptions.cs index a8faf591e8..86a2bf36a5 100644 --- a/src/Exceptionless.Job/JobRunnerOptions.cs +++ b/src/Exceptionless.Job/JobRunnerOptions.cs @@ -5,77 +5,121 @@ public class JobRunnerOptions public JobRunnerOptions(string[] args) { if (args.Length > 1) + { throw new ArgumentException("More than one job argument specified. You must either specify 1 named job or don't pass any arguments to run all jobs."); + } AllJobs = args.Length == 0; CleanupData = args.Length == 0 || args.Contains(nameof(CleanupData), StringComparer.OrdinalIgnoreCase); if (CleanupData && args.Length != 0) + { JobName = nameof(CleanupData); + } CleanupOrphanedData = args.Length == 0 || args.Contains(nameof(CleanupOrphanedData), StringComparer.OrdinalIgnoreCase); if (CleanupOrphanedData && args.Length != 0) + { JobName = nameof(CleanupOrphanedData); + } CloseInactiveSessions = args.Length == 0 || args.Contains(nameof(CloseInactiveSessions), StringComparer.OrdinalIgnoreCase); if (CloseInactiveSessions && args.Length != 0) + { JobName = nameof(CloseInactiveSessions); + } DailySummary = args.Length == 0 || args.Contains(nameof(DailySummary), StringComparer.OrdinalIgnoreCase); if (DailySummary && args.Length != 0) + { JobName = nameof(DailySummary); + } DataMigration = args.Contains(nameof(DataMigration), StringComparer.OrdinalIgnoreCase); if (DataMigration && args.Length != 0) + { JobName = nameof(DataMigration); + } DownloadGeoIPDatabase = args.Length == 0 || args.Contains(nameof(DownloadGeoIPDatabase), StringComparer.OrdinalIgnoreCase); if (DownloadGeoIPDatabase && args.Length != 0) + { JobName = nameof(DownloadGeoIPDatabase); + } EventNotifications = args.Length == 0 || args.Contains(nameof(EventNotifications), StringComparer.OrdinalIgnoreCase); if (EventNotifications && args.Length != 0) + { JobName = nameof(EventNotifications); + } EventPosts = args.Length == 0 || args.Contains(nameof(EventPosts), StringComparer.OrdinalIgnoreCase); if (EventPosts && args.Length != 0) + { JobName = nameof(EventPosts); + } EventUsage = args.Length == 0 || args.Contains(nameof(EventUsage), StringComparer.OrdinalIgnoreCase); if (EventUsage && args.Length != 0) + { JobName = nameof(EventUsage); + } EventUserDescriptions = args.Length == 0 || args.Contains(nameof(EventUserDescriptions), StringComparer.OrdinalIgnoreCase); if (EventUserDescriptions && args.Length != 0) + { JobName = nameof(EventUserDescriptions); + } MailMessage = args.Length == 0 || args.Contains(nameof(MailMessage), StringComparer.OrdinalIgnoreCase); if (MailMessage && args.Length != 0) + { JobName = nameof(MailMessage); + } MaintainIndexes = args.Length == 0 || args.Contains(nameof(MaintainIndexes), StringComparer.OrdinalIgnoreCase); if (MaintainIndexes && args.Length != 0) + { JobName = nameof(MaintainIndexes); + } Migration = args.Length == 0 || args.Contains(nameof(Migration), StringComparer.OrdinalIgnoreCase); if (Migration && args.Length != 0) + { JobName = nameof(Migration); + } StackStatus = args.Length == 0 || args.Contains(nameof(StackStatus), StringComparer.OrdinalIgnoreCase); if (StackStatus && args.Length != 0) + { JobName = nameof(StackStatus); + } StackEventCount = args.Length == 0 || args.Contains(nameof(StackEventCount), StringComparer.OrdinalIgnoreCase); if (StackEventCount && args.Length != 0) + { JobName = nameof(StackEventCount); + } + + IngestionStackEventCount = args.Length == 0 + || args.Contains(nameof(IngestionStackEventCount), StringComparer.OrdinalIgnoreCase) + || args.Contains(nameof(StackEventCount), StringComparer.OrdinalIgnoreCase); + if (args.Contains(nameof(IngestionStackEventCount), StringComparer.OrdinalIgnoreCase)) + { + JobName = nameof(IngestionStackEventCount); + } WebHooks = args.Length == 0 || args.Contains(nameof(WebHooks), StringComparer.OrdinalIgnoreCase); if (WebHooks && args.Length != 0) + { JobName = nameof(WebHooks); + } WorkItem = args.Length == 0 || args.Contains(nameof(WorkItem), StringComparer.OrdinalIgnoreCase); if (WorkItem && args.Length != 0) + { JobName = nameof(WorkItem); + } } public string JobName { get; } = "All"; @@ -95,6 +139,7 @@ public JobRunnerOptions(string[] args) public bool Migration { get; } public bool StackStatus { get; } public bool StackEventCount { get; } + public bool IngestionStackEventCount { get; } public bool WebHooks { get; } public bool WorkItem { get; } } diff --git a/src/Exceptionless.Job/Program.cs b/src/Exceptionless.Job/Program.cs index 2c04155463..2cb27543e5 100644 --- a/src/Exceptionless.Job/Program.cs +++ b/src/Exceptionless.Job/Program.cs @@ -36,7 +36,9 @@ public static async Task Main(string[] args) await ExceptionlessClient.Default.ProcessQueueAsync(); if (Debugger.IsAttached) + { Console.ReadKey(); + } } } @@ -77,7 +79,9 @@ public static IHostBuilder CreateHostBuilder(string[] args) c.Enrich.WithMachineName(); if (!String.IsNullOrEmpty(options.ExceptionlessApiKey)) + { c.WriteTo.Exceptionless(restrictedToMinimumLevel: LogEventLevel.Information); + } }, writeToProviders: true) .ConfigureWebHostDefaults(webBuilder => { @@ -91,7 +95,9 @@ public static IHostBuilder CreateHostBuilder(string[] args) o.GetLevel = new Func((context, duration, ex) => { if (ex is not null || context.Response.StatusCode > 499) + { return LogEventLevel.Error; + } return duration < 1000 && context.Response.StatusCode < 400 ? LogEventLevel.Debug : LogEventLevel.Information; }); @@ -100,7 +106,9 @@ public static IHostBuilder CreateHostBuilder(string[] args) Bootstrapper.LogConfiguration(app.ApplicationServices, options, app.ApplicationServices.GetRequiredService>()); if (!String.IsNullOrEmpty(options.ExceptionlessApiKey) && !String.IsNullOrEmpty(options.ExceptionlessServerUrl)) + { app.UseExceptionless(ExceptionlessClient.Default); + } app.UseHealthChecks("/health", new HealthCheckOptions { @@ -137,52 +145,113 @@ private static void AddJobs(IServiceCollection services, JobRunnerOptions option services.AddJobLifetimeService(); if (options is { CleanupData: true, AllJobs: true }) + { services.AddCronJob("30 */4 * * *"); + } + if (options is { CleanupData: true, AllJobs: false }) + { services.AddJob(); + } if (options is { CleanupOrphanedData: true, AllJobs: true }) + { services.AddCronJob("45 */8 * * *"); + } + if (options is { CleanupOrphanedData: true, AllJobs: false }) + { services.AddJob(); + } if (options.CloseInactiveSessions) + { services.AddJob(o => o.WaitForStartupActions()); + } + if (options.DailySummary) + { services.AddJob(o => o.WaitForStartupActions()); + } + if (options.DataMigration) + { services.AddJob(o => o.WaitForStartupActions()); + } if (options is { DownloadGeoIPDatabase: true, AllJobs: true }) + { services.AddCronJob("0 1 * * *"); + } + if (options is { DownloadGeoIPDatabase: true, AllJobs: false }) + { services.AddJob(o => o.WaitForStartupActions()); + } if (options.EventNotifications) + { services.AddJob(o => o.WaitForStartupActions()); + } + if (options.EventPosts) + { services.AddJob(o => o.WaitForStartupActions()); + } + if (options.EventUsage) + { services.AddJob(o => o.WaitForStartupActions()); + } + if (options.EventUserDescriptions) + { services.AddJob(o => o.WaitForStartupActions()); + } + if (options.MailMessage) + { services.AddJob(o => o.WaitForStartupActions()); + } if (options is { MaintainIndexes: true, AllJobs: true }) + { services.AddCronJob("10 */2 * * *"); + } + if (options is { MaintainIndexes: true, AllJobs: false }) + { services.AddJob(); + } if (options.Migration) + { services.AddJob(o => o.WaitForStartupActions()); + } + if (options.StackStatus) + { services.AddJob(o => o.WaitForStartupActions()); + } + if (options.StackEventCount) + { services.AddJob(o => o.WaitForStartupActions()); + } + + if (options.IngestionStackEventCount) + { + services.AddJob(o => o.WaitForStartupActions()); + } + if (options.WebHooks) + { services.AddJob(o => o.WaitForStartupActions()); + } + if (options.WorkItem) + { services.AddJob(o => o.WaitForStartupActions()); + } } } diff --git a/src/Exceptionless.Web/Api/Endpoints/EventEndpoints.cs b/src/Exceptionless.Web/Api/Endpoints/EventEndpoints.cs index a45cc56a4e..34b2515367 100644 --- a/src/Exceptionless.Web/Api/Endpoints/EventEndpoints.cs +++ b/src/Exceptionless.Web/Api/Endpoints/EventEndpoints.cs @@ -805,7 +805,7 @@ Exceptionless is really amazing!``` """) .WithMetadata(new RequestBodyContentAttribute("application/json", "text/plain")) .WithMetadata(new EndpointDocumentation { - AdditionalParameters = EventEndpointHelpers.PostUserAgentParameter, + AdditionalParameters = EventEndpointHelpers.PostV2AdditionalParameters, ParameterDescriptions = new() { ["userAgent"] = "The user agent that submitted the event.", }, @@ -845,7 +845,7 @@ Exceptionless is really amazing!``` """) .WithMetadata(new RequestBodyContentAttribute("application/json", "text/plain")) .WithMetadata(new EndpointDocumentation { - AdditionalParameters = EventEndpointHelpers.PostUserAgentParameter, + AdditionalParameters = EventEndpointHelpers.PostV2AdditionalParameters, ParameterDescriptions = new() { ["projectId"] = "The identifier of the project.", ["userAgent"] = "The user agent that submitted the event.", @@ -890,7 +890,10 @@ private static async Task SubmitEventByPostAsync(string? projectId, if (httpContext.Request.ContentLength is <= 0) return Microsoft.AspNetCore.Http.Results.StatusCode(StatusCodes.Status202Accepted); - return (await mediator.InvokeAsync(new SubmitEventByPost(projectId, apiVersion, httpContext.Request.GetClientUserAgent(), httpContext))).ToHttpResult(resultMapper); + bool trackProcessing = apiVersion == 2 + && Boolean.TryParse(httpContext.Request.Headers[Headers.TrackEventPost], out bool parsedTrackProcessing) + && parsedTrackProcessing; + return (await mediator.InvokeAsync(new SubmitEventByPost(projectId, apiVersion, httpContext.Request.GetClientUserAgent(), trackProcessing, httpContext))).ToHttpResult(resultMapper); } } @@ -923,4 +926,10 @@ internal static class EventEndpointHelpers [ new("userAgent", "header", Description: "The user agent that submitted the event."), ]; + + public static readonly List PostV2AdditionalParameters = + [ + .. PostUserAgentParameter, + new(Headers.TrackEventPost, "header", Description: "Whether to return an event-post identifier that can be polled for terminal processing.", Type: "boolean"), + ]; } diff --git a/src/Exceptionless.Web/Api/Handlers/EventHandler.cs b/src/Exceptionless.Web/Api/Handlers/EventHandler.cs index eb77c82c92..66027c1016 100644 --- a/src/Exceptionless.Web/Api/Handlers/EventHandler.cs +++ b/src/Exceptionless.Web/Api/Handlers/EventHandler.cs @@ -555,6 +555,7 @@ await eventPostService.EnqueueAsync(new EventPost(appOptions.EnableArchive) public async Task Handle(SubmitEventByPost message) { var httpContext = message.Context; + bool trackProcessing = message.TrackProcessing && appOptions.EventIngestionV3.EnableProcessingStatus; string? claimProjectId = httpContext.Request.GetProjectId(); if (message.ProjectId is not null && claimProjectId is not null && !String.Equals(message.ProjectId, claimProjectId)) { @@ -601,6 +602,7 @@ public async Task Handle(SubmitEventByPost message) MediaType = mediaType, OrganizationId = project.OrganizationId, ProjectId = project.Id, + TrackProcessing = trackProcessing, UserAgent = message.UserAgent, }, requestBody, httpContext.RequestAborted); @@ -614,6 +616,11 @@ public async Task Handle(SubmitEventByPost message) return Result.BadRequest(result.RejectionReason ?? "Request body was rejected."); } + + if (trackProcessing && result.QueueEntryId is { Length: > 0 } queueEntryId) + { + httpContext.Response.Headers[Headers.EventPostId] = queueEntryId; + } } catch (Exception ex) { diff --git a/src/Exceptionless.Web/Api/Handlers/StackHandler.cs b/src/Exceptionless.Web/Api/Handlers/StackHandler.cs index b61206d1d1..403b2f58bf 100644 --- a/src/Exceptionless.Web/Api/Handlers/StackHandler.cs +++ b/src/Exceptionless.Web/Api/Handlers/StackHandler.cs @@ -3,6 +3,7 @@ using Exceptionless.Core.Authorization; using Exceptionless.Core.Extensions; using Exceptionless.Core.Models; +using Exceptionless.Core.Models.Ingestion; using Exceptionless.Core.Plugins.Formatting; using Exceptionless.Core.Plugins.WebHook; using Exceptionless.Core.Queries.Validation; @@ -11,6 +12,7 @@ using Exceptionless.Core.Repositories.Configuration; using Exceptionless.Core.Repositories.Queries; using Exceptionless.Core.Utility; +using Exceptionless.Core.Services; using Exceptionless.DateTimeExtensions; using Exceptionless.Web.Api.Infrastructure; using Exceptionless.Web.Api.Results; @@ -40,6 +42,7 @@ public class StackHandler( FormattingPluginManager formattingPluginManager, SemanticVersionParser semanticVersionParser, StackQueryValidator validator, + IStackRouteResolver stackRouteResolver, AppOptions options, TimeProvider timeProvider, ILoggerFactory loggerFactory) @@ -77,7 +80,7 @@ public async Task Handle(MarkStacksFixed message) foreach (var stack in stacks) stack.MarkFixed(semanticVersion, timeProvider); - await stackRepository.SaveAsync(stacks); + await SaveStacksAsync(stacks); return Result.Success(); } @@ -117,7 +120,7 @@ public async Task Handle(SnoozeStacks message) stack.DateFixed = null; } - await stackRepository.SaveAsync(stacks); + await SaveStacksAsync(stacks); return Result.Success(); } @@ -134,7 +137,7 @@ public async Task Handle(AddStackLink message) if (!stack.References.Contains(message.Url.Value.Trim())) { stack.References.Add(message.Url.Value.Trim()); - await stackRepository.SaveAsync(stack); + await SaveStacksAsync([stack]); } return Result.Success(); @@ -171,7 +174,7 @@ public async Task Handle(RemoveStackLink message) if (stack.References.Contains(message.Url.Value.Trim())) { stack.References.Remove(message.Url.Value.Trim()); - await stackRepository.SaveAsync(stack); + await SaveStacksAsync([stack]); } return Result.NoContent(); @@ -189,7 +192,7 @@ public async Task Handle(MarkStacksCritical message) foreach (var stack in stacks) stack.OccurrencesAreCritical = true; - await stackRepository.SaveAsync(stacks); + await SaveStacksAsync(stacks); } return Result.Success(); @@ -207,7 +210,7 @@ public async Task Handle(MarkStacksNotCritical message) foreach (var stack in stacks) stack.OccurrencesAreCritical = false; - await stackRepository.SaveAsync(stacks); + await SaveStacksAsync(stacks); } return Result.NoContent(); @@ -241,7 +244,7 @@ public async Task Handle(ChangeStacksStatus message) stack.SnoozeUntilUtc = null; } - await stackRepository.SaveAsync(stacks); + await SaveStacksAsync(stacks); } return Result.Success(); @@ -512,6 +515,14 @@ private async Task> GetUserCountByProjectIdsAsync(ICo return totals; } + private async Task SaveStacksAsync(ICollection stacks) + { + await stackRepository.SaveAsync(stacks); + await Task.WhenAll(stacks + .Where(stack => !String.IsNullOrEmpty(stack.ProjectId) && !String.IsNullOrEmpty(stack.SignatureHash)) + .Select(stack => stackRouteResolver.UpdateAsync(stack.ProjectId, stack.SignatureHash, StackRouteResolver.CreateRoute(stack)))); + } + private async Task GetModelAsync(string id, HttpContext httpContext, bool useCache = true) { if (String.IsNullOrEmpty(id)) diff --git a/src/Exceptionless.Web/Api/Messages/EventMessages.cs b/src/Exceptionless.Web/Api/Messages/EventMessages.cs index 6c1ac3bb95..59004e113f 100644 --- a/src/Exceptionless.Web/Api/Messages/EventMessages.cs +++ b/src/Exceptionless.Web/Api/Messages/EventMessages.cs @@ -36,7 +36,7 @@ public record RecordEventHeartbeat(string? Id, bool Close, HttpContext Context); public record SubmitEventByGet(string? ProjectId, int ApiVersion, string? Type, string? UserAgent, HttpContext Context); // Submit via POST -public record SubmitEventByPost(string? ProjectId, int ApiVersion, string? UserAgent, HttpContext Context); +public record SubmitEventByPost(string? ProjectId, int ApiVersion, string? UserAgent, bool TrackProcessing, HttpContext Context); // Delete public record DeleteEvents(string Ids, HttpContext Context); diff --git a/src/Exceptionless.Web/Endpoints/EventIngestionV3Endpoints.cs b/src/Exceptionless.Web/Endpoints/EventIngestionV3Endpoints.cs new file mode 100644 index 0000000000..f20ced9b47 --- /dev/null +++ b/src/Exceptionless.Web/Endpoints/EventIngestionV3Endpoints.cs @@ -0,0 +1,453 @@ +using System.IO.Compression; +using System.Text.Json; +using System.Threading.RateLimiting; +using Exceptionless.Core; +using Exceptionless.Core.Authorization; +using Exceptionless.Core.Models; +using Exceptionless.Core.Models.Ingestion; +using Exceptionless.Core.Repositories; +using Exceptionless.Core.Serialization; +using Exceptionless.Core.Services; +using Exceptionless.Web.Extensions; +using Exceptionless.Web.Models; +using Exceptionless.Web.Utility; +using Exceptionless.Web.Utility.Handlers; +using Foundatio.Repositories; +using Foundatio.Repositories.Exceptions; +using Microsoft.AspNetCore.Http.Features; +using Microsoft.AspNetCore.Http.Timeouts; +using Microsoft.AspNetCore.OpenApi; +using Microsoft.Net.Http.Headers; +using Microsoft.OpenApi; + +namespace Exceptionless.Web.Endpoints; + +public static class EventIngestionV3Endpoints +{ + private const string ContentType = "application/x-ndjson"; + + public static IEndpointRouteBuilder MapEventIngestionV3(this IEndpointRouteBuilder endpoints, AppOptions options) + { + var group = endpoints.MapGroup("/api/v3") + .RequireAuthorization(AuthorizationRoles.ClientPolicy) + .WithTags("Event Ingestion V3"); + + Map(group.MapPost("/events", HandleDefaultProjectAsync), options) + .WithName("PostEventsV3"); + Map(group.MapPost("/projects/{projectId:objectid}/events", HandleProjectAsync), options) + .WithName("PostEventsByProjectV3"); + group.MapPost("/projects/{projectId:objectid}/events/processing/status", GetProcessingStatusAsync) + .WithName("GetEventIngestionProcessingStatusV3") + .ExcludeFromDescription() + .WithSummary("Get full processing status for V3 events.") + .WithDescription("Returns benchmark-oriented completion status for client event ids while terminal side-effect markers remain available.") + .Accepts("application/json") + .Produces() + .ProducesProblem(StatusCodes.Status422UnprocessableEntity) + .Produces(StatusCodes.Status404NotFound) + .AddOpenApiOperationTransformer(AddBearerSecurityAsync); + + return endpoints; + } + + private static async Task GetProcessingStatusAsync( + string projectId, + EventIngestionV3ProcessingStatusRequest statusRequest, + HttpRequest request, + IEventIngestionIdStore eventIngestionIdStore, + IngestionSideEffectExecutor sideEffectExecutor, + AppOptions options, + CancellationToken cancellationToken) + { + if (!options.EventIngestionV3.EnableProcessingStatus) + { + return Results.NotFound(); + } + + string? claimProjectId = request.GetProjectId(); + if (claimProjectId is null || !String.Equals(projectId, claimProjectId, StringComparison.Ordinal)) + { + return Results.NotFound(); + } + + if (statusRequest.ClientIds is not { Count: >= 1 and <= 1000 }) + { + return InvalidProcessingIdentifiers("Between 1 and 1000 client event ids are required."); + } + + string[] clientIds = statusRequest.ClientIds.Distinct(StringComparer.Ordinal).ToArray(); + if (clientIds.Any(id => String.IsNullOrWhiteSpace(id) || id.Length > EventIngestionV3Limits.MaximumEventIdLength)) + { + return InvalidProcessingIdentifiers($"Client event ids must contain between 1 and {EventIngestionV3Limits.MaximumEventIdLength} characters."); + } + + var assignedIds = await eventIngestionIdStore.GetAsync(projectId, clientIds, cancellationToken); + string[] eventIds = assignedIds.Values + .Select(identity => identity.EventId) + .ToArray(); + var completed = await sideEffectExecutor.GetCompletedIdentitiesAsync(IngestionSideEffectExecutor.TerminalStage, projectId, eventIds); + return Results.Ok(new EventIngestionV3ProcessingSummary(clientIds.Length, clientIds.Length - completed.Count, completed.Count)); + } + + private static IResult InvalidProcessingIdentifiers(string detail) + { + return Results.Problem( + detail, + statusCode: StatusCodes.Status422UnprocessableEntity, + title: "Invalid client event ids"); + } + + private static RouteHandlerBuilder Map(RouteHandlerBuilder builder, AppOptions options) + { + builder + .WithMetadata(EventIngestionV3EndpointMetadata.Instance) + .Accepts(ContentType) + .Produces(StatusCodes.Status200OK, "application/json") + .ProducesProblem(StatusCodes.Status400BadRequest) + .ProducesProblem(StatusCodes.Status401Unauthorized) + .ProducesProblem(StatusCodes.Status403Forbidden) + .ProducesProblem(StatusCodes.Status402PaymentRequired) + .ProducesProblem(StatusCodes.Status404NotFound) + .ProducesProblem(StatusCodes.Status413RequestEntityTooLarge) + .ProducesProblem(StatusCodes.Status415UnsupportedMediaType) + .ProducesProblem(StatusCodes.Status422UnprocessableEntity) + .ProducesProblem(StatusCodes.Status429TooManyRequests) + .ProducesProblem(StatusCodes.Status503ServiceUnavailable) + .WithRequestTimeout(new RequestTimeoutPolicy + { + Timeout = options.EventIngestionV3.RequestTimeout, + TimeoutStatusCode = StatusCodes.Status503ServiceUnavailable + }) + .AddOpenApiOperationTransformer(AddBearerSecurityAsync); + + return builder; + } + + private static Task AddBearerSecurityAsync( + OpenApiOperation operation, + OpenApiOperationTransformerContext context, + CancellationToken cancellationToken) + { + operation.Security ??= []; + operation.Security.Add(new OpenApiSecurityRequirement + { + [new OpenApiSecuritySchemeReference("Bearer", context.Document, null)] = [] + }); + return Task.CompletedTask; + } + + private static Task HandleDefaultProjectAsync( + HttpRequest request, + EventIngestionV3Processor processor, + EventIngestionV3ConcurrencyLimiter concurrencyLimiter, + IProjectRepository projectRepository, + IOrganizationRepository organizationRepository, + AppOptions options, + CancellationToken cancellationToken) => + HandleAsync(request, null, processor, concurrencyLimiter, projectRepository, organizationRepository, options, cancellationToken); + + private static Task HandleProjectAsync( + HttpRequest request, + string projectId, + EventIngestionV3Processor processor, + EventIngestionV3ConcurrencyLimiter concurrencyLimiter, + IProjectRepository projectRepository, + IOrganizationRepository organizationRepository, + AppOptions options, + CancellationToken cancellationToken) => + HandleAsync(request, projectId, processor, concurrencyLimiter, projectRepository, organizationRepository, options, cancellationToken); + + private static async Task HandleAsync( + HttpRequest request, + string? projectId, + EventIngestionV3Processor processor, + EventIngestionV3ConcurrencyLimiter concurrencyLimiter, + IProjectRepository projectRepository, + IOrganizationRepository organizationRepository, + AppOptions options, + CancellationToken cancellationToken) + { + if (!options.EventIngestionV3.Enabled || options.EventSubmissionDisabled) + { + return Results.Problem(statusCode: StatusCodes.Status503ServiceUnavailable, title: "Event ingestion is unavailable."); + } + + if (!MediaTypeHeaderValue.TryParse(request.ContentType, out MediaTypeHeaderValue? mediaType) + || !String.Equals(mediaType.MediaType.Value, ContentType, StringComparison.OrdinalIgnoreCase)) + { + return Results.Problem(statusCode: StatusCodes.Status415UnsupportedMediaType, title: $"Content-Type must be {ContentType}."); + } + + string[] contentEncodings = request.Headers.ContentEncoding + .SelectMany(value => value?.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) ?? []) + .ToArray(); + if (contentEncodings.Length > 1) + { + return Results.Problem(statusCode: StatusCodes.Status415UnsupportedMediaType, title: "Only one Content-Encoding may be specified."); + } + + string? contentEncoding = contentEncodings.FirstOrDefault(); + if (!String.IsNullOrEmpty(contentEncoding) + && !String.Equals(contentEncoding, "identity", StringComparison.OrdinalIgnoreCase) + && !String.Equals(contentEncoding, "gzip", StringComparison.OrdinalIgnoreCase) + && !String.Equals(contentEncoding, "br", StringComparison.OrdinalIgnoreCase)) + { + return Results.Problem(statusCode: StatusCodes.Status415UnsupportedMediaType, title: "Content-Encoding must be gzip, br, or identity."); + } + + string? claimProjectId = request.GetProjectId(); + if (projectId is not null && claimProjectId is not null && !String.Equals(projectId, claimProjectId, StringComparison.Ordinal)) + { + return Results.NotFound(); + } + + projectId ??= claimProjectId ?? request.GetDefaultProjectId(); + if (String.IsNullOrEmpty(projectId)) + { + return Results.Problem(statusCode: StatusCodes.Status400BadRequest, title: "No project was specified and no default project was found."); + } + + var project = await projectRepository.GetByIdAsync(projectId, o => o.Cache()); + if (project is null || !request.CanAccessOrganization(project.OrganizationId)) + { + return Results.NotFound(); + } + + if (options.EventIngestionV3.AllowedProjectIds.Count > 0 && !options.EventIngestionV3.AllowedProjectIds.Contains(project.Id)) + { + return Results.NotFound(); + } + + if (options.EventIngestionV3.AllowedOrganizationIds.Count > 0 && !options.EventIngestionV3.AllowedOrganizationIds.Contains(project.OrganizationId)) + { + return Results.NotFound(); + } + + var organization = await organizationRepository.GetByIdAsync(project.OrganizationId, o => o.Cache()); + if (organization is null) + { + return Results.NotFound(); + } + + if (organization.IsSuspended) + { + return Results.Problem(statusCode: StatusCodes.Status402PaymentRequired, title: "The organization cannot accept events."); + } + + using RateLimitLease organizationStreamLease = await concurrencyLimiter.AcquireOrganizationActiveStreamAsync(organization.Id, cancellationToken); + if (!organizationStreamLease.IsAcquired) + { + return Results.Problem( + statusCode: StatusCodes.Status429TooManyRequests, + title: "Event ingestion stream capacity is busy."); + } + + request.SetProject(project); + var compressedBodyState = request.HttpContext.Features.Get(); + var limitedBody = new EventPostRequestBodyStream( + request.Body, + options.EventIngestionV3.MaximumDecompressedBodySize, + "The decompressed request body is too large.", + StatusCodes.Status400BadRequest, + "The compressed request body is invalid."); + request.Body = limitedBody; + + var response = new EventIngestionV3Response(); + var batch = new List(options.EventIngestionV3.MicroBatchSize); + long batchBytes = 0; + int received = 0; + long maximumRecordSize = Math.Min(options.EventIngestionV3.MaximumEventSize, options.EventIngestionV3.MaximumMicroBatchBytes); + + using var activity = AppDiagnostics.StartActivity("Ingestion V3 Request"); + if (request.ContentLength.HasValue) + { + AppDiagnostics.IngestionV3CompressedSize.Record(request.ContentLength.Value); + } + + AppDiagnostics.IngestionV3ActiveStreams.Add(1); + try + { + while (await EventIngestionV3StreamReader.ReadAsync(request.BodyReader, maximumRecordSize, cancellationToken) is { } record) + { + EventIngestionV3BufferedRecord bufferedRecord = record.BufferedRecord; + bool addedToBatch = false; + try + { + received++; + if (received > options.EventIngestionV3.MaximumEventsPerRequest) + { + return Problem(response, StatusCodes.Status413RequestEntityTooLarge, "The request contains too many events."); + } + + long eventSize = record.Size; + if (batch.Count > 0 && batchBytes + eventSize > options.EventIngestionV3.MaximumMicroBatchBytes) + { + response.Add(await ProcessBatchAsync(processor, concurrencyLimiter, batch, organization, project, cancellationToken)); + batchBytes = 0; + } + + batch.Add(bufferedRecord); + addedToBatch = true; + batchBytes += eventSize; + if (batch.Count < options.EventIngestionV3.MicroBatchSize) + { + continue; + } + + response.Add(await ProcessBatchAsync(processor, concurrencyLimiter, batch, organization, project, cancellationToken)); + batchBytes = 0; + } + finally + { + if (!addedToBatch) + { + bufferedRecord.Dispose(); + } + } + } + + if (GetBodyRejection(limitedBody, compressedBodyState) is { } rejection) + { + return Problem(response, rejection.StatusCode, rejection.Reason); + } + + if (batch.Count > 0) + { + response.Add(await ProcessBatchAsync(processor, concurrencyLimiter, batch, organization, project, cancellationToken)); + } + } + catch (EventIngestionV3RecordTooLargeException) + { + return Problem(response, StatusCodes.Status413RequestEntityTooLarge, "An event exceeds the maximum event size."); + } + catch (JsonException) when (GetBodyRejection(limitedBody, compressedBodyState) is not null) + { + BodyRejection rejection = GetBodyRejection(limitedBody, compressedBodyState)!; + return Problem(response, rejection.StatusCode, rejection.Reason); + } + catch (JsonException ex) + { + AppDiagnostics.IngestionV3Failures.Add(1); + return Problem(response, StatusCodes.Status400BadRequest, "The event stream contains invalid JSON.", ex.Message); + } + catch (InvalidDataException) when (GetBodyRejection(limitedBody, compressedBodyState) is not null) + { + BodyRejection rejection = GetBodyRejection(limitedBody, compressedBodyState)!; + return Problem(response, rejection.StatusCode, rejection.Reason); + } + catch (InvalidDataException ex) + { + AppDiagnostics.IngestionV3Failures.Add(1); + return Problem(response, StatusCodes.Status400BadRequest, "The compressed request body is invalid.", ex.Message); + } + catch (ProcessingConcurrencyRejectedException) + { + return Problem(response, StatusCodes.Status429TooManyRequests, "Event ingestion processing capacity is busy."); + } + catch (EventBatchWriteException) + { + AppDiagnostics.IngestionV3Failures.Add(1); + return Problem(response, StatusCodes.Status503ServiceUnavailable, "Durable event processing is unavailable."); + } + catch (RepositoryException) + { + AppDiagnostics.IngestionV3Failures.Add(1); + return Problem(response, StatusCodes.Status503ServiceUnavailable, "Durable event storage is unavailable."); + } + catch (Exception) + { + AppDiagnostics.IngestionV3Failures.Add(1); + throw; + } + finally + { + DisposeBatch(batch); + AppDiagnostics.IngestionV3DecompressedSize.Record(limitedBody.BytesRead); + AppDiagnostics.IngestionV3ActiveStreams.Add(-1); + } + + if (response.Received > 0 && response.Invalid == response.Received) + { + return Problem(response, StatusCodes.Status422UnprocessableEntity, "The stream did not contain any valid event records."); + } + + return Results.Json(response, EventIngestionJsonContext.Default.EventIngestionV3Response); + } + + private static async Task ProcessBatchAsync( + EventIngestionV3Processor processor, + EventIngestionV3ConcurrencyLimiter concurrencyLimiter, + List batch, + Organization organization, + Project project, + CancellationToken cancellationToken) + { + try + { + using RateLimitLease lease = await concurrencyLimiter.AcquireProcessingAsync(organization.Id, cancellationToken); + if (!lease.IsAcquired) + { + throw new ProcessingConcurrencyRejectedException(); + } + + return await processor.ProcessBufferedAsync(batch, organization, project, cancellationToken); + } + finally + { + DisposeBatch(batch); + } + } + + private static void DisposeBatch(List batch) + { + foreach (EventIngestionV3BufferedRecord record in batch) + { + record.Dispose(); + } + + batch.Clear(); + } + + private static BodyRejection? GetBodyRejection( + EventPostRequestBodyStream decompressedBody, + EventIngestionV3RequestBodyState? compressedBodyState) + { + if (decompressedBody.RejectedStatusCode is { } decompressedStatusCode) + { + return new BodyRejection(decompressedStatusCode, decompressedBody.RejectionReason); + } + + if (compressedBodyState?.CompressedBody.RejectedStatusCode is { } compressedStatusCode) + { + return new BodyRejection(compressedStatusCode, compressedBodyState.CompressedBody.RejectionReason); + } + + return null; + } + + private static IResult Problem(EventIngestionV3Response response, int statusCode, string? title, string? detail = null) + { + Dictionary? extensions = null; + if (response.Received > 0) + { + extensions = new Dictionary + { + ["partial_result"] = response, + ["retry_guidance"] = "Some earlier events were processed. Retry the complete request; event ids make replay idempotent." + }; + } + + return Results.Problem(statusCode: statusCode, title: title, detail: detail, extensions: extensions); + } + + private sealed record BodyRejection(int StatusCode, string? Reason); + + private sealed class ProcessingConcurrencyRejectedException : Exception { } +} + +internal sealed class EventIngestionV3EndpointMetadata +{ + public static EventIngestionV3EndpointMetadata Instance { get; } = new(); + + private EventIngestionV3EndpointMetadata() { } +} diff --git a/src/Exceptionless.Web/Endpoints/EventPostProcessingEndpoints.cs b/src/Exceptionless.Web/Endpoints/EventPostProcessingEndpoints.cs new file mode 100644 index 0000000000..c99fa6d78f --- /dev/null +++ b/src/Exceptionless.Web/Endpoints/EventPostProcessingEndpoints.cs @@ -0,0 +1,78 @@ +using Exceptionless.Core; +using Exceptionless.Core.Authorization; +using Exceptionless.Core.Services; +using Exceptionless.Web.Extensions; +using Exceptionless.Web.Models; +using Microsoft.OpenApi; + +namespace Exceptionless.Web.Endpoints; + +public static class EventPostProcessingEndpoints +{ + public static void MapEventPostProcessing(this IEndpointRouteBuilder endpoints) + { + endpoints.MapPost("api/v2/projects/{projectId:objectid}/events/posts/status", GetStatusesAsync) + .WithName("GetEventPostProcessingStatusesV2") + .ExcludeFromDescription() + .WithTags("Event") + .WithSummary("Get aggregate processing status for tracked V2 event posts.") + .WithDescription("Returns terminal processing status for up to 1000 event-post identifiers returned in X-Exceptionless-Event-Post-Id headers.") + .Accepts("application/json") + .Produces() + .ProducesProblem(StatusCodes.Status422UnprocessableEntity) + .Produces(StatusCodes.Status404NotFound) + .RequireAuthorization(AuthorizationRoles.ClientPolicy) + .AddOpenApiOperationTransformer((operation, context, _) => + { + operation.Security ??= []; + operation.Security.Add(new OpenApiSecurityRequirement + { + [new OpenApiSecuritySchemeReference("Bearer", context.Document, null)] = [] + }); + return Task.CompletedTask; + }); + } + + private static async Task GetStatusesAsync( + string projectId, + EventPostProcessingStatusRequest request, + HttpRequest httpRequest, + EventPostService eventPostService, + AppOptions options) + { + if (!options.EventIngestionV3.EnableProcessingStatus) + { + return TypedResults.NotFound(); + } + + string? claimProjectId = httpRequest.GetProjectId(); + if (claimProjectId is null || !String.Equals(projectId, claimProjectId, StringComparison.Ordinal)) + { + return TypedResults.NotFound(); + } + + if (request.Ids is not { Count: >= 1 and <= 1000 }) + { + return InvalidIdentifiers("Between 1 and 1000 event-post identifiers are required."); + } + + string[] ids = request.Ids.Distinct(StringComparer.Ordinal).ToArray(); + if (ids.Any(id => String.IsNullOrWhiteSpace(id) || id.Length > 256)) + { + return InvalidIdentifiers("Event-post identifiers must contain between 1 and 256 characters."); + } + + var statuses = await eventPostService.GetProcessingStatusesAsync(ids); + int completed = statuses.Count(pair => String.Equals(pair.Value.ProjectId, projectId, StringComparison.Ordinal) && pair.Value.IsCompleted); + int queued = statuses.Count(pair => String.Equals(pair.Value.ProjectId, projectId, StringComparison.Ordinal) && !pair.Value.IsCompleted); + return TypedResults.Ok(new EventPostProcessingSummary(ids.Length, queued, completed, ids.Length - queued - completed)); + } + + private static IResult InvalidIdentifiers(string detail) + { + return TypedResults.Problem( + detail, + statusCode: StatusCodes.Status422UnprocessableEntity, + title: "Invalid event-post identifiers"); + } +} diff --git a/src/Exceptionless.Web/Models/EventPostProcessingModels.cs b/src/Exceptionless.Web/Models/EventPostProcessingModels.cs new file mode 100644 index 0000000000..8e02c7b705 --- /dev/null +++ b/src/Exceptionless.Web/Models/EventPostProcessingModels.cs @@ -0,0 +1,26 @@ +using System.ComponentModel.DataAnnotations; + +namespace Exceptionless.Web.Models; + +public sealed record EventPostProcessingStatusRequest +{ + [Required, MinLength(1), MaxLength(1000)] + public required IReadOnlyCollection Ids { get; init; } +} + +public sealed record EventPostProcessingSummary( + int Requested, + int Queued, + int Completed, + int Unknown); + +public sealed record EventIngestionV3ProcessingStatusRequest +{ + [Required, MinLength(1), MaxLength(1000)] + public required IReadOnlyCollection ClientIds { get; init; } +} + +public sealed record EventIngestionV3ProcessingSummary( + int Requested, + int Pending, + int Completed); diff --git a/src/Exceptionless.Web/Program.cs b/src/Exceptionless.Web/Program.cs index 455d3d00c3..fd7b1f2057 100644 --- a/src/Exceptionless.Web/Program.cs +++ b/src/Exceptionless.Web/Program.cs @@ -9,6 +9,7 @@ using Exceptionless.Insulation.Configuration; using Exceptionless.Web.Api; using Exceptionless.Web.Api.Results; +using Exceptionless.Web.Endpoints; using Exceptionless.Web.Extensions; using Exceptionless.Web.Hubs; using Exceptionless.Web.Mcp; @@ -19,7 +20,6 @@ using Foundatio.Extensions.Hosting.Startup; using Foundatio.Mediator; using Foundatio.Repositories.Exceptions; -using HttpIResult = Microsoft.AspNetCore.Http.IResult; using Joonasw.AspNetCore.SecurityHeaders; using Joonasw.AspNetCore.SecurityHeaders.Csp; using Microsoft.AspNetCore.Authorization; @@ -37,6 +37,7 @@ using Serilog; using Serilog.Events; using Serilog.Sinks.Exceptionless; +using HttpIResult = Microsoft.AspNetCore.Http.IResult; namespace Exceptionless.Web; @@ -50,7 +51,9 @@ public static async Task Main(string[] args) string? environment = Environment.GetEnvironmentVariable("EX_AppMode"); if (String.IsNullOrWhiteSpace(environment)) + { environment = Environments.Production; + } var builder = WebApplication.CreateBuilder(new WebApplicationOptions { @@ -103,7 +106,9 @@ public static async Task Main(string[] args) c.Enrich.WithMachineName(); if (!String.IsNullOrEmpty(options.ExceptionlessApiKey)) + { c.WriteTo.Sink(new ExceptionlessSink(), LogEventLevel.Information); + } }, writeToProviders: true) .AddApm(apmConfig); @@ -111,8 +116,11 @@ public static async Task Main(string[] args) { c.AddServerHeader = false; - if (options.MaximumEventPostSize > 0) - c.Limits.MaxRequestBodySize = options.MaximumEventPostSize + EventPostRequestBodyStream.KestrelBodyLimitSlopBytes; + long maximumRequestBodySize = options.MaximumEventPostSize + EventPostRequestBodyStream.KestrelBodyLimitSlopBytes; + if (maximumRequestBodySize > 0) + { + c.Limits.MaxRequestBodySize = maximumRequestBodySize; + } }); builder.Services.AddSingleton(configuration); @@ -126,7 +134,7 @@ public static async Task Main(string[] args) .SetIsOriginAllowed(isOriginAllowed: _ => true) .AllowCredentials() .SetPreflightMaxAge(TimeSpan.FromMinutes(5)) - .WithExposedHeaders("ETag", Headers.LegacyConfigurationVersion, Headers.ConfigurationVersion, HeaderNames.Link, Headers.RateLimit, Headers.RateLimitRemaining, Headers.ResultCount))); + .WithExposedHeaders("ETag", Headers.LegacyConfigurationVersion, Headers.ConfigurationVersion, Headers.EventPostId, HeaderNames.Link, Headers.RateLimit, Headers.RateLimitRemaining, Headers.ResultCount))); builder.Services.Configure(o => { @@ -171,6 +179,12 @@ public static async Task Main(string[] args) }); builder.Services.AddExceptionlessOpenApi(); + builder.Services.AddRequestDecompression(decompression => + { + decompression.DecompressionProviders.Remove("deflate"); + }); + builder.Services.AddRequestTimeouts(); + builder.Services.AddSingleton(); builder.Services.AddSingleton, ApiResultMapper>(); builder.Services.AddMediator() @@ -197,8 +211,9 @@ public static async Task Main(string[] args) }); var app = builder.Build(); + var runtimeOptions = app.Services.GetRequiredService(); - Core.Bootstrapper.LogConfiguration(app.Services, options, app.Services.GetRequiredService>()); + Core.Bootstrapper.LogConfiguration(app.Services, runtimeOptions, app.Services.GetRequiredService>()); app.UseExceptionHandler(new ExceptionHandlerOptions { @@ -217,22 +232,29 @@ ApplicationException applicationException when applicationException.Message.Cont app.UseHealthChecks("/health", new HealthCheckOptions { - Predicate = hcr => hcr.Tags.Contains("Critical") || (options.RunJobsInProcess && hcr.Tags.Contains("AllJobs")) + Predicate = hcr => hcr.Tags.Contains("Critical") || (runtimeOptions.RunJobsInProcess && hcr.Tags.Contains("AllJobs")) }); List readyTags = ["Critical"]; - if (!options.EventSubmissionDisabled) + if (!runtimeOptions.EventSubmissionDisabled) + { readyTags.Add("Storage"); + } + app.UseReadyHealthChecks(readyTags.ToArray()); app.UseWaitForStartupActionsBeforeServingRequests(); - if (!String.IsNullOrEmpty(options.ExceptionlessApiKey) && !String.IsNullOrEmpty(options.ExceptionlessServerUrl)) + if (!String.IsNullOrEmpty(runtimeOptions.ExceptionlessApiKey) && !String.IsNullOrEmpty(runtimeOptions.ExceptionlessServerUrl)) + { app.UseExceptionless(ExceptionlessClient.Default); + } app.Use(async (context, next) => { - if (options.AppMode != AppMode.Development && !context.Request.IsLocal()) + if (runtimeOptions.AppMode != AppMode.Development && !context.Request.IsLocal()) + { context.Response.Headers.StrictTransportSecurity = "max-age=31536000; includeSubDomains"; + } context.Response.Headers["Referrer-Policy"] = "strict-origin-when-cross-origin"; context.Response.Headers.XContentTypeOptions = "nosniff"; @@ -244,10 +266,12 @@ ApplicationException applicationException when applicationException.Message.Cont }); var serverAddressesFeature = app.Services.GetRequiredService().Features.Get(); - bool ssl = options.AppMode != AppMode.Development && serverAddressesFeature is not null && serverAddressesFeature.Addresses.Any(a => a.StartsWith("https://")); + bool ssl = runtimeOptions.AppMode != AppMode.Development && serverAddressesFeature is not null && serverAddressesFeature.Addresses.Any(a => a.StartsWith("https://")); if (ssl) + { app.UseHttpsRedirection(); + } app.UseCsp(csp => { @@ -296,19 +320,27 @@ ApplicationException applicationException when applicationException.Message.Cont o.EnrichDiagnosticContext = (context, httpContext) => { if (Activity.Current?.Id is not null) + { context.Set("ActivityId", Activity.Current.Id); + } }; o.MessageTemplate = "{ActivityId} HTTP {RequestMethod} {RequestPath} responded {StatusCode} in {Elapsed:0.0000} ms"; o.GetLevel = (context, duration, ex) => { if (ex is not null || context.Response.StatusCode > 499) + { return LogEventLevel.Error; + } if (context.Response.StatusCode > 399) + { return LogEventLevel.Information; + } if (duration < 1000 || context.Request.Path.StartsWithSegments("/api/v2/push")) + { return LogEventLevel.Debug; + } return LogEventLevel.Information; }; @@ -318,6 +350,7 @@ ApplicationException applicationException when applicationException.Message.Cont app.UseDefaultFiles(); app.UseFileServer(); app.UseRouting(); + app.UseRequestTimeouts(); app.UseCors("AllowAny"); app.UseHttpMethodOverride(); app.UseForwardedHeaders(); @@ -328,27 +361,51 @@ ApplicationException applicationException when applicationException.Message.Cont app.UseMiddleware(); app.UseMiddleware(); - if (options.ApiThrottleLimit < Int32.MaxValue) + if (runtimeOptions.ApiThrottleLimit < Int32.MaxValue) + { app.UseMiddleware(); + } app.UseMiddleware(); - if (options.EnableWebSockets) + // Bound all admitted open streams globally before relaxing Kestrel's raw-body limit. + // The endpoint acquires the routed organization's stream permit after project lookup; + // processing concurrency is acquired separately only while a microbatch is executing. + app.UseMiddleware(); + + // Only relax Kestrel's single raw-body limit after authentication, authorization, and + // global active-stream admission. The V3 branch still enforces finite independent + // compressed and decompressed limits while the endpoint resolves organization admission. + app.UseWhen( + context => runtimeOptions.EventIngestionV3.Enabled + && !runtimeOptions.EventSubmissionDisabled + && IsEventIngestionV3Endpoint(context), + branch => + { + branch.UseMiddleware(); + branch.UseRequestDecompression(); + }); + + if (runtimeOptions.EnableWebSockets) { app.UseWebSockets(); app.UseMiddleware(); } - app.MapOpenApi("/docs/v2/openapi.json"); + app.MapOpenApi("/docs/{documentName}/openapi.json"); app.MapScalarApiReference("/docs", o => { o.WithOpenApiRoutePattern("/docs/{documentName}/openapi.json") .AddDocument("v2", "Exceptionless API", "/docs/{documentName}/openapi.json", true) + .AddDocument("v3", "Exceptionless Event Ingestion API", "/docs/{documentName}/openapi.json") .AddPreferredSecuritySchemes("Bearer"); }); app.MapApiEndpoints(); + app.MapEventPostProcessing(); + app.MapEventIngestionV3(runtimeOptions); app.MapMcp("/mcp").RequireAuthorization(AuthorizationRoles.McpPolicy); - app.MapFallback("{**slug:nonfile}", CreateRequestDelegate(app, "/index.html")); + app.MapFallback("{**slug:nonfile}", CreateRequestDelegate(app, "/index.html")) + .WithMetadata(new HttpMethodMetadata([HttpMethods.Get])); await app.RunAsync(); return 0; @@ -364,7 +421,9 @@ ApplicationException applicationException when applicationException.Message.Cont await ExceptionlessClient.Default.ProcessQueueAsync(); if (Debugger.IsAttached) + { Console.ReadKey(); + } } } @@ -372,10 +431,14 @@ private static void CustomizeProblemDetails(ProblemDetailsContext ctx) { ctx.ProblemDetails.Instance = $"{ctx.HttpContext.Request.Method} {ctx.HttpContext.Request.Path}"; if (ctx.HttpContext.Items.TryGetValue("reference-id", out object? refId) && refId is string referenceId) + { ctx.ProblemDetails.Extensions.Add("reference-id", referenceId); + } if (ctx.HttpContext.Items.TryGetValue("errors", out object? value) && value is Dictionary errors) + { ctx.ProblemDetails.Extensions.Add("errors", errors); + } if (ctx.ProblemDetails is ValidationProblemDetails validationProblem) { @@ -394,6 +457,9 @@ internal static Task WriteProblemDetailsStatusCodeResponseAsync(StatusCodeContex .ExecuteAsync(statusCodeContext.HttpContext); } + private static bool IsEventIngestionV3Endpoint(HttpContext context) => + context.GetEndpoint()?.Metadata.GetMetadata() is not null; + private static RequestDelegate CreateRequestDelegate(IEndpointRouteBuilder endpoints, string filePath) { var app = endpoints.CreateApplicationBuilder(); @@ -407,9 +473,13 @@ private static RequestDelegate CreateRequestDelegate(IEndpointRouteBuilder endpo bool isNextRequest = context.Request.Path.StartsWithSegments(nextPathSegment); if (!isApiRequest && !isDocsRequest && !isNextRequest) + { context.Request.Path = "/" + filePath; + } else if (!isApiRequest && !isDocsRequest) + { context.Request.Path = "/next/" + filePath; + } context.SetEndpoint(null); return next(context); @@ -422,7 +492,9 @@ private static RequestDelegate CreateRequestDelegate(IEndpointRouteBuilder endpo private static void SetClientEnvironmentVariablesInDevelopmentMode(AppOptions options) { if (options.AppMode is not AppMode.Development) + { return; + } Log.Debug("Updating client environment variables"); try diff --git a/src/Exceptionless.Web/Utility/EventIngestionV3ConcurrencyLimiter.cs b/src/Exceptionless.Web/Utility/EventIngestionV3ConcurrencyLimiter.cs new file mode 100644 index 0000000000..17bf8ade73 --- /dev/null +++ b/src/Exceptionless.Web/Utility/EventIngestionV3ConcurrencyLimiter.cs @@ -0,0 +1,83 @@ +using System.Threading.RateLimiting; +using Exceptionless.Core; + +namespace Exceptionless.Web.Utility; + +internal sealed class EventIngestionV3ConcurrencyLimiter : IAsyncDisposable +{ + private const string GlobalPartitionKey = "event-ingestion-v3"; + private readonly ConcurrencyLimiter _globalActiveStreamLimiter; + private readonly PartitionedRateLimiter _organizationActiveStreamLimiter; + private readonly PartitionedRateLimiter _processingLimiter; + + public EventIngestionV3ConcurrencyLimiter(AppOptions options) + { + EventIngestionV3Options ingestionOptions = options.EventIngestionV3; + _globalActiveStreamLimiter = new ConcurrencyLimiter(new ConcurrencyLimiterOptions + { + PermitLimit = ingestionOptions.MaximumActiveStreams, + QueueLimit = ingestionOptions.ActiveStreamQueueLimit, + QueueProcessingOrder = QueueProcessingOrder.OldestFirst + }); + _organizationActiveStreamLimiter = CreateOrganizationLimiter( + ingestionOptions.MaximumActiveStreamsPerOrganization, + ingestionOptions.ActiveStreamQueueLimitPerOrganization); + _processingLimiter = CreateChainedLimiter( + ingestionOptions.MaximumConcurrentRequests, + ingestionOptions.ConcurrencyQueueLimit, + ingestionOptions.MaximumConcurrentRequestsPerOrganization, + ingestionOptions.ConcurrencyQueueLimitPerOrganization); + } + + public ValueTask AcquireGlobalActiveStreamAsync(CancellationToken cancellationToken) => + _globalActiveStreamLimiter.AcquireAsync(cancellationToken: cancellationToken); + + public ValueTask AcquireOrganizationActiveStreamAsync(string organizationId, CancellationToken cancellationToken) => + _organizationActiveStreamLimiter.AcquireAsync(organizationId, cancellationToken: cancellationToken); + + public ValueTask AcquireProcessingAsync(string organizationId, CancellationToken cancellationToken) => + _processingLimiter.AcquireAsync(organizationId, cancellationToken: cancellationToken); + + public async ValueTask DisposeAsync() + { + await _globalActiveStreamLimiter.DisposeAsync(); + await _organizationActiveStreamLimiter.DisposeAsync(); + await _processingLimiter.DisposeAsync(); + } + + private static PartitionedRateLimiter CreateOrganizationLimiter( + int organizationPermitLimit, + int organizationQueueLimit) => + PartitionedRateLimiter.Create(organizationId => + RateLimitPartition.GetConcurrencyLimiter( + organizationId, + _ => new ConcurrencyLimiterOptions + { + PermitLimit = organizationPermitLimit, + QueueLimit = organizationQueueLimit, + QueueProcessingOrder = QueueProcessingOrder.OldestFirst + })); + + private static PartitionedRateLimiter CreateChainedLimiter( + int globalPermitLimit, + int globalQueueLimit, + int organizationPermitLimit, + int organizationQueueLimit) + { + var organizationLimiter = CreateOrganizationLimiter( + organizationPermitLimit, + organizationQueueLimit); + var globalLimiter = PartitionedRateLimiter.Create(_ => + RateLimitPartition.GetConcurrencyLimiter( + GlobalPartitionKey, + _ => new ConcurrencyLimiterOptions + { + PermitLimit = globalPermitLimit, + QueueLimit = globalQueueLimit, + QueueProcessingOrder = QueueProcessingOrder.OldestFirst + })); + + // Wait at the organization boundary before consuming scarce global capacity. + return PartitionedRateLimiter.CreateChained(organizationLimiter, globalLimiter); + } +} diff --git a/src/Exceptionless.Web/Utility/EventIngestionV3StreamReader.cs b/src/Exceptionless.Web/Utility/EventIngestionV3StreamReader.cs new file mode 100644 index 0000000000..faef563389 --- /dev/null +++ b/src/Exceptionless.Web/Utility/EventIngestionV3StreamReader.cs @@ -0,0 +1,357 @@ +using System.Buffers; +using System.IO.Pipelines; +using System.Text.Json; +using Exceptionless.Core.Models.Ingestion; + +namespace Exceptionless.Web.Utility; + +/// +/// Frames newline-delimited JSON before materialization so a single record can never cause an +/// unbounded allocation. It projects routing fields first and materializes only surviving records. +/// +internal static class EventIngestionV3StreamReader +{ + private static readonly JsonReaderOptions _readerOptions = new() + { + AllowTrailingCommas = false, + CommentHandling = JsonCommentHandling.Disallow, + MaxDepth = EventIngestionV3Limits.MaximumJsonDepth + }; + + public static async ValueTask ReadAsync( + PipeReader pipeReader, + long maximumEventSize, + CancellationToken cancellationToken, + MemoryPool? memoryPool = null) + { + ArgumentNullException.ThrowIfNull(pipeReader); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maximumEventSize); + maximumEventSize = Math.Min(maximumEventSize, Array.MaxLength); + + // PipeReader retains an incomplete record between reads. Remember how much of that record + // has already been searched so a client that fragments a large line into tiny writes cannot + // make us rescan the prefix on every read (quadratic work). + long examinedLength = 0; + bool objectPrefixValidated = false; + while (true) + { + ReadResult readResult = await pipeReader.ReadAsync(cancellationToken); + ReadOnlySequence buffer = readResult.Buffer; + ReadOnlySequence remaining = buffer; + + try + { + while (true) + { + if (examinedLength > remaining.Length) + { + throw new InvalidOperationException("The ingestion pipe returned less data than it retained."); + } + + ReadOnlySequence unexamined = remaining.Slice(examinedLength); + if (unexamined.PositionOf((byte)'\n') is not { } newline) + { + break; + } + + ReadOnlySequence record = remaining.Slice(0, newline); + SequencePosition consumed = remaining.GetPosition(1, newline); + if (record.Length > maximumEventSize) + { + throw new EventIngestionV3RecordTooLargeException(); + } + + if (!objectPrefixValidated) + { + objectPrefixValidated = EnsureObjectPrefix(unexamined.Slice(0, newline)); + } + + if (IsJsonWhitespace(record)) + { + remaining = remaining.Slice(consumed); + examinedLength = 0; + objectPrefixValidated = false; + continue; + } + + EventIngestionV3BufferedRecord bufferedRecord = Buffer(record, memoryPool ?? MemoryPool.Shared); + pipeReader.AdvanceTo(consumed, consumed); + return new EventIngestionV3StreamRecord(bufferedRecord); + } + + ReadOnlySequence newlyExamined = remaining.Slice(examinedLength); + if (!objectPrefixValidated) + { + objectPrefixValidated = EnsureObjectPrefix(newlyExamined); + } + + if (remaining.Length > maximumEventSize) + { + throw new EventIngestionV3RecordTooLargeException(); + } + + if (readResult.IsCompleted) + { + if (IsJsonWhitespace(remaining)) + { + pipeReader.AdvanceTo(buffer.End, buffer.End); + return null; + } + + EventIngestionV3BufferedRecord bufferedRecord = Buffer(remaining, memoryPool ?? MemoryPool.Shared); + pipeReader.AdvanceTo(buffer.End, buffer.End); + return new EventIngestionV3StreamRecord(bufferedRecord); + } + + examinedLength = remaining.Length; + pipeReader.AdvanceTo(remaining.Start, buffer.End); + } + catch + { + pipeReader.AdvanceTo(buffer.Start, buffer.End); + throw; + } + } + } + + private static EventIngestionV3BufferedRecord Buffer(ReadOnlySequence record, MemoryPool memoryPool) + { + int length = checked((int)record.Length); + IMemoryOwner owner = memoryPool.Rent(length); + try + { + record.CopyTo(owner.Memory.Span); + EventIngestionV3Event routingEvent = ParseRoutingEvent(owner.Memory.Span[..length]); + return new EventIngestionV3BufferedRecord(owner, length, routingEvent); + } + catch + { + owner.Dispose(); + throw; + } + } + + private static EventIngestionV3Event ParseRoutingEvent(ReadOnlySpan record) + { + var jsonReader = new Utf8JsonReader(record, isFinalBlock: true, new JsonReaderState(_readerOptions)); + if (!jsonReader.Read() || jsonReader.TokenType != JsonTokenType.StartObject) + { + throw new JsonException("Each NDJSON line must contain one event object."); + } + + string? id = null; + string? type = null; + string? source = null; + string? exceptionType = null; + string? stackTrace = null; + EventIngestionV3Stacking? stacking = null; + while (jsonReader.Read() && jsonReader.TokenType != JsonTokenType.EndObject) + { + if (jsonReader.TokenType != JsonTokenType.PropertyName) + { + throw new JsonException("The event record must be a JSON object."); + } + + bool isId = jsonReader.ValueTextEquals("id"u8); + bool isType = jsonReader.ValueTextEquals("type"u8); + bool isSource = jsonReader.ValueTextEquals("source"u8); + bool isExceptionType = jsonReader.ValueTextEquals("exception_type"u8); + bool isStackTrace = jsonReader.ValueTextEquals("stack_trace"u8); + bool isStacking = jsonReader.ValueTextEquals("stacking"u8); + if (!jsonReader.Read()) + { + throw new JsonException("The event record ended before a property value was complete."); + } + + if (isId) + { + id = ReadNullableString(ref jsonReader); + } + else if (isType) + { + type = ReadNullableString(ref jsonReader); + } + else if (isSource) + { + source = ReadNullableString(ref jsonReader); + } + else if (isExceptionType) + { + exceptionType = ReadNullableString(ref jsonReader); + } + else if (isStackTrace) + { + stackTrace = ReadNullableString(ref jsonReader); + } + else if (isStacking) + { + stacking = ReadStacking(ref jsonReader); + } + else + { + jsonReader.Skip(); + } + } + + if (jsonReader.TokenType != JsonTokenType.EndObject || jsonReader.Read()) + { + throw new JsonException("Each NDJSON line must contain exactly one event object."); + } + + return new EventIngestionV3Event + { + Id = id!, + Type = type!, + Source = source, + ExceptionType = exceptionType, + StackTrace = stackTrace, + Stacking = stacking + }; + } + + private static EventIngestionV3Stacking? ReadStacking(ref Utf8JsonReader jsonReader) + { + if (jsonReader.TokenType == JsonTokenType.Null) + { + return null; + } + + if (jsonReader.TokenType != JsonTokenType.StartObject) + { + throw new JsonException("stacking must be a JSON object."); + } + + Dictionary? signatureData = null; + while (jsonReader.Read() && jsonReader.TokenType != JsonTokenType.EndObject) + { + if (jsonReader.TokenType != JsonTokenType.PropertyName) + { + throw new JsonException("stacking must be a JSON object."); + } + + bool isSignatureData = jsonReader.ValueTextEquals("signature_data"u8); + if (!jsonReader.Read()) + { + throw new JsonException("stacking ended before a property value was complete."); + } + + if (isSignatureData) + { + signatureData = ReadSignatureData(ref jsonReader); + } + else + { + jsonReader.Skip(); + } + } + + if (jsonReader.TokenType != JsonTokenType.EndObject) + { + throw new JsonException("stacking must be a complete JSON object."); + } + + return new EventIngestionV3Stacking + { + SignatureData = signatureData! + }; + } + + private static Dictionary? ReadSignatureData(ref Utf8JsonReader jsonReader) + { + if (jsonReader.TokenType == JsonTokenType.Null) + { + return null; + } + + if (jsonReader.TokenType != JsonTokenType.StartObject) + { + throw new JsonException("stacking.signature_data must be a JSON object."); + } + + var values = new Dictionary(); + while (jsonReader.Read() && jsonReader.TokenType != JsonTokenType.EndObject) + { + if (jsonReader.TokenType != JsonTokenType.PropertyName) + { + throw new JsonException("stacking.signature_data must be a JSON object."); + } + + string key = jsonReader.GetString()!; + if (!jsonReader.Read()) + { + throw new JsonException("stacking.signature_data ended before a value was complete."); + } + + values[key] = ReadNullableString(ref jsonReader)!; + } + + if (jsonReader.TokenType != JsonTokenType.EndObject) + { + throw new JsonException("stacking.signature_data must be a complete JSON object."); + } + + return values; + } + + private static string? ReadNullableString(ref Utf8JsonReader jsonReader) + { + if (jsonReader.TokenType == JsonTokenType.Null) + { + return null; + } + + if (jsonReader.TokenType != JsonTokenType.String) + { + throw new JsonException("The event property must be a JSON string or null."); + } + + return jsonReader.GetString(); + } + + private static bool IsJsonWhitespace(ReadOnlySequence value) + { + foreach (ReadOnlyMemory segment in value) + { + foreach (byte item in segment.Span) + { + if (item is not (0x20 or 0x09 or 0x0A or 0x0D)) + { + return false; + } + } + } + + return true; + } + + private static bool EnsureObjectPrefix(ReadOnlySequence value) + { + foreach (ReadOnlyMemory segment in value) + { + foreach (byte item in segment.Span) + { + if (item is 0x20 or 0x09 or 0x0A or 0x0D) + { + continue; + } + + if (item != (byte)'{') + { + throw new JsonException("Each NDJSON line must contain one event object."); + } + + return true; + } + } + + return false; + } +} + +internal readonly record struct EventIngestionV3StreamRecord(EventIngestionV3BufferedRecord BufferedRecord) +{ + public EventIngestionV3Event Event => BufferedRecord.RoutingEvent; + public long Size => BufferedRecord.Length; +} + +internal sealed class EventIngestionV3RecordTooLargeException : Exception; diff --git a/src/Exceptionless.Web/Utility/EventPostRequestBodyStream.cs b/src/Exceptionless.Web/Utility/EventPostRequestBodyStream.cs index 385f65c1de..3c0aa57378 100644 --- a/src/Exceptionless.Web/Utility/EventPostRequestBodyStream.cs +++ b/src/Exceptionless.Web/Utility/EventPostRequestBodyStream.cs @@ -9,19 +9,31 @@ public sealed class EventPostRequestBodyStream : Stream, IEventPostBodyReadState private readonly Stream _inner; private readonly long _maximumBytes; + private readonly string _limitRejectionReason; + private readonly int? _invalidOperationStatusCode; + private readonly string? _invalidOperationRejectionReason; private long _bytesRead; - public EventPostRequestBodyStream(Stream inner, long maximumBytes) + public EventPostRequestBodyStream( + Stream inner, + long maximumBytes, + string limitRejectionReason = "Request body too large.", + int? invalidOperationStatusCode = null, + string? invalidOperationRejectionReason = null) { ArgumentNullException.ThrowIfNull(inner); ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maximumBytes); _inner = inner; _maximumBytes = maximumBytes; + _limitRejectionReason = limitRejectionReason; + _invalidOperationStatusCode = invalidOperationStatusCode; + _invalidOperationRejectionReason = invalidOperationRejectionReason; } public int? RejectedStatusCode { get; private set; } public string? RejectionReason { get; private set; } + public long BytesRead => _bytesRead; public override bool CanRead => _inner.CanRead; public override bool CanSeek => false; @@ -49,11 +61,15 @@ public override int Read(byte[] buffer, int offset, int count) ValidateBufferArguments(buffer, offset, count); if (count == 0 || RejectedStatusCode.HasValue) + { return 0; + } int readLength = GetReadLength(count); if (readLength == 0) + { return 0; + } try { @@ -65,16 +81,25 @@ public override int Read(byte[] buffer, int offset, int count) Reject(ex.StatusCode, ex.Message); return 0; } + catch (InvalidOperationException) when (_invalidOperationStatusCode.HasValue) + { + Reject(_invalidOperationStatusCode.Value, _invalidOperationRejectionReason ?? "Request body is invalid."); + return 0; + } } public override async ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) { if (buffer.Length == 0 || RejectedStatusCode.HasValue) + { return 0; + } int readLength = GetReadLength(buffer.Length); if (readLength == 0) + { return 0; + } try { @@ -86,6 +111,11 @@ public override async ValueTask ReadAsync(Memory buffer, Cancellation Reject(ex.StatusCode, ex.Message); return 0; } + catch (InvalidOperationException) when (_invalidOperationStatusCode.HasValue) + { + Reject(_invalidOperationStatusCode.Value, _invalidOperationRejectionReason ?? "Request body is invalid."); + return 0; + } } public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) @@ -114,15 +144,19 @@ private int GetReadLength(int requestedLength) long remaining = _maximumBytes - _bytesRead; if (remaining < 0) { - Reject(StatusCodes.Status413RequestEntityTooLarge, "Request body too large."); + Reject(StatusCodes.Status413RequestEntityTooLarge, _limitRejectionReason); return 0; } if (remaining == 0) + { return 1; + } if (remaining >= requestedLength) + { return requestedLength; + } return (int)remaining + 1; } @@ -130,12 +164,14 @@ private int GetReadLength(int requestedLength) private int HandleReadResult(int bytesRead) { if (bytesRead == 0) + { return 0; + } long totalBytesRead = _bytesRead + bytesRead; if (totalBytesRead > _maximumBytes) { - Reject(StatusCodes.Status413RequestEntityTooLarge, "Request body too large."); + Reject(StatusCodes.Status413RequestEntityTooLarge, _limitRejectionReason); return 0; } diff --git a/src/Exceptionless.Web/Utility/Handlers/EventIngestionV3ActiveStreamMiddleware.cs b/src/Exceptionless.Web/Utility/Handlers/EventIngestionV3ActiveStreamMiddleware.cs new file mode 100644 index 0000000000..344a9c7390 --- /dev/null +++ b/src/Exceptionless.Web/Utility/Handlers/EventIngestionV3ActiveStreamMiddleware.cs @@ -0,0 +1,37 @@ +using System.Threading.RateLimiting; +using Exceptionless.Core; +using Exceptionless.Web.Endpoints; + +namespace Exceptionless.Web.Utility.Handlers; + +internal sealed class EventIngestionV3ActiveStreamMiddleware(RequestDelegate next) +{ + public async Task InvokeAsync( + HttpContext context, + EventIngestionV3ConcurrencyLimiter concurrencyLimiter, + AppOptions options) + { + if (!options.EventIngestionV3.Enabled + || options.EventSubmissionDisabled + || context.GetEndpoint()?.Metadata.GetMetadata() is null) + { + await next(context); + return; + } + + // This global permit is intentionally acquired before the request-body middleware raises + // Kestrel's limit. The endpoint acquires the routed organization's permit after project + // authorization, without consuming a second global permit. + using RateLimitLease lease = await concurrencyLimiter.AcquireGlobalActiveStreamAsync(context.RequestAborted); + if (!lease.IsAcquired) + { + await Microsoft.AspNetCore.Http.Results.Problem( + statusCode: StatusCodes.Status429TooManyRequests, + title: "Event ingestion stream capacity is busy.") + .ExecuteAsync(context); + return; + } + + await next(context); + } +} diff --git a/src/Exceptionless.Web/Utility/Handlers/EventIngestionV3RequestBodyMiddleware.cs b/src/Exceptionless.Web/Utility/Handlers/EventIngestionV3RequestBodyMiddleware.cs new file mode 100644 index 0000000000..761d6d7804 --- /dev/null +++ b/src/Exceptionless.Web/Utility/Handlers/EventIngestionV3RequestBodyMiddleware.cs @@ -0,0 +1,45 @@ +using Exceptionless.Core; +using Exceptionless.Web.Utility; +using Microsoft.AspNetCore.Http.Features; + +namespace Exceptionless.Web.Utility.Handlers; + +internal sealed class EventIngestionV3RequestBodyMiddleware(RequestDelegate next) +{ + public async Task InvokeAsync(HttpContext context, AppOptions options) + { + var ingestionOptions = options.EventIngestionV3; + if (context.Request.ContentLength > ingestionOptions.MaximumCompressedBodySize) + { + await Microsoft.AspNetCore.Http.Results.Problem( + statusCode: StatusCodes.Status413RequestEntityTooLarge, + title: "The compressed request body is too large.").ExecuteAsync(context); + return; + } + + // The framework uses one feature value for both Kestrel's raw-body limit and + // its decompression wrapper, so it cannot express our two independent limits. + // Raise the shared ceiling only as far as the larger V3 limit; the wrappers + // enforce the exact compressed and decompressed limits when the endpoint reads. + // Keeping a finite transport ceiling also bounds Kestrel's unread-body drain + // when an admitted request is rejected before its body is consumed. + IHttpMaxRequestBodySizeFeature? requestSizeFeature = context.Features.Get(); + if (requestSizeFeature is { IsReadOnly: false }) + { + requestSizeFeature.MaxRequestBodySize = Math.Max( + ingestionOptions.MaximumCompressedBodySize, + ingestionOptions.MaximumDecompressedBodySize); + } + + var compressedBody = new EventPostRequestBodyStream( + context.Request.Body, + ingestionOptions.MaximumCompressedBodySize, + "The compressed request body is too large."); + context.Features.Set(new EventIngestionV3RequestBodyState(compressedBody)); + context.Request.Body = compressedBody; + + await next(context); + } +} + +internal sealed record EventIngestionV3RequestBodyState(EventPostRequestBodyStream CompressedBody); diff --git a/src/Exceptionless.Web/Utility/Handlers/OverageMiddleware.cs b/src/Exceptionless.Web/Utility/Handlers/OverageMiddleware.cs index 66393479f0..4ee95ea7b6 100644 --- a/src/Exceptionless.Web/Utility/Handlers/OverageMiddleware.cs +++ b/src/Exceptionless.Web/Utility/Handlers/OverageMiddleware.cs @@ -26,6 +26,14 @@ public OverageMiddleware(RequestDelegate next, UsageService usageService, IOrgan public async Task Invoke(HttpContext context) { + // V3 reserves quota after the discard route, so discarded events are never + // charged and concurrent nodes cannot all pass a stale preflight check. + if (context.Request.Path.StartsWithSegments("/api/v3")) + { + await _next(context); + return; + } + if (!context.Request.IsEventPost()) { await _next(context); @@ -58,14 +66,18 @@ public async Task Invoke(HttpContext context) long size = contentLength.GetValueOrDefault(); if (size > 0) + { AppDiagnostics.PostsSize.Record(size); + } if (size > _appOptions.MaximumEventPostSize) { if (_logger.IsEnabled(LogLevel.Warning)) { using (_logger.BeginScope(new ExceptionlessState().Value(size).Tag(context.Request.Headers.TryGetAndReturn(Headers.ContentEncoding)))) + { _logger.SubmissionTooLarge(size); + } } tooBig = true; diff --git a/src/Exceptionless.Web/Utility/Headers.cs b/src/Exceptionless.Web/Utility/Headers.cs index 1474292cb6..61c7452af7 100644 --- a/src/Exceptionless.Web/Utility/Headers.cs +++ b/src/Exceptionless.Web/Utility/Headers.cs @@ -3,6 +3,8 @@ public static class Headers { public const string ContentEncoding = "Content-Encoding"; + public const string EventPostId = "X-Exceptionless-Event-Post-Id"; + public const string TrackEventPost = "X-Exceptionless-Track-Event-Post"; public const string LegacyConfigurationVersion = "v"; public const string ConfigurationVersion = "X-Exceptionless-ConfigVersion"; public const string Client = "X-Exceptionless-Client"; diff --git a/src/Exceptionless.Web/Utility/OpenApi/DocumentInfoTransformer.cs b/src/Exceptionless.Web/Utility/OpenApi/DocumentInfoTransformer.cs index 2badff6aed..6b831a199c 100644 --- a/src/Exceptionless.Web/Utility/OpenApi/DocumentInfoTransformer.cs +++ b/src/Exceptionless.Web/Utility/OpenApi/DocumentInfoTransformer.cs @@ -12,8 +12,8 @@ public Task TransformAsync(OpenApiDocument document, OpenApiDocumentTransformerC { document.Info = new OpenApiInfo { - Title = "Exceptionless API", - Version = "v2", + Title = context.DocumentName == "v3" ? "Exceptionless Event Ingestion API" : "Exceptionless API", + Version = context.DocumentName, TermsOfService = new Uri("https://exceptionless.com/terms/"), Contact = new OpenApiContact { @@ -54,6 +54,14 @@ public Task TransformAsync(OpenApiDocument document, OpenApiDocumentTransformerC }; document.Security ??= []; + if (context.DocumentName == "v3") + { + document.Security.Add(new OpenApiSecurityRequirement + { + [new OpenApiSecuritySchemeReference("Bearer", document, null)] = [] + }); + } + return Task.CompletedTask; } } diff --git a/src/Exceptionless.Web/Utility/OpenApi/EventIngestionV3ContractSchemaTransformer.cs b/src/Exceptionless.Web/Utility/OpenApi/EventIngestionV3ContractSchemaTransformer.cs new file mode 100644 index 0000000000..e99f75d791 --- /dev/null +++ b/src/Exceptionless.Web/Utility/OpenApi/EventIngestionV3ContractSchemaTransformer.cs @@ -0,0 +1,54 @@ +using Exceptionless.Core.Models.Ingestion; +using Microsoft.AspNetCore.OpenApi; +using Microsoft.OpenApi; + +namespace Exceptionless.Web.Utility.OpenApi; + +/// +/// Adds collection and dictionary value limits that data annotations cannot +/// express on the V3 wire model. +/// +public sealed class EventIngestionV3ContractSchemaTransformer : IOpenApiSchemaTransformer +{ + public Task TransformAsync(OpenApiSchema schema, OpenApiSchemaTransformerContext context, CancellationToken cancellationToken) + { + if (schema.Properties is null) + { + return Task.CompletedTask; + } + + if (context.JsonTypeInfo.Type == typeof(EventIngestionV3Event) + && JsonPropertyNameResolver.TryGetSchemaProperty( + context.JsonTypeInfo, + typeof(EventIngestionV3Event).GetProperty(nameof(EventIngestionV3Event.Tags))!, + schema.Properties, + out IOpenApiSchema? tagsProperty) + && tagsProperty is OpenApiSchema tags) + { + tags.MaxItems = EventIngestionV3Limits.MaximumTags; + if (tags.Items is OpenApiSchema tag) + { + tag.MinLength = 1; + tag.MaxLength = EventIngestionV3Limits.MaximumTagLength; + } + } + + if (context.JsonTypeInfo.Type == typeof(EventIngestionV3Stacking) + && JsonPropertyNameResolver.TryGetSchemaProperty( + context.JsonTypeInfo, + typeof(EventIngestionV3Stacking).GetProperty(nameof(EventIngestionV3Stacking.SignatureData))!, + schema.Properties, + out IOpenApiSchema? signatureProperty) + && signatureProperty is OpenApiSchema signature) + { + signature.MinProperties = 1; + signature.MaxProperties = EventIngestionV3Limits.MaximumMetadataEntries; + if (signature.AdditionalProperties is OpenApiSchema value) + { + value.MaxLength = EventIngestionV3Limits.MaximumMetadataValueLength; + } + } + + return Task.CompletedTask; + } +} diff --git a/src/Exceptionless.Web/Utility/OpenApi/EventIngestionV3DataSchemaTransformer.cs b/src/Exceptionless.Web/Utility/OpenApi/EventIngestionV3DataSchemaTransformer.cs new file mode 100644 index 0000000000..348ac61960 --- /dev/null +++ b/src/Exceptionless.Web/Utility/OpenApi/EventIngestionV3DataSchemaTransformer.cs @@ -0,0 +1,51 @@ +using System.Reflection; +using System.Text.Json; +using Exceptionless.Core.Models.Ingestion; +using Microsoft.AspNetCore.OpenApi; +using Microsoft.OpenApi; + +namespace Exceptionless.Web.Utility.OpenApi; + +/// +/// Advertises the V3 metadata bags that accept only JSON objects or null. +/// intentionally remains unconstrained JSON. +/// +public sealed class EventIngestionV3DataSchemaTransformer : IOpenApiSchemaTransformer +{ + private static readonly HashSet _objectDataTypes = + [ + typeof(EventIngestionV3Event), + typeof(EventIngestionV3User), + typeof(EventIngestionV3Request), + typeof(EventIngestionV3Environment) + ]; + + public Task TransformAsync(OpenApiSchema schema, OpenApiSchemaTransformerContext context, CancellationToken cancellationToken) + { + if (!_objectDataTypes.Contains(context.JsonTypeInfo.Type) || schema.Properties is null) + { + return Task.CompletedTask; + } + + PropertyInfo? dataProperty = context.JsonTypeInfo.Type.GetProperty(nameof(EventIngestionV3Event.Data)); + if (dataProperty?.PropertyType != typeof(JsonElement?)) + { + return Task.CompletedTask; + } + + string? propertyName = JsonPropertyNameResolver.GetJsonPropertyName(context.JsonTypeInfo, dataProperty); + if (propertyName is null || !schema.Properties.ContainsKey(propertyName)) + { + return Task.CompletedTask; + } + + // Nullable JsonElement properties are emitted as reference wrappers that are not + // mutable OpenApiSchema instances. Replace the property schema at the parent. + schema.Properties[propertyName] = new OpenApiSchema + { + Type = JsonSchemaType.Object | JsonSchemaType.Null, + AdditionalProperties = new OpenApiSchema() + }; + return Task.CompletedTask; + } +} diff --git a/src/Exceptionless.Web/Utility/OpenApi/ExceptionlessOpenApiServiceCollectionExtensions.cs b/src/Exceptionless.Web/Utility/OpenApi/ExceptionlessOpenApiServiceCollectionExtensions.cs index 9fe098e181..295dc33a33 100644 --- a/src/Exceptionless.Web/Utility/OpenApi/ExceptionlessOpenApiServiceCollectionExtensions.cs +++ b/src/Exceptionless.Web/Utility/OpenApi/ExceptionlessOpenApiServiceCollectionExtensions.cs @@ -1,35 +1,50 @@ using Microsoft.Extensions.DependencyInjection; +using Microsoft.AspNetCore.OpenApi; namespace Exceptionless.Web.Utility.OpenApi; internal static class ExceptionlessOpenApiServiceCollectionExtensions { - public const string DocumentName = "v1"; - public static IServiceCollection AddExceptionlessOpenApi(this IServiceCollection services) { - services.AddOpenApi(options => + services.AddOpenApi("v2", options => + { + Configure(options); + options.ShouldInclude = description => !IsV3Api(description.RelativePath); + }); + services.AddOpenApi("v3", options => { - options.CreateSchemaReferenceId = SchemaReferenceIdHelper.CreateSchemaReferenceId; - options.AddDocumentTransformer(); - options.AddDocumentTransformer(); - options.AddDocumentTransformer(); - options.AddDocumentTransformer(); - options.AddOperationTransformer(); - options.AddOperationTransformer(); - options.AddOperationTransformer(); - options.AddOperationTransformer(); - options.AddSchemaTransformer(); - options.AddSchemaTransformer(); - options.AddSchemaTransformer(); - options.AddSchemaTransformer(); - options.AddSchemaTransformer(); - options.AddSchemaTransformer(); - options.AddSchemaTransformer(); - options.AddSchemaTransformer(); - options.AddSchemaTransformer(); + Configure(options); + options.ShouldInclude = description => IsV3Api(description.RelativePath); }); return services; } + + private static bool IsV3Api(string? relativePath) => + relativePath?.StartsWith("api/v3/", StringComparison.OrdinalIgnoreCase) is true; + + private static void Configure(OpenApiOptions options) + { + options.CreateSchemaReferenceId = SchemaReferenceIdHelper.CreateSchemaReferenceId; + options.AddDocumentTransformer(); + options.AddDocumentTransformer(); + options.AddDocumentTransformer(); + options.AddDocumentTransformer(); + options.AddOperationTransformer(); + options.AddOperationTransformer(); + options.AddOperationTransformer(); + options.AddOperationTransformer(); + options.AddSchemaTransformer(); + options.AddSchemaTransformer(); + options.AddSchemaTransformer(); + options.AddSchemaTransformer(); + options.AddSchemaTransformer(); + options.AddSchemaTransformer(); + options.AddSchemaTransformer(); + options.AddSchemaTransformer(); + options.AddSchemaTransformer(); + options.AddSchemaTransformer(); + options.AddSchemaTransformer(); + } } diff --git a/src/Exceptionless.Web/Utility/OpenApi/ReadOnlyPropertySchemaTransformer.cs b/src/Exceptionless.Web/Utility/OpenApi/ReadOnlyPropertySchemaTransformer.cs index e529c7f6b2..5ee4bd1f53 100644 --- a/src/Exceptionless.Web/Utility/OpenApi/ReadOnlyPropertySchemaTransformer.cs +++ b/src/Exceptionless.Web/Utility/OpenApi/ReadOnlyPropertySchemaTransformer.cs @@ -1,4 +1,5 @@ using System.Collections; +using System.ComponentModel; using System.Reflection; using Microsoft.AspNetCore.OpenApi; using Microsoft.OpenApi; @@ -7,7 +8,8 @@ namespace Exceptionless.Web.Utility.OpenApi; /// /// Schema transformer that adds readOnly: true to properties that have only getters (no setters) -/// and removes nullable: true from get-only properties with field initializers. +/// or are explicitly marked with , and removes nullable: true +/// from get-only properties with field initializers. /// /// /// @@ -38,26 +40,33 @@ public class ReadOnlyPropertySchemaTransformer : IOpenApiSchemaTransformer public Task TransformAsync(OpenApiSchema schema, OpenApiSchemaTransformerContext context, CancellationToken cancellationToken) { if (schema.Properties is null || schema.Properties.Count == 0) + { return Task.CompletedTask; + } var type = context.JsonTypeInfo.Type; if (!type.IsClass) + { return Task.CompletedTask; + } foreach (var property in type.GetProperties() - .Where(p => p.CanRead && !p.CanWrite)) + .Where(p => p.CanRead && (!p.CanWrite || p.GetCustomAttribute()?.IsReadOnly is true))) { // Use JsonTypeInfo to get the effective JSON property name (respects [JsonPropertyName] and naming policy) if (!JsonPropertyNameResolver.TryGetSchemaProperty(context.JsonTypeInfo, property, schema.Properties, out IOpenApiSchema? propertySchema) || propertySchema is not OpenApiSchema mutableSchema) + { continue; + } - // Mark as read-only since there's no setter + // Mark getter-only and explicitly annotated server-managed properties as read-only. mutableSchema.ReadOnly = true; - // If the property has an initializer (backing field exists), it's never null + // If the property has an initializer (backing field exists), it's never null. + // Writable properties explicitly marked [ReadOnly(true)] do not need this adjustment. // Remove nullable flag for properties with initializers - if (HasBackingFieldWithInitializer(type, property) && mutableSchema.Type.HasValue) + if (!property.CanWrite && HasBackingFieldWithInitializer(type, property) && mutableSchema.Type.HasValue) { // Remove the Null type flag to indicate the property is not nullable mutableSchema.Type = mutableSchema.Type.Value & ~JsonSchemaType.Null; @@ -81,7 +90,9 @@ private static bool HasBackingFieldWithInitializer(Type type, PropertyInfo prope // If backing field exists and property type is a reference type that's non-nullable in the declaration, // it likely has an initializer. For collection types, this is almost always the case. if (backingField is null) + { return false; + } // For collection types, get-only with backing field almost always means initialized // Use interface checks for broader compatibility with different collection implementations diff --git a/src/Exceptionless.Web/Utility/OpenApi/RequiredPropertySchemaTransformer.cs b/src/Exceptionless.Web/Utility/OpenApi/RequiredPropertySchemaTransformer.cs index 6aa89679f0..241dc5d705 100644 --- a/src/Exceptionless.Web/Utility/OpenApi/RequiredPropertySchemaTransformer.cs +++ b/src/Exceptionless.Web/Utility/OpenApi/RequiredPropertySchemaTransformer.cs @@ -1,4 +1,5 @@ using System.Reflection; +using System.Text.Json.Serialization; using Microsoft.AspNetCore.OpenApi; using Microsoft.OpenApi; @@ -34,11 +35,15 @@ public class RequiredPropertySchemaTransformer : IOpenApiSchemaTransformer public Task TransformAsync(OpenApiSchema schema, OpenApiSchemaTransformerContext context, CancellationToken cancellationToken) { if (schema.Properties is null || schema.Properties.Count == 0) + { return Task.CompletedTask; + } var type = context.JsonTypeInfo.Type; if (!type.IsClass && !type.IsValueType) + { return Task.CompletedTask; + } // Initialize Required collection if needed schema.Required ??= new HashSet(); @@ -52,15 +57,29 @@ public Task TransformAsync(OpenApiSchema schema, OpenApiSchemaTransformerContext // Use JsonTypeInfo to get the effective JSON property name (respects [JsonPropertyName] and naming policy) string? schemaPropertyName = JsonPropertyNameResolver.GetJsonPropertyName(context.JsonTypeInfo, property); if (schemaPropertyName is null) + { continue; + } // Check if property exists in schema if (!schema.Properties.ContainsKey(schemaPropertyName)) + { continue; + } // Skip properties that are already marked as required if (schema.Required.Contains(schemaPropertyName)) + { + continue; + } + + // A conditionally ignored property can be absent from valid serialized output, + // even when its CLR type is non-nullable. Advertising it as required would make + // the OpenAPI contract stricter than the JSON produced by System.Text.Json. + if (CanBeOmittedWhenWriting(property, context.JsonTypeInfo.Options.DefaultIgnoreCondition)) + { continue; + } // Determine if property should be required if (IsPropertyRequired(property, nullabilityContext)) @@ -78,6 +97,25 @@ public Task TransformAsync(OpenApiSchema schema, OpenApiSchemaTransformerContext return Task.CompletedTask; } + private static bool CanBeOmittedWhenWriting(PropertyInfo property, JsonIgnoreCondition defaultIgnoreCondition) + { + JsonIgnoreAttribute? ignoreAttribute = property.GetCustomAttribute(); + if (ignoreAttribute?.Condition is JsonIgnoreCondition.WhenWritingDefault or JsonIgnoreCondition.WhenWritingNull) + { + return true; + } + + if (ignoreAttribute?.Condition is JsonIgnoreCondition.Never) + { + return false; + } + + // The application currently uses WhenWritingNull globally, which does not change the + // required status of a correctly populated non-nullable property. WhenWritingDefault, + // however, can omit every non-nullable value type at its default value. + return defaultIgnoreCondition is JsonIgnoreCondition.WhenWritingDefault; + } + private static bool IsPropertyRequired(PropertyInfo property, NullabilityInfoContext nullabilityContext) { var propertyType = property.PropertyType; diff --git a/src/Exceptionless.Web/appsettings.yml b/src/Exceptionless.Web/appsettings.yml index 5ae80b1264..e6b34d16fc 100644 --- a/src/Exceptionless.Web/appsettings.yml +++ b/src/Exceptionless.Web/appsettings.yml @@ -34,3 +34,20 @@ Apm: EnableLogs: true FullDetails: true Debug: false + +EventIngestionV3: + Enabled: false + EnableProcessingStatus: false + MicroBatchSize: 100 + MaximumMicroBatchBytes: 1048576 + MaximumEventSize: 524288 + MaximumCompressedBodySize: 10485760 + MaximumDecompressedBodySize: 52428800 + MaximumEventsPerRequest: 10000 + MaximumStackCreationConcurrency: 8 + RequestTimeout: 00:02:00 + IdempotencyWindow: 7.00:00:00 + StackRouteCacheDuration: 01:00:00 + NegativeStackRouteCacheDuration: 00:00:30 + AllowedProjectIds: [] + AllowedOrganizationIds: [] diff --git a/tests/Exceptionless.Tests/Api/Data/endpoint-manifest.json b/tests/Exceptionless.Tests/Api/Data/endpoint-manifest.json index 65f2578aba..2864497d4e 100644 --- a/tests/Exceptionless.Tests/Api/Data/endpoint-manifest.json +++ b/tests/Exceptionless.Tests/Api/Data/endpoint-manifest.json @@ -1923,6 +1923,20 @@ "authorizationRoles": [], "authenticationSchemes": [] }, + { + "method": "POST", + "route": "/api/v2/projects/{projectId:objectid}/events/posts/status", + "displayName": "HTTP: POST api/v2/projects/{projectId:objectid}/events/posts/status =\u003E GetStatusesAsync", + "tags": [ + "Event" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "ClientPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, { "method": "GET", "route": "/api/v2/projects/{projectId:objectid}/events/sessions", @@ -2881,6 +2895,48 @@ "authorizationRoles": [], "authenticationSchemes": [] }, + { + "method": "POST", + "route": "/api/v3/events", + "displayName": "HTTP: POST /api/v3/events =\u003E HandleDefaultProjectAsync", + "tags": [ + "Event Ingestion V3" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "ClientPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "POST", + "route": "/api/v3/projects/{projectId:objectid}/events", + "displayName": "HTTP: POST /api/v3/projects/{projectId:objectid}/events =\u003E HandleProjectAsync", + "tags": [ + "Event Ingestion V3" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "ClientPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, + { + "method": "POST", + "route": "/api/v3/projects/{projectId:objectid}/events/processing/status", + "displayName": "HTTP: POST /api/v3/projects/{projectId:objectid}/events/processing/status =\u003E GetProcessingStatusAsync", + "tags": [ + "Event Ingestion V3" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "ClientPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, { "method": "POST", "route": "/api/v{apiVersion:int=2}/webhooks/subscribe", diff --git a/tests/Exceptionless.Tests/Api/Data/openapi-v3.json b/tests/Exceptionless.Tests/Api/Data/openapi-v3.json new file mode 100644 index 0000000000..5c2333b645 --- /dev/null +++ b/tests/Exceptionless.Tests/Api/Data/openapi-v3.json @@ -0,0 +1,874 @@ +{ + "openapi": "3.1.1", + "info": { + "title": "Exceptionless Event Ingestion API", + "termsOfService": "https://exceptionless.com/terms/", + "contact": { + "name": "Exceptionless", + "url": "https://github.com/exceptionless/Exceptionless", + "email": "" + }, + "license": { + "name": "Apache License 2.0", + "url": "https://github.com/exceptionless/Exceptionless/blob/main/LICENSE.txt" + }, + "version": "v3" + }, + "servers": [ + { + "url": "http://localhost/" + } + ], + "paths": { + "/api/v3/events": { + "post": { + "tags": [ + "Event Ingestion V3" + ], + "operationId": "PostEventsV3", + "requestBody": { + "content": { + "application/x-ndjson": { + "schema": { + "$ref": "#/components/schemas/EventIngestionV3Event" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EventIngestionV3Response" + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "402": { + "description": "Payment Required", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "413": { + "description": "Payload Too Large", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "415": { + "description": "Unsupported Media Type", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "422": { + "description": "Unprocessable Entity", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "429": { + "description": "Too Many Requests", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "503": { + "description": "Service Unavailable", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + }, + "security": [ + { + "Bearer": [] + } + ] + } + }, + "/api/v3/projects/{projectId}/events": { + "post": { + "tags": [ + "Event Ingestion V3" + ], + "operationId": "PostEventsByProjectV3", + "parameters": [ + { + "name": "projectId", + "in": "path", + "required": true, + "schema": { + "pattern": "^[a-zA-Z\\d]{24,36}$", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/x-ndjson": { + "schema": { + "$ref": "#/components/schemas/EventIngestionV3Event" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EventIngestionV3Response" + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "402": { + "description": "Payment Required", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "413": { + "description": "Payload Too Large", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "415": { + "description": "Unsupported Media Type", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "422": { + "description": "Unprocessable Entity", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "429": { + "description": "Too Many Requests", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "503": { + "description": "Service Unavailable", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + }, + "security": [ + { + "Bearer": [] + } + ] + } + } + }, + "components": { + "schemas": { + "EventIngestionV3Client": { + "required": [ + "name", + "version" + ], + "type": "object", + "properties": { + "name": { + "maxLength": 255, + "minLength": 1, + "type": "string" + }, + "version": { + "maxLength": 255, + "minLength": 1, + "type": "string" + } + } + }, + "EventIngestionV3Environment": { + "type": "object", + "properties": { + "architecture": { + "type": [ + "null", + "string" + ] + }, + "os_name": { + "type": [ + "null", + "string" + ] + }, + "os_version": { + "type": [ + "null", + "string" + ] + }, + "machine_name": { + "type": [ + "null", + "string" + ] + }, + "runtime_version": { + "type": [ + "null", + "string" + ] + }, + "process_name": { + "type": [ + "null", + "string" + ] + }, + "process_id": { + "type": [ + "null", + "string" + ] + }, + "thread_name": { + "type": [ + "null", + "string" + ] + }, + "thread_id": { + "type": [ + "null", + "string" + ] + }, + "processor_count": { + "type": [ + "null", + "integer" + ], + "format": "int32" + }, + "total_physical_memory": { + "type": [ + "null", + "integer" + ], + "format": "int64" + }, + "available_physical_memory": { + "type": [ + "null", + "integer" + ], + "format": "int64" + }, + "process_memory_size": { + "type": [ + "null", + "integer" + ], + "format": "int64" + }, + "data": { + "type": [ + "null", + "object" + ], + "additionalProperties": {} + } + } + }, + "EventIngestionV3Error": { + "required": [ + "id", + "code", + "message" + ], + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + } + }, + "EventIngestionV3Event": { + "required": [ + "id", + "type" + ], + "type": "object", + "properties": { + "id": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "type": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "date": { + "type": [ + "null", + "string" + ], + "format": "date-time" + }, + "source": { + "maxLength": 2000, + "minLength": 1, + "type": [ + "null", + "string" + ] + }, + "message": { + "maxLength": 2000, + "minLength": 1, + "type": [ + "null", + "string" + ] + }, + "reference_id": { + "maxLength": 100, + "minLength": 8, + "type": [ + "null", + "string" + ] + }, + "value": { + "type": [ + "null", + "number" + ], + "format": "double" + }, + "tags": { + "maxItems": 50, + "type": [ + "null", + "array" + ], + "items": { + "maxLength": 100, + "minLength": 1, + "type": "string" + } + }, + "version": { + "maxLength": 255, + "minLength": 1, + "type": [ + "null", + "string" + ] + }, + "level": { + "maxLength": 32, + "minLength": 1, + "type": [ + "null", + "string" + ] + }, + "client": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/EventIngestionV3Client" + } + ] + }, + "exception_type": { + "maxLength": 2000, + "minLength": 1, + "type": [ + "null", + "string" + ] + }, + "stack_trace": { + "maxLength": 262144, + "minLength": 1, + "type": [ + "null", + "string" + ] + }, + "stacking": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/EventIngestionV3Stacking" + } + ] + }, + "user": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/EventIngestionV3User" + } + ] + }, + "request": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/EventIngestionV3Request" + } + ] + }, + "environment": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/EventIngestionV3Environment" + } + ] + }, + "data": { + "type": [ + "null", + "object" + ], + "additionalProperties": {} + } + }, + "description": "A compact V3 ingestion event. Organization and project ownership come from\nthe authenticated request rather than the event payload." + }, + "EventIngestionV3Request": { + "type": "object", + "properties": { + "user_agent": { + "type": [ + "null", + "string" + ] + }, + "http_method": { + "type": [ + "null", + "string" + ] + }, + "is_secure": { + "type": [ + "null", + "boolean" + ] + }, + "host": { + "type": [ + "null", + "string" + ] + }, + "port": { + "type": [ + "null", + "integer" + ], + "format": "int32" + }, + "path": { + "type": [ + "null", + "string" + ] + }, + "referrer": { + "type": [ + "null", + "string" + ] + }, + "client_ip_address": { + "type": [ + "null", + "string" + ] + }, + "headers": { + "type": [ + "null", + "object" + ], + "additionalProperties": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "cookies": { + "type": [ + "null", + "object" + ], + "additionalProperties": { + "type": "string" + } + }, + "query_string": { + "type": [ + "null", + "object" + ], + "additionalProperties": { + "type": "string" + } + }, + "post_data": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/JsonElement" + } + ] + }, + "data": { + "type": [ + "null", + "object" + ], + "additionalProperties": {} + } + } + }, + "EventIngestionV3Response": { + "required": [ + "received", + "persisted", + "discarded", + "duplicate", + "blocked", + "invalid", + "errors" + ], + "type": "object", + "properties": { + "received": { + "type": "integer", + "format": "int32" + }, + "persisted": { + "type": "integer", + "format": "int32" + }, + "discarded": { + "type": "integer", + "format": "int32" + }, + "duplicate": { + "type": "integer", + "format": "int32" + }, + "blocked": { + "type": "integer", + "format": "int32" + }, + "invalid": { + "type": "integer", + "format": "int32" + }, + "errors": { + "type": "array", + "items": { + "$ref": "#/components/schemas/EventIngestionV3Error" + } + } + } + }, + "EventIngestionV3Stacking": { + "required": [ + "signature_data" + ], + "type": "object", + "properties": { + "title": { + "maxLength": 1000, + "minLength": 1, + "type": [ + "null", + "string" + ] + }, + "signature_data": { + "maxProperties": 100, + "minProperties": 1, + "type": "object", + "additionalProperties": { + "maxLength": 16384, + "type": "string" + } + } + } + }, + "EventIngestionV3User": { + "type": "object", + "properties": { + "identity": { + "maxLength": 255, + "minLength": 1, + "type": [ + "null", + "string" + ] + }, + "name": { + "maxLength": 255, + "minLength": 1, + "type": [ + "null", + "string" + ] + }, + "data": { + "type": [ + "null", + "object" + ], + "additionalProperties": {} + } + } + }, + "IAggregate": { + "type": "object", + "properties": { + "data": { + "type": "object", + "additionalProperties": {}, + "description": "Additional data associated with the aggregate." + } + }, + "description": "Base interface for aggregation results. Concrete types include ValueAggregate, BucketAggregate, StatsAggregate, etc. See client-side type definitions for full type information." + }, + "JsonElement": {}, + "ProblemDetails": { + "type": "object", + "properties": { + "type": { + "type": [ + "null", + "string" + ] + }, + "title": { + "type": [ + "null", + "string" + ] + }, + "status": { + "type": [ + "null", + "integer" + ], + "format": "int32" + }, + "detail": { + "type": [ + "null", + "string" + ] + }, + "instance": { + "type": [ + "null", + "string" + ] + } + } + } + }, + "securitySchemes": { + "Basic": { + "type": "http", + "description": "Basic HTTP Authentication", + "scheme": "basic" + }, + "Bearer": { + "type": "http", + "description": "Authorization token. Example: \u0022Bearer {apikey}\u0022", + "scheme": "bearer" + }, + "Token": { + "type": "apiKey", + "description": "Authorization token. Example: \u0022Bearer {apikey}\u0022", + "name": "access_token", + "in": "query" + } + } + }, + "security": [ + { + "Bearer": [] + } + ], + "tags": [ + { + "name": "Event Ingestion V3" + } + ] +} \ No newline at end of file diff --git a/tests/Exceptionless.Tests/Api/Data/openapi.json b/tests/Exceptionless.Tests/Api/Data/openapi.json index 68ec2364ce..b32bc08151 100644 --- a/tests/Exceptionless.Tests/Api/Data/openapi.json +++ b/tests/Exceptionless.Tests/Api/Data/openapi.json @@ -8289,6 +8289,14 @@ "schema": { "type": "string" } + }, + { + "name": "X-Exceptionless-Track-Event-Post", + "in": "header", + "description": "Whether to return an event-post identifier that can be polled for terminal processing.", + "schema": { + "type": "boolean" + } } ], "requestBody": { @@ -8657,6 +8665,14 @@ "schema": { "type": "string" } + }, + { + "name": "X-Exceptionless-Track-Event-Post", + "in": "header", + "description": "Whether to return an event-post identifier that can be polled for terminal processing.", + "schema": { + "type": "boolean" + } } ], "requestBody": { @@ -11862,6 +11878,11 @@ "type": "boolean", "description": "Whether the event resulted in the creation of a new stack." }, + "is_regression": { + "type": "boolean", + "description": "Whether this occurrence caused a fixed stack to regress.", + "readOnly": true + }, "created_utc": { "type": "string", "description": "The date that the event was created in the system.", @@ -12213,6 +12234,16 @@ "description": "The date the stack was fixed.", "format": "date-time" }, + "regression_event_id": { + "maxLength": 24, + "minLength": 24, + "pattern": "^[a-fA-F0-9]{24}$", + "type": [ + "null", + "string" + ], + "description": "The event that most recently caused this stack to regress." + }, "title": { "maxLength": 1000, "minLength": 0, diff --git a/tests/Exceptionless.Tests/Api/EndpointManifestTests.cs b/tests/Exceptionless.Tests/Api/EndpointManifestTests.cs index 8d34d5c2c9..a4c3108cd6 100644 --- a/tests/Exceptionless.Tests/Api/EndpointManifestTests.cs +++ b/tests/Exceptionless.Tests/Api/EndpointManifestTests.cs @@ -17,11 +17,8 @@ public EndpointManifestTests(AppWebHostFactory factory) } [Fact] - public async Task MapApiEndpoints_DefaultServices_MatchesSnapshot() + public Task MapApiEndpoints_DefaultServices_MatchesSnapshot() { - // Arrange - await _factory.Server.WaitForReadyAsync(); - // Act var manifest = _factory.Server.Services.GetRequiredService().Endpoints .OfType() @@ -35,7 +32,7 @@ public async Task MapApiEndpoints_DefaultServices_MatchesSnapshot() string actualJson = SnapshotTestHelper.Serialize(manifest); // Assert - await SnapshotTestHelper.AssertMatchesJsonSnapshotAsync("endpoint-manifest.json", actualJson, TestContext.Current.CancellationToken); + return SnapshotTestHelper.AssertMatchesJsonSnapshotAsync("endpoint-manifest.json", actualJson, TestContext.Current.CancellationToken); } private static bool IsApiContractEndpoint(RouteEndpoint endpoint) diff --git a/tests/Exceptionless.Tests/Api/Endpoints/EventEndpointTests.cs b/tests/Exceptionless.Tests/Api/Endpoints/EventEndpointTests.cs index 00be49ac24..dac9d9d2e1 100644 --- a/tests/Exceptionless.Tests/Api/Endpoints/EventEndpointTests.cs +++ b/tests/Exceptionless.Tests/Api/Endpoints/EventEndpointTests.cs @@ -1560,8 +1560,8 @@ await SendRequestAsync(r => r Assert.Equal(blocked, secondBucketUsageInfo.CurrentUsage.Blocked); Assert.Equal(0, secondBucketUsageInfo.CurrentUsage.TooBig); - // move forward again and run process usage job - TimeProvider.Advance(TimeSpan.FromMinutes(6)); + // Move beyond the full previous-bucket grace window used by V3 settlements. + TimeProvider.Advance(TimeSpan.FromMinutes(11)); var processUsageJob = GetService(); Assert.Equal(JobResult.Success, await processUsageJob.RunAsync(TestCancellationToken)); @@ -1821,8 +1821,8 @@ await SendRequestAsync(r => r Assert.True(viewOrganization.IsThrottled); Assert.False(viewOrganization.IsOverMonthlyLimit); - // move forward again and run process usage job - TimeProvider.Advance(TimeSpan.FromMinutes(6)); + // Move beyond the full previous-bucket grace window used by V3 settlements. + TimeProvider.Advance(TimeSpan.FromMinutes(11)); var processUsageJob = GetService(); Assert.Equal(JobResult.Success, await processUsageJob.RunAsync(TestCancellationToken)); diff --git a/tests/Exceptionless.Tests/Api/OpenApiSnapshotTests.cs b/tests/Exceptionless.Tests/Api/OpenApiSnapshotTests.cs index 9561adc053..7e6787a8a7 100644 --- a/tests/Exceptionless.Tests/Api/OpenApiSnapshotTests.cs +++ b/tests/Exceptionless.Tests/Api/OpenApiSnapshotTests.cs @@ -1,4 +1,6 @@ using System.Text.Json; +using Exceptionless.Core.Models.Ingestion; +using Exceptionless.Web.Utility; using Microsoft.AspNetCore.TestHost; using Xunit; @@ -35,6 +37,21 @@ public async Task GetOpenApiJson_Default_MatchesSnapshot() await SnapshotTestHelper.AssertMatchesJsonSnapshotAsync("openapi.json", actualJson, TestContext.Current.CancellationToken); } + [Fact] + public async Task GetOpenApiJson_V3_MatchesSnapshot() + { + string actualJson = await GetOpenApiJsonAsync("v3"); + + if (String.Equals(Environment.GetEnvironmentVariable("UPDATE_SNAPSHOTS"), "true", StringComparison.OrdinalIgnoreCase)) + { + string sourcePath = Path.GetFullPath(Path.Join(AppContext.BaseDirectory, "..", "..", "..", "Api", "Data", "openapi-v3.json")); + await File.WriteAllTextAsync(sourcePath, actualJson, TestContext.Current.CancellationToken); + return; + } + + await SnapshotTestHelper.AssertMatchesJsonSnapshotAsync("openapi-v3.json", actualJson, TestContext.Current.CancellationToken); + } + [Fact] public async Task GetOpenApiJson_Default_ContainsExpectedRoutesOperationsAndResponses() { @@ -63,6 +80,33 @@ public async Task GetOpenApiJson_Default_ContainsExpectedRoutesOperationsAndResp Assert.True(userDescriptionPath.TryGetProperty("post", out var userDescriptionPost)); Assert.True(userDescriptionPost.TryGetProperty("requestBody", out _)); AssertResponseCodes(userDescriptionPost, "202"); + + Assert.True(paths.TryGetProperty("/api/v2/projects/{projectId}/events", out var projectEventsPath)); + Assert.True(projectEventsPath.TryGetProperty("post", out var projectEventsPost)); + Assert.Contains(projectEventsPost.GetProperty("parameters").EnumerateArray(), parameter => + parameter.GetProperty("name").GetString() == Headers.TrackEventPost + && parameter.GetProperty("in").GetString() == "header"); + + Assert.False(paths.TryGetProperty("/api/v2/projects/{projectId}/events/posts/status", out _)); + Assert.False(paths.TryGetProperty("/api/v3/events", out _)); + + using var v3Document = await GetOpenApiDocumentAsync("v3"); + var v3Paths = v3Document.RootElement.GetProperty("paths"); + Assert.True(v3Paths.TryGetProperty("/api/v3/events", out var ingestionPath)); + Assert.True(ingestionPath.TryGetProperty("post", out var ingestionPost)); + Assert.True(ingestionPost.TryGetProperty("requestBody", out var ingestionRequestBody)); + Assert.True(ingestionRequestBody.GetProperty("content").TryGetProperty("application/x-ndjson", out _)); + AssertResponseCodes(ingestionPost, "200", "400", "401", "402", "403", "404", "413", "415", "422", "429", "503"); + + Assert.True(v3Paths.TryGetProperty("/api/v3/projects/{projectId}/events", out var projectIngestionPath)); + Assert.True(projectIngestionPath.TryGetProperty("post", out _)); + Assert.False(v3Paths.TryGetProperty("/api/v3/projects/{projectId}/events/processing/status", out _)); + Assert.Equal(2, v3Paths.EnumerateObject().Count()); + + var security = v3Document.RootElement.GetProperty("security"); + var requirement = Assert.Single(security.EnumerateArray()); + Assert.True(requirement.TryGetProperty("Bearer", out var scopes)); + Assert.Empty(scopes.EnumerateArray()); } [Fact] @@ -96,6 +140,72 @@ public async Task GetOpenApiJson_Default_ContainsExpectedSchemasAndSecuritySchem Assert.Equal("access_token", token.GetProperty("name").GetString()); } + [Fact] + public async Task GetOpenApiJson_InternalIngestionFields_AreExcluded() + { + using var document = await GetOpenApiDocumentAsync(); + var persistentEvent = document.RootElement.GetProperty("components").GetProperty("schemas").GetProperty("PersistentEvent"); + string[] required = persistentEvent.GetProperty("required").EnumerateArray().Select(value => value.GetString()!).ToArray(); + var properties = persistentEvent.GetProperty("properties"); + Assert.DoesNotContain("is_regression", required); + Assert.True(properties.GetProperty("is_regression").GetProperty("readOnly").GetBoolean()); + Assert.False(properties.TryGetProperty("ingestion_is_regression_candidate", out _)); + Assert.False(properties.TryGetProperty("ingestion_regression_fixed_in_version", out _)); + Assert.False(properties.TryGetProperty("ingestion_regression_date_fixed", out _)); + + var stackProperties = document.RootElement.GetProperty("components").GetProperty("schemas").GetProperty("Stack").GetProperty("properties"); + Assert.False(stackProperties.TryGetProperty("ingestion_first_event_id", out _)); + Assert.False(stackProperties.TryGetProperty("ingestion_stack_usage_sequence", out _)); + Assert.Contains("is_first_occurrence", required); + Assert.Contains("created_utc", required); + } + + [Fact] + public async Task GetOpenApiJson_V3ObjectMetadata_IsNullableObjectWithArbitraryProperties() + { + using var document = await GetOpenApiDocumentAsync("v3"); + var schemas = document.RootElement.GetProperty("components").GetProperty("schemas"); + string[] objectDataSchemas = ["EventIngestionV3Event", "EventIngestionV3User", "EventIngestionV3Request", "EventIngestionV3Environment"]; + + foreach (string schemaName in objectDataSchemas) + { + var data = schemas.GetProperty(schemaName).GetProperty("properties").GetProperty("data"); + string[] types = data.GetProperty("type").EnumerateArray().Select(value => value.GetString()!).ToArray(); + Assert.Contains("object", types); + Assert.Contains("null", types); + Assert.True(data.TryGetProperty("additionalProperties", out var additionalProperties)); + Assert.Equal(JsonValueKind.Object, additionalProperties.ValueKind); + Assert.False(data.TryGetProperty("oneOf", out _)); + } + + var postData = schemas.GetProperty("EventIngestionV3Request").GetProperty("properties").GetProperty("post_data"); + Assert.True(postData.TryGetProperty("oneOf", out _)); + } + + [Fact] + public async Task GetOpenApiJson_V3StringLimits_MatchDurableModels() + { + using var document = await GetOpenApiDocumentAsync("v3"); + var schemas = document.RootElement.GetProperty("components").GetProperty("schemas"); + var eventProperties = schemas.GetProperty("EventIngestionV3Event").GetProperty("properties"); + var message = eventProperties.GetProperty("message"); + var referenceId = eventProperties.GetProperty("reference_id"); + var tags = eventProperties.GetProperty("tags"); + var title = schemas.GetProperty("EventIngestionV3Stacking").GetProperty("properties").GetProperty("title"); + + Assert.Equal(EventIngestionV3Limits.MaximumMessageLength, message.GetProperty("maxLength").GetInt32()); + Assert.Equal(EventIngestionV3Limits.MinimumReferenceIdLength, referenceId.GetProperty("minLength").GetInt32()); + Assert.Equal(EventIngestionV3Limits.MaximumReferenceIdLength, referenceId.GetProperty("maxLength").GetInt32()); + Assert.Equal(EventIngestionV3Limits.MaximumTags, tags.GetProperty("maxItems").GetInt32()); + Assert.Equal(EventIngestionV3Limits.MaximumTagLength, tags.GetProperty("items").GetProperty("maxLength").GetInt32()); + Assert.Equal(EventIngestionV3Limits.MaximumStackTitleLength, title.GetProperty("maxLength").GetInt32()); + + var signatureData = schemas.GetProperty("EventIngestionV3Stacking").GetProperty("properties").GetProperty("signature_data"); + Assert.Equal(1, signatureData.GetProperty("minProperties").GetInt32()); + Assert.Equal(EventIngestionV3Limits.MaximumMetadataEntries, signatureData.GetProperty("maxProperties").GetInt32()); + Assert.Equal(EventIngestionV3Limits.MaximumMetadataValueLength, signatureData.GetProperty("additionalProperties").GetProperty("maxLength").GetInt32()); + } + [Fact] public async Task GetOpenApiJson_MigratedContracts_PreserveControllerMetadata() { @@ -195,17 +305,17 @@ public async Task GetOpenApiJson_MigratedContracts_PreserveControllerMetadata() } } - private async Task GetOpenApiDocumentAsync() + private async Task GetOpenApiDocumentAsync(string documentName = "v2") { - string json = await GetOpenApiJsonAsync(); + string json = await GetOpenApiJsonAsync(documentName); return JsonDocument.Parse(json); } - private async Task GetOpenApiJsonAsync() + private async Task GetOpenApiJsonAsync(string documentName = "v2") { await _factory.Server.WaitForReadyAsync(); using var client = _factory.CreateClient(); - using var response = await client.GetAsync("/docs/v2/openapi.json", TestContext.Current.CancellationToken); + using var response = await client.GetAsync($"/docs/{documentName}/openapi.json", TestContext.Current.CancellationToken); response.EnsureSuccessStatusCode(); string json = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); diff --git a/tests/Exceptionless.Tests/AppWebHostFactory.cs b/tests/Exceptionless.Tests/AppWebHostFactory.cs index fa1d2f73ee..1eeaa3bf6e 100644 --- a/tests/Exceptionless.Tests/AppWebHostFactory.cs +++ b/tests/Exceptionless.Tests/AppWebHostFactory.cs @@ -1,6 +1,8 @@ using System.Collections.Concurrent; using System.Net; +using System.Net.Sockets; using Aspire.Hosting; +using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Testing; using Exceptionless.Core; using Exceptionless.Core.Extensions; @@ -47,17 +49,42 @@ public async ValueTask InitializeAsync() await WaitForElasticsearchAsync(new Uri(SharedElasticsearchUrl)); } + public async Task GetRedisConnectionStringAsync(CancellationToken cancellationToken = default) + { + var app = await s_sharedAppHost.Value; + return await app.GetConnectionStringAsync("Redis", cancellationToken) + ?? throw new InvalidOperationException("Unable to get the shared Redis connection string."); + } + private static async Task StartSharedAppHostAsync() { var appHost = await DistributedApplicationTestingBuilder.CreateAsync( ["services-only", "--Logging:LogLevel:Default=Warning"], CancellationToken.None); + // Tests share developer machines with other Aspire stacks that may already own 6379. + // Let Aspire allocate an isolated host port while retaining Redis's container port. + var redis = appHost.Resources.OfType().Single(resource => resource.Name == "Redis"); + redis.PrimaryEndpoint.EndpointAnnotation.Port = GetFreePort(); var app = await appHost.BuildAsync(CancellationToken.None); await app.StartAsync(CancellationToken.None); return app; } + private static int GetFreePort() + { + var listener = new TcpListener(IPAddress.Loopback, 0); + try + { + listener.Start(); + return ((IPEndPoint)listener.LocalEndpoint).Port; + } + finally + { + listener.Stop(); + } + } + private static async Task WaitForElasticsearchAsync(Uri elasticsearchUri) { using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(1) }; diff --git a/tests/Exceptionless.Tests/Benchmarks/IngestionLoadTests.cs b/tests/Exceptionless.Tests/Benchmarks/IngestionLoadTests.cs new file mode 100644 index 0000000000..c185d414a7 --- /dev/null +++ b/tests/Exceptionless.Tests/Benchmarks/IngestionLoadTests.cs @@ -0,0 +1,435 @@ +using System.IO.Compression; +using System.Net; +using System.Text.Json; +using Exceptionless.Ingestion.Load; +using Xunit; + +namespace Exceptionless.Tests.Benchmarks; + +public sealed class IngestionLoadTests +{ + [Fact] + public void ParseBothProtocolsAndBatchOptions() + { + LoadOptions options = LoadOptions.Parse([ + "--base-url", "https://localhost:7131/", + "--project-id", "537650f3b77efe23a47914f4", + "--submission-token", "submission", + "--read-token", "read", + "--protocol", "both", + "--events", "1000", + "--batch-size", "250", + "--expected-persisted", "900", + "--completion-poll-concurrency", "2" + ]); + + Assert.Equal([IngestionProtocol.V2, IngestionProtocol.V3], options.Protocols); + Assert.Equal(1000, options.EventCount); + Assert.Equal(250, options.BatchSize); + Assert.Equal(900, options.ExpectedPersisted); + Assert.Equal(StackScenario.Hot, options.StackScenario); + Assert.Equal(2, options.CompletionPollConcurrency); + } + + [Fact] + public void Parse_DiscardPercentWithoutOverride_DerivesExpectedPersisted() + { + LoadOptions options = LoadOptions.Parse([ + "--base-url", "https://localhost:7131/", + "--project-id", "537650f3b77efe23a47914f4", + "--submission-token", "submission", + "--read-token", "read", + "--protocol", "both", + "--events", "250", + "--discard-percent", "90" + ]); + + Assert.Equal(20, options.ExpectedPersisted); + } + + [Fact] + public void Parse_NewStackDiscardComparison_Throws() + { + var exception = Assert.Throws(() => LoadOptions.Parse([ + "--base-url", "https://localhost:7131/", + "--project-id", "537650f3b77efe23a47914f4", + "--submission-token", "submission", + "--read-token", "read", + "--protocol", "both", + "--stack-scenario", "new", + "--discard-percent", "10" + ])); + + Assert.Contains("stack-scenario hot", exception.Message); + } + + [Fact] + public void Parse_V3WithoutPersistedEvents_DoesNotRequireReadCredential() + { + LoadOptions options = LoadOptions.Parse([ + "--base-url", "https://localhost:7131/", + "--project-id", "537650f3b77efe23a47914f4", + "--submission-token", "submission", + "--protocol", "v3", + "--expected-persisted", "0" + ]); + + Assert.Equal([IngestionProtocol.V3], options.Protocols); + Assert.Null(options.ReadToken); + } + + [Fact] + public async Task V2SingleEventWritesOneObjectAsync() + { + byte[] payload = await SerializeAsync(IngestionProtocol.V2, count: 1); + using JsonDocument json = JsonDocument.Parse(payload); + + Assert.Equal(JsonValueKind.Object, json.RootElement.ValueKind); + Assert.Equal("benchmark-reference-00000000", json.RootElement.GetProperty("reference_id").GetString()); + Assert.Equal("benchmark-reference", json.RootElement.GetProperty("tags")[0].GetString()); + Assert.Equal("Load.test.ActiveException0", json.RootElement.GetProperty("data").GetProperty("@simple_error").GetProperty("type").GetString()); + } + + [Fact] + public async Task V2BatchWritesOneArrayAsync() + { + byte[] payload = await SerializeAsync(IngestionProtocol.V2, count: 100); + using JsonDocument json = JsonDocument.Parse(payload); + + Assert.Equal(JsonValueKind.Array, json.RootElement.ValueKind); + Assert.Equal(100, json.RootElement.GetArrayLength()); + } + + [Fact] + public async Task V3BatchWritesTopLevelValuesAsync() + { + byte[] payload = await SerializeAsync(IngestionProtocol.V3, count: 100); + string[] lines = System.Text.Encoding.UTF8.GetString(payload).Split('\n', StringSplitOptions.RemoveEmptyEntries); + + Assert.Equal(100, lines.Length); + using JsonDocument first = JsonDocument.Parse(lines[0]); + Assert.Equal("benchmark-reference-00000000", first.RootElement.GetProperty("reference_id").GetString()); + Assert.Equal("benchmark-reference", first.RootElement.GetProperty("tags")[0].GetString()); + Assert.Equal("Load.test.ActiveException0", first.RootElement.GetProperty("exception_type").GetString()); + } + + [Fact] + public async Task GzipTracksRawAndTransferredBytesAsync() + { + LoadOptions options = CreateOptions() with { Compression = "gzip" }; + using var content = new StreamingEventContent(options, IngestionProtocol.V2, "benchmark-reference", "test", DateTimeOffset.UtcNow, 0, 100); + byte[] compressed = await content.ReadAsByteArrayAsync(TestContext.Current.CancellationToken); + await using var source = new MemoryStream(compressed); + await using var gzip = new GZipStream(source, CompressionMode.Decompress); + await using var uncompressed = new MemoryStream(); + await gzip.CopyToAsync(uncompressed, TestContext.Current.CancellationToken); + + Assert.Equal(compressed.Length, content.TransferredBytes); + Assert.Equal(uncompressed.Length, content.UncompressedBytes); + Assert.True(content.TransferredBytes < content.UncompressedBytes); + } + + [Fact] + public async Task RunAsync_V2AllDiscarded_WaitsForTrackedTerminalProcessing() + { + var handler = new CompletedV2Handler(observedPersisted: 0); + LoadOptions options = CreateOptions() with + { + Protocols = [IngestionProtocol.V2], + EventCount = 4, + ExpectedPersisted = 0, + BatchSize = 1, + Trials = 1, + WarmupEvents = 0, + DiscardPercent = 100 + }; + + int result = await new IngestionLoadRunner(options, handler).RunAsync(); + + Assert.Equal(0, result); + Assert.Equal(4, handler.IngestionRequests); + Assert.True(handler.StatusRequests > 0); + Assert.Equal(0, handler.CountRequests); + } + + [Fact] + public async Task RunAsync_V2MixedDiscarded_WaitsForTerminalAndQueryVisibleCompletion() + { + var handler = new CompletedV2Handler(observedPersisted: 50); + LoadOptions options = CreateOptions() with + { + Protocols = [IngestionProtocol.V2], + EventCount = 100, + ExpectedPersisted = 50, + BatchSize = 100, + Trials = 1, + WarmupEvents = 0, + DiscardPercent = 50 + }; + + int result = await new IngestionLoadRunner(options, handler).RunAsync(); + + Assert.Equal(0, result); + Assert.Equal(1, handler.IngestionRequests); + Assert.True(handler.StatusRequests > 0); + Assert.True(handler.CountRequests > 0); + } + + [Fact] + public async Task RunAsync_V3MixedDiscarded_WaitsForFullProcessingAndQueryVisibility() + { + var handler = new CompletedV3Handler(observedPersisted: 50); + LoadOptions options = CreateOptions() with + { + Protocols = [IngestionProtocol.V3], + EventCount = 100, + ExpectedPersisted = 50, + BatchSize = 100, + Trials = 1, + WarmupEvents = 0, + DiscardPercent = 50 + }; + + int result = await new IngestionLoadRunner(options, handler).RunAsync(); + + Assert.Equal(0, result); + Assert.Equal(1, handler.IngestionRequests); + Assert.True(handler.StatusRequests >= 2); + Assert.True(handler.CountRequests > 0); + } + + [Fact] + public async Task RunAsync_V3LargeCompletionSet_BoundsObserverConcurrencyAndStopsPollingCompletedChunks() + { + var handler = new CompletedV3Handler(observedPersisted: 2500, statusDelay: TimeSpan.FromMilliseconds(10)); + LoadOptions options = CreateOptions() with + { + Protocols = [IngestionProtocol.V3], + EventCount = 2500, + ExpectedPersisted = 2500, + BatchSize = 2500, + Trials = 1, + WarmupEvents = 0, + CompletionPollConcurrency = 2 + }; + + int result = await new IngestionLoadRunner(options, handler).RunAsync(); + + Assert.Equal(0, result); + Assert.Equal(1, handler.IngestionRequests); + Assert.Equal(4, handler.StatusRequests); + Assert.InRange(handler.MaximumConcurrentStatusRequests, 1, 2); + Assert.InRange(handler.StatusIdentifierReads, 2501, 3500); + } + + [Fact] + public async Task RunAsync_WithResultsPath_WritesSecretFreeEvidence() + { + string resultsPath = Path.Combine(Path.GetTempPath(), $"exceptionless-ingestion-{Guid.NewGuid():N}.json"); + try + { + var handler = new CompletedV2Handler(observedPersisted: 0); + LoadOptions options = CreateOptions() with + { + Protocols = [IngestionProtocol.V2], + EventCount = 1, + ExpectedPersisted = 0, + BatchSize = 1, + Trials = 1, + WarmupEvents = 0, + DiscardPercent = 100, + SubmissionToken = "secret-submission-token-123", + ReadToken = "secret-read-token-456", + ResultsPath = resultsPath, + EnvironmentLabel = "test topology" + }; + + Assert.Equal(0, await new IngestionLoadRunner(options, handler).RunAsync()); + + string json = await File.ReadAllTextAsync(resultsPath, TestContext.Current.CancellationToken); + using JsonDocument evidence = JsonDocument.Parse(json); + Assert.Equal("3", evidence.RootElement.GetProperty("schema_version").GetString()); + Assert.Equal("test topology", evidence.RootElement.GetProperty("environment").GetProperty("label").GetString()); + JsonElement recordedResult = evidence.RootElement.GetProperty("results")[0]; + Assert.Equal("event_post", recordedResult.GetProperty("completion_identifier_kind").GetString()); + Assert.Equal(1, recordedResult.GetProperty("completion_tracked_identifiers").GetInt64()); + Assert.True(recordedResult.GetProperty("completion_status_requests").GetInt32() >= 1); + Assert.True(recordedResult.GetProperty("completion_identifier_reads").GetInt64() >= 1); + Assert.DoesNotContain(options.SubmissionToken, json, StringComparison.Ordinal); + Assert.DoesNotContain(options.ReadToken!, json, StringComparison.Ordinal); + } + finally + { + File.Delete(resultsPath); + } + } + + private static async Task SerializeAsync(IngestionProtocol protocol, int count) + { + using var content = new StreamingEventContent(CreateOptions(), protocol, "benchmark-reference", "test", DateTimeOffset.UtcNow, 0, count); + return await content.ReadAsByteArrayAsync(TestContext.Current.CancellationToken); + } + + private static LoadOptions CreateOptions() => new( + new Uri("https://localhost:7131/"), + "537650f3b77efe23a47914f4", + "submission", + "read", + null, + null, + [IngestionProtocol.V2, IngestionProtocol.V3], + LoadEventType.Error, + StackScenario.Hot, + 100, + 100, + 4, + 100, + 3, + 0, + 10, + 0, + "none", + "test", + null, + null, + new string('x', 64), + TimeSpan.FromMinutes(1), + TimeSpan.FromMilliseconds(10), + 4); + + private sealed class CompletedV2Handler(long observedPersisted) : HttpMessageHandler + { + private int _nextId; + private int _ingestionRequests; + private int _statusRequests; + private int _countRequests; + public int IngestionRequests => Volatile.Read(ref _ingestionRequests); + public int StatusRequests => Volatile.Read(ref _statusRequests); + public int CountRequests => Volatile.Read(ref _countRequests); + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + string path = request.RequestUri!.AbsolutePath; + if (request.Method == HttpMethod.Post && path.EndsWith("/events/posts/status", StringComparison.Ordinal)) + { + Interlocked.Increment(ref _statusRequests); + await using Stream body = await request.Content!.ReadAsStreamAsync(cancellationToken); + using JsonDocument document = await JsonDocument.ParseAsync(body, cancellationToken: cancellationToken); + int requested = document.RootElement.GetProperty("ids").GetArrayLength(); + return JsonResponse(new { requested, queued = 0, completed = requested, unknown = 0 }); + } + + if (request.Method == HttpMethod.Get && path.EndsWith("/events/count", StringComparison.Ordinal)) + { + Interlocked.Increment(ref _countRequests); + return JsonResponse(new { total = observedPersisted }); + } + + if (request.Method == HttpMethod.Post && path.EndsWith("/events", StringComparison.Ordinal)) + { + Interlocked.Increment(ref _ingestionRequests); + _ = await request.Content!.ReadAsByteArrayAsync(cancellationToken); + var response = new HttpResponseMessage(HttpStatusCode.Accepted); + response.Headers.Add("X-Exceptionless-Event-Post-Id", $"post-{Interlocked.Increment(ref _nextId)}"); + return response; + } + + return new HttpResponseMessage(HttpStatusCode.NotFound); + } + + private static HttpResponseMessage JsonResponse(T value) + { + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(JsonSerializer.Serialize(value), System.Text.Encoding.UTF8, "application/json") + }; + } + } + + private sealed class CompletedV3Handler(long observedPersisted, TimeSpan? statusDelay = null) : HttpMessageHandler + { + private int _ingestionRequests; + private int _statusRequests; + private int _countRequests; + private int _activeStatusRequests; + private int _maximumConcurrentStatusRequests; + private long _statusIdentifierReads; + public int IngestionRequests => Volatile.Read(ref _ingestionRequests); + public int StatusRequests => Volatile.Read(ref _statusRequests); + public int CountRequests => Volatile.Read(ref _countRequests); + public int MaximumConcurrentStatusRequests => Volatile.Read(ref _maximumConcurrentStatusRequests); + public long StatusIdentifierReads => Volatile.Read(ref _statusIdentifierReads); + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + string path = request.RequestUri!.AbsolutePath; + if (request.Method == HttpMethod.Post && path.EndsWith("/events/processing/status", StringComparison.Ordinal)) + { + int active = Interlocked.Increment(ref _activeStatusRequests); + UpdateMaximum(ref _maximumConcurrentStatusRequests, active); + try + { + int statusRequest = Interlocked.Increment(ref _statusRequests); + await using Stream body = await request.Content!.ReadAsStreamAsync(cancellationToken); + using JsonDocument document = await JsonDocument.ParseAsync(body, cancellationToken: cancellationToken); + int requested = document.RootElement.GetProperty("client_ids").GetArrayLength(); + Interlocked.Add(ref _statusIdentifierReads, requested); + if (statusDelay.HasValue) + await Task.Delay(statusDelay.Value, cancellationToken); + return JsonResponse(statusRequest == 1 + ? new { requested, pending = requested, completed = 0 } + : new { requested, pending = 0, completed = requested }); + } + finally + { + Interlocked.Decrement(ref _activeStatusRequests); + } + } + + if (request.Method == HttpMethod.Get && path.EndsWith("/events/count", StringComparison.Ordinal)) + { + Interlocked.Increment(ref _countRequests); + return JsonResponse(new { total = observedPersisted }); + } + + if (request.Method == HttpMethod.Post && path.EndsWith("/events", StringComparison.Ordinal)) + { + Interlocked.Increment(ref _ingestionRequests); + string payload = await request.Content!.ReadAsStringAsync(cancellationToken); + int received = payload.Split('\n', StringSplitOptions.RemoveEmptyEntries).Length; + return JsonResponse(new + { + received, + persisted = (int)observedPersisted, + discarded = received - (int)observedPersisted, + duplicate = 0, + blocked = 0, + invalid = 0, + errors = Array.Empty() + }); + } + + return new HttpResponseMessage(HttpStatusCode.NotFound); + } + + private static HttpResponseMessage JsonResponse(T value) + { + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(JsonSerializer.Serialize(value), System.Text.Encoding.UTF8, "application/json") + }; + } + + private static void UpdateMaximum(ref int target, int value) + { + int current = Volatile.Read(ref target); + while (value > current) + { + int observed = Interlocked.CompareExchange(ref target, value, current); + if (observed == current) + return; + current = observed; + } + } + } +} diff --git a/tests/Exceptionless.Tests/Endpoints/EventIngestionV3EndpointTests.cs b/tests/Exceptionless.Tests/Endpoints/EventIngestionV3EndpointTests.cs new file mode 100644 index 0000000000..6233c8821b --- /dev/null +++ b/tests/Exceptionless.Tests/Endpoints/EventIngestionV3EndpointTests.cs @@ -0,0 +1,817 @@ +using System.IO.Compression; +using System.Net; +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; +using System.Threading.RateLimiting; +using Exceptionless.Core; +using Exceptionless.Core.Extensions; +using Exceptionless.Core.Models; +using Exceptionless.Core.Models.Ingestion; +using Exceptionless.Core.Jobs.WorkItemHandlers; +using Exceptionless.Core.Repositories; +using Exceptionless.Core.Services; +using Exceptionless.Core.Utility; +using Exceptionless.Tests.Utility; +using Exceptionless.Web.Utility; +using Foundatio.Caching; +using Foundatio.Jobs; +using Foundatio.Queues; +using Foundatio.Repositories; +using Xunit; + +namespace Exceptionless.Tests.Endpoints; + +public sealed class EventIngestionV3EndpointTests : IntegrationTestsBase +{ + private readonly IEventRepository _eventRepository; + private readonly IProjectRepository _projectRepository; + private readonly IStackRepository _stackRepository; + + public EventIngestionV3EndpointTests(ITestOutputHelper output, AppWebHostFactory factory) : base(output, factory) + { + GetService().EventIngestionV3.Enabled = true; + _eventRepository = GetService(); + _projectRepository = GetService(); + _stackRepository = GetService(); + _ = GetService(); + } + + protected override async Task ResetDataAsync() + { + await base.ResetDataAsync(); + await GetService().CreateDataAsync(); + } + + [Fact] + public async Task Post_ChunkedTopLevelValues_PersistsEveryEventInline() + { + const string payload = """ + {"id":"v3-stream-event-0001","type":"log","source":"Example.Service","message":"first","reference_id":"v3-stream-ref-0001"} + {"id":"v3-stream-event-0002","type":"log","source":"Example.Service","message":"second","reference_id":"v3-stream-ref-0002"} + """; + + using var content = new UnknownLengthJsonContent(Encoding.UTF8.GetBytes(payload)); + using HttpResponseMessage httpResponse = await PostAsync(content); + EventIngestionV3Response response = await DeserializeAsync(httpResponse); + + Assert.Equal(HttpStatusCode.OK, httpResponse.StatusCode); + Assert.Equal(2, response.Received); + Assert.Equal(2, response.Persisted); + Assert.Equal(0, response.Discarded); + await RefreshDataAsync(); + Assert.Single((await _eventRepository.GetByReferenceIdAsync(TestConstants.ProjectId, "v3-stream-ref-0001")).Documents); + Assert.Single((await _eventRepository.GetByReferenceIdAsync(TestConstants.ProjectId, "v3-stream-ref-0002")).Documents); + } + + [Fact] + public async Task Post_GzipError_ParsesStructuredStackOnServer() + { + const string payload = """ + {"id":"v3-gzip-error-0001","type":"error","message":"failed","reference_id":"v3-gzip-ref-0001","exception_type":"System.InvalidOperationException","stack_trace":"at Example.OrderService.Save() in /src/OrderService.cs:line 42"} + """; + byte[] compressed; + await using (var output = new MemoryStream()) + { + await using (var gzip = new GZipStream(output, CompressionMode.Compress, leaveOpen: true)) + await gzip.WriteAsync(Encoding.UTF8.GetBytes(payload), TestCancellationToken); + compressed = output.ToArray(); + } + + using var content = new ByteArrayContent(compressed); + content.Headers.ContentType = new MediaTypeHeaderValue("application/x-ndjson"); + content.Headers.ContentEncoding.Add("gzip"); + using HttpResponseMessage httpResponse = await PostAsync(content); + EventIngestionV3Response response = await DeserializeAsync(httpResponse); + + Assert.Equal(1, response.Persisted); + await RefreshDataAsync(); + var ev = Assert.Single((await _eventRepository.GetByReferenceIdAsync(TestConstants.ProjectId, "v3-gzip-ref-0001")).Documents); + Assert.NotNull(ev); + var error = ev.GetError(GetService(), GetService>()); + var frame = Assert.Single(error!.StackTrace!); + Assert.Equal("Example", frame.DeclaringNamespace); + Assert.Equal("OrderService", frame.DeclaringType); + Assert.Equal("Save", frame.Name); + Assert.Equal(42, frame.LineNumber); + } + + [Fact] + public async Task Post_MalformedGzipBody_ReturnsBadRequest() + { + using var content = new ByteArrayContent("not-a-gzip-stream"u8.ToArray()); + content.Headers.ContentType = new MediaTypeHeaderValue("application/x-ndjson"); + content.Headers.ContentEncoding.Add("gzip"); + + using HttpResponseMessage response = await PostAsync(content); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + Assert.Equal("application/problem+json", response.Content.Headers.ContentType?.MediaType); + } + + [Fact] + public async Task Post_MalformedBrotliBody_ReturnsBadRequest() + { + using var content = new ByteArrayContent("not-a-brotli-stream"u8.ToArray()); + content.Headers.ContentType = new MediaTypeHeaderValue("application/x-ndjson"); + content.Headers.ContentEncoding.Add("br"); + + using HttpResponseMessage response = await PostAsync(content); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + Assert.Equal("application/problem+json", response.Content.Headers.ContentType?.MediaType); + } + + [Fact] + public async Task Post_ClientMetadata_PersistsFirstClassEventData() + { + const string payload = """ + {"id":"v3-client-metadata-0001","type":"log","message":"started","reference_id":"v3-client-metadata-ref-0001","version":"3.4.0","level":"info","client":{"name":"exceptionless.go","version":"1.2.0"}} + """; + + using HttpResponseMessage httpResponse = await PostAsync(new StringContent(payload, Encoding.UTF8, "application/x-ndjson")); + EventIngestionV3Response response = await DeserializeAsync(httpResponse); + + Assert.Equal(1, response.Persisted); + await RefreshDataAsync(); + var ev = Assert.Single((await _eventRepository.GetByReferenceIdAsync(TestConstants.ProjectId, "v3-client-metadata-ref-0001")).Documents); + Assert.Equal("3.4.0", ev.GetVersion()); + Assert.Equal("info", ev.GetLevel()); + var client = ev.GetSubmissionClient( + GetService(), + GetService>()); + Assert.NotNull(client); + Assert.Equal("exceptionless.go", client.UserAgent); + Assert.Equal("1.2.0", client.Version); + } + + [Fact] + public async Task Post_ValuesAtDurableLimits_PersistsWithoutTruncationOrDropping() + { + string message = new('m', EventIngestionV3Limits.MaximumMessageLength); + string referenceId = new('r', EventIngestionV3Limits.MaximumReferenceIdLength); + string tag = new('t', EventIngestionV3Limits.MaximumTagLength); + string title = new('s', EventIngestionV3Limits.MaximumStackTitleLength); + var source = new EventIngestionV3Event + { + Id = "v3-durable-boundaries-0001", + Type = Event.KnownTypes.Log, + Message = message, + ReferenceId = referenceId, + Tags = [tag], + Stacking = new EventIngestionV3Stacking + { + Title = title, + SignatureData = new Dictionary { ["boundary"] = "exact" } + } + }; + string payload = JsonSerializer.Serialize( + source, + Exceptionless.Core.Serialization.EventIngestionJsonContext.Default.EventIngestionV3Event); + + using HttpResponseMessage httpResponse = await PostAsync(new StringContent(payload, Encoding.UTF8, "application/x-ndjson")); + EventIngestionV3Response response = await DeserializeAsync(httpResponse); + + Assert.Equal(HttpStatusCode.OK, httpResponse.StatusCode); + Assert.Equal(1, response.Persisted); + await RefreshDataAsync(); + var ev = Assert.Single((await _eventRepository.GetByReferenceIdAsync(TestConstants.ProjectId, referenceId)).Documents); + Assert.Equal(message, ev.Message); + Assert.Equal(tag, Assert.Single(ev.Tags!)); + var stack = await _stackRepository.GetByIdAsync(ev.StackId); + Assert.NotNull(stack); + Assert.Equal(title, stack.Title); + } + + [Fact] + public async Task Post_ReservedTopLevelDataKey_ReturnsValidationProblemWithoutPersistence() + { + const string referenceId = "v3-reserved-data-ref-0001"; + const string payload = """ + {"id":"v3-reserved-data-0001","type":"log","reference_id":"v3-reserved-data-ref-0001","data":{"@request":{"cookies":{"authorization":"secret"}}}} + """; + + using HttpResponseMessage response = await PostAsync(new StringContent(payload, Encoding.UTF8, "application/x-ndjson")); + + Assert.Equal(HttpStatusCode.UnprocessableEntity, response.StatusCode); + Assert.Equal("application/problem+json", response.Content.Headers.ContentType?.MediaType); + await RefreshDataAsync(); + Assert.Empty((await _eventRepository.GetByReferenceIdAsync(TestConstants.ProjectId, referenceId)).Documents); + } + + [Fact] + public async Task Post_NullManualStackingValue_ReturnsValidationProblem() + { + const string payload = """ + {"id":"v3-null-manual-stack-0001","type":"log","stacking":{"signature_data":{"hash":null}}} + """; + + using HttpResponseMessage response = await PostAsync(new StringContent(payload, Encoding.UTF8, "application/x-ndjson")); + + Assert.Equal(HttpStatusCode.UnprocessableEntity, response.StatusCode); + Assert.Equal("application/problem+json", response.Content.Headers.ContentType?.MediaType); + } + + [Fact] + public async Task Post_ReplayedEvent_ReturnsDuplicateWithoutSecondWrite() + { + const string payload = """{"id":"v3-duplicate-event-0001","type":"log","message":"once","reference_id":"v3-duplicate-ref-0001"}"""; + var workItemQueue = GetService>(); + var initialQueueStats = await workItemQueue.GetQueueStatsAsync(); + + using HttpResponseMessage firstHttpResponse = await PostAsync(new StringContent(payload, Encoding.UTF8, "application/x-ndjson")); + EventIngestionV3Response first = await DeserializeAsync(firstHttpResponse); + var firstQueueStats = await workItemQueue.GetQueueStatsAsync(); + await RefreshDataAsync(); + using HttpResponseMessage secondHttpResponse = await PostAsync(new StringContent(payload, Encoding.UTF8, "application/x-ndjson")); + EventIngestionV3Response second = await DeserializeAsync(secondHttpResponse); + var secondQueueStats = await workItemQueue.GetQueueStatsAsync(); + + Assert.Equal(1, first.Persisted); + Assert.Equal(0, first.Duplicate); + Assert.Equal(0, second.Persisted); + Assert.Equal(1, second.Duplicate); + Assert.Equal(initialQueueStats.Enqueued + 1, firstQueueStats.Enqueued); + Assert.Equal(firstQueueStats.Enqueued, secondQueueStats.Enqueued); + Assert.Single((await _eventRepository.GetByReferenceIdAsync(TestConstants.ProjectId, "v3-duplicate-ref-0001")).Documents); + } + + [Fact] + public async Task Post_RetryAfterStackOnlyWrite_RecoversFirstOccurrence() + { + var project = await _projectRepository.GetByIdAsync(TestConstants.ProjectId); + Assert.NotNull(project); + var organization = await GetService().GetByIdAsync(project.OrganizationId); + Assert.NotNull(organization); + DateTimeOffset eventDate = TimeProvider.GetUtcNow(); + var source = new EventIngestionV3Event + { + Id = "v3-first-occurrence-recovery-01", + Type = Event.KnownTypes.Log, + Date = eventDate, + Source = "Example.FirstOccurrence", + Message = "recover first event", + ReferenceId = "v3-first-recovery-ref-01" + }; + StackFingerprint fingerprint = GetService().Create(source, organization, project); + string eventId = EventBatchWriter.GetDeterministicEventId(project.Id, source.Id, eventDate.UtcDateTime); + await _stackRepository.AddAsync(new Stack + { + OrganizationId = organization.Id, + ProjectId = project.Id, + Type = source.Type, + Status = StackStatus.Open, + SignatureHash = fingerprint.SignatureHash, + SignatureInfo = new SettingsDictionary(fingerprint.SignatureData.ToDictionary(pair => pair.Key, pair => pair.Value)), + DuplicateSignature = $"{project.Id}:{fingerprint.SignatureHash}", + Title = source.Message, + TotalOccurrences = 0, + FirstOccurrence = TimeProvider.GetUtcNow().UtcDateTime, + LastOccurrence = TimeProvider.GetUtcNow().UtcDateTime, + IngestionFirstEventId = eventId + }, o => o.ImmediateConsistency().Cache()); + + string payload = JsonSerializer.Serialize(source, Exceptionless.Core.Serialization.EventIngestionJsonContext.Default.EventIngestionV3Event); + using HttpResponseMessage httpResponse = await PostAsync(new StringContent(payload, Encoding.UTF8, "application/x-ndjson")); + EventIngestionV3Response response = await DeserializeAsync(httpResponse); + + Assert.Equal(1, response.Persisted); + await RefreshDataAsync(); + var persisted = Assert.Single((await _eventRepository.GetByReferenceIdAsync(project.Id, source.ReferenceId)).Documents); + Assert.Equal(eventId, persisted.Id); + Assert.True(persisted.IsFirstOccurrence); + } + + [Fact] + public async Task Post_DiscardedStack_DoesNotMaterializeOrPersistEvent() + { + var project = await _projectRepository.GetByIdAsync(TestConstants.ProjectId); + Assert.NotNull(project); + var organization = await GetService().GetByIdAsync(project.OrganizationId); + Assert.NotNull(organization); + var source = new EventIngestionV3Event + { + Id = "v3-discarded-event-01", + Type = Event.KnownTypes.Error, + Message = "discard me", + ExceptionType = "System.InvalidOperationException", + StackTrace = "at Example.OrderService.Save() in /src/OrderService.cs:line 42", + ReferenceId = "v3-discard-ref-0001" + }; + StackFingerprint fingerprint = GetService().Create(source, organization, project); + await _stackRepository.AddAsync(new Stack + { + OrganizationId = TestConstants.OrganizationId, + ProjectId = TestConstants.ProjectId, + Type = Event.KnownTypes.Error, + Status = StackStatus.Discarded, + SignatureHash = fingerprint.SignatureHash, + SignatureInfo = new SettingsDictionary(fingerprint.SignatureData.ToDictionary(pair => pair.Key, pair => pair.Value)), + DuplicateSignature = $"{TestConstants.ProjectId}:{fingerprint.SignatureHash}", + Title = "discarded", + FirstOccurrence = TimeProvider.GetUtcNow().UtcDateTime, + LastOccurrence = TimeProvider.GetUtcNow().UtcDateTime + }, o => o.ImmediateConsistency().Cache()); + + string payload = JsonSerializer.Serialize(source, Exceptionless.Core.Serialization.EventIngestionJsonContext.Default.EventIngestionV3Event); + using HttpResponseMessage httpResponse = await PostAsync(new StringContent(payload, Encoding.UTF8, "application/x-ndjson")); + EventIngestionV3Response response = await DeserializeAsync(httpResponse); + + Assert.Equal(1, response.Discarded); + Assert.Equal(0, response.Persisted); + await RefreshDataAsync(); + Assert.Empty((await _eventRepository.GetByReferenceIdAsync(TestConstants.ProjectId, source.ReferenceId)).Documents); + } + + [Fact] + public async Task Post_DiscardedStackWithHugeInvalidOptionalContext_DiscardsBeforeFullDeserialization() + { + var project = await _projectRepository.GetByIdAsync(TestConstants.ProjectId); + Assert.NotNull(project); + var organization = await GetService().GetByIdAsync(project.OrganizationId); + Assert.NotNull(organization); + var routingEvent = new EventIngestionV3Event + { + Id = "v3-discarded-projection-01", + Type = Event.KnownTypes.Error, + ExceptionType = "Example.ProjectedException", + StackTrace = "at Example.Projected.Run() in /src/Projected.cs:line 42" + }; + StackFingerprint fingerprint = GetService().Create(routingEvent, organization, project); + await _stackRepository.AddAsync(new Stack + { + OrganizationId = TestConstants.OrganizationId, + ProjectId = TestConstants.ProjectId, + Type = Event.KnownTypes.Error, + Status = StackStatus.Discarded, + SignatureHash = fingerprint.SignatureHash, + SignatureInfo = new SettingsDictionary(fingerprint.SignatureData.ToDictionary(pair => pair.Key, pair => pair.Value)), + DuplicateSignature = $"{TestConstants.ProjectId}:{fingerprint.SignatureHash}", + Title = "discarded projection", + FirstOccurrence = TimeProvider.GetUtcNow().UtcDateTime, + LastOccurrence = TimeProvider.GetUtcNow().UtcDateTime + }, o => o.ImmediateConsistency().Cache()); + + string payload = $$""" + {"id":"{{routingEvent.Id}}","type":"error","exception_type":"{{routingEvent.ExceptionType}}","stack_trace":"{{routingEvent.StackTrace}}","data":{"value":"{{new string('x', 64 * 1024)}}"},"request":"not-an-object"} + """; + using HttpResponseMessage httpResponse = await PostAsync(new StringContent(payload, Encoding.UTF8, "application/x-ndjson")); + EventIngestionV3Response response = await DeserializeAsync(httpResponse); + + Assert.Equal(HttpStatusCode.OK, httpResponse.StatusCode); + Assert.Equal(1, response.Received); + Assert.Equal(1, response.Discarded); + Assert.Equal(0, response.Invalid); + Assert.Equal(0, response.Persisted); + } + + [Fact] + public async Task Post_TopLevelArray_ReturnsProblemDetails() + { + using HttpResponseMessage response = await PostAsync(new StringContent("[{\"id\":\"event-1\",\"type\":\"log\"}]", Encoding.UTF8, "application/x-ndjson")); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + Assert.Equal("application/problem+json", response.Content.Headers.ContentType?.MediaType); + } + + [Fact] + public async Task Post_BrotliBody_PersistsEvent() + { + const string payload = """{"id":"v3-brotli-event-0001","type":"log","message":"brotli","reference_id":"v3-brotli-ref-0001"}"""; + byte[] compressed; + await using (var output = new MemoryStream()) + { + await using (var brotli = new BrotliStream(output, CompressionMode.Compress, leaveOpen: true)) + await brotli.WriteAsync(Encoding.UTF8.GetBytes(payload), TestCancellationToken); + compressed = output.ToArray(); + } + + using var content = new ByteArrayContent(compressed); + content.Headers.ContentType = new MediaTypeHeaderValue("application/x-ndjson"); + content.Headers.ContentEncoding.Add("br"); + using HttpResponseMessage httpResponse = await PostAsync(content); + EventIngestionV3Response response = await DeserializeAsync(httpResponse); + + Assert.Equal(1, response.Persisted); + await RefreshDataAsync(); + Assert.Single((await _eventRepository.GetByReferenceIdAsync(TestConstants.ProjectId, "v3-brotli-ref-0001")).Documents); + } + + [Fact] + public async Task Post_UnsupportedContentEncoding_ReturnsUnsupportedMediaType() + { + using var content = new StringContent("{\"id\":\"event-1\",\"type\":\"log\"}", Encoding.UTF8, "application/x-ndjson"); + content.Headers.ContentEncoding.Add("deflate"); + + using HttpResponseMessage response = await PostAsync(content); + + Assert.Equal(HttpStatusCode.UnsupportedMediaType, response.StatusCode); + } + + [Fact] + public async Task Post_EmptyStream_ReturnsEmptySuccess() + { + using HttpResponseMessage httpResponse = await PostAsync(new StringContent(String.Empty, Encoding.UTF8, "application/x-ndjson")); + EventIngestionV3Response response = await DeserializeAsync(httpResponse); + + Assert.Equal(HttpStatusCode.OK, httpResponse.StatusCode); + Assert.Equal(0, response.Received); + Assert.Equal(0, response.Persisted); + } + + [Fact] + public async Task Post_TruncatedJson_ReturnsBadRequest() + { + using HttpResponseMessage response = await PostAsync(new StringContent("{\"id\":\"event-1\",\"type\":", Encoding.UTF8, "application/x-ndjson")); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task Post_TooManyEvents_ReturnsRequestEntityTooLarge() + { + var options = GetService().EventIngestionV3; + int originalLimit = options.MaximumEventsPerRequest; + options.MaximumEventsPerRequest = 1; + try + { + const string payload = """ + {"id":"v3-limit-event-0001","type":"log"} + {"id":"v3-limit-event-0002","type":"log"} + """; + using HttpResponseMessage response = await PostAsync(new StringContent(payload, Encoding.UTF8, "application/x-ndjson")); + + Assert.Equal(HttpStatusCode.RequestEntityTooLarge, response.StatusCode); + } + finally + { + options.MaximumEventsPerRequest = originalLimit; + } + } + + [Fact] + public async Task Post_DuplicateIdInSameStream_PersistsAndChargesOnce() + { + const string payload = """ + {"id":"v3-stream-duplicate-0001","type":"log","message":"first","reference_id":"v3-stream-duplicate-ref-0001"} + {"id":"v3-stream-duplicate-0001","type":"log","message":"retry","reference_id":"v3-stream-duplicate-ref-0001"} + """; + + using HttpResponseMessage httpResponse = await PostAsync(new StringContent(payload, Encoding.UTF8, "application/x-ndjson")); + EventIngestionV3Response response = await DeserializeAsync(httpResponse); + + Assert.Equal(2, response.Received); + Assert.Equal(1, response.Persisted); + Assert.Equal(1, response.Duplicate); + await RefreshDataAsync(); + Assert.Single((await _eventRepository.GetByReferenceIdAsync(TestConstants.ProjectId, "v3-stream-duplicate-ref-0001")).Documents); + } + + [Fact] + public async Task Post_AllInvalidEvents_ReturnsUnprocessableEntity() + { + using HttpResponseMessage response = await PostAsync(new StringContent("{\"id\":\"\",\"type\":\"log\"}", Encoding.UTF8, "application/x-ndjson")); + + Assert.Equal(HttpStatusCode.UnprocessableEntity, response.StatusCode); + Assert.Equal("application/problem+json", response.Content.Headers.ContentType?.MediaType); + } + + [Fact] + public async Task Post_CompressedBodyOverLimit_ReturnsRequestEntityTooLarge() + { + var options = GetService().EventIngestionV3; + long originalLimit = options.MaximumCompressedBodySize; + options.MaximumCompressedBodySize = 8; + try + { + using HttpResponseMessage response = await PostAsync(new StringContent("{\"id\":\"v3-compressed-limit\",\"type\":\"log\"}", Encoding.UTF8, "application/x-ndjson")); + + Assert.Equal(HttpStatusCode.RequestEntityTooLarge, response.StatusCode); + } + finally + { + options.MaximumCompressedBodySize = originalLimit; + } + } + + [Fact] + public async Task Post_DecompressedBodyOverLimit_ReturnsRequestEntityTooLarge() + { + var options = GetService().EventIngestionV3; + long originalLimit = options.MaximumDecompressedBodySize; + options.MaximumDecompressedBodySize = 16; + try + { + using HttpResponseMessage response = await PostAsync(new StringContent("{\"id\":\"v3-decompressed-limit\",\"type\":\"log\"}", Encoding.UTF8, "application/x-ndjson")); + + Assert.Equal(HttpStatusCode.RequestEntityTooLarge, response.StatusCode); + } + finally + { + options.MaximumDecompressedBodySize = originalLimit; + } + } + + [Fact] + public async Task Post_HighCompressionRatioWithinIndependentLimits_PersistsEvent() + { + var options = GetService().EventIngestionV3; + long originalCompressedLimit = options.MaximumCompressedBodySize; + long originalDecompressedLimit = options.MaximumDecompressedBodySize; + options.MaximumCompressedBodySize = 1024; + options.MaximumDecompressedBodySize = 8192; + try + { + string payload = $$"""{"id":"v3-high-ratio-0001","type":"log","reference_id":"v3-high-ratio-ref-0001","message":"{{new string('x', 1900)}}"}"""; + byte[] compressed; + await using (var output = new MemoryStream()) + { + await using (var gzip = new GZipStream(output, CompressionMode.Compress, leaveOpen: true)) + await gzip.WriteAsync(Encoding.UTF8.GetBytes(payload), TestCancellationToken); + compressed = output.ToArray(); + } + Assert.True(compressed.Length < options.MaximumCompressedBodySize); + + using var content = new ByteArrayContent(compressed); + content.Headers.ContentType = new MediaTypeHeaderValue("application/x-ndjson"); + content.Headers.ContentEncoding.Add("gzip"); + using HttpResponseMessage httpResponse = await PostAsync(content); + EventIngestionV3Response response = await DeserializeAsync(httpResponse); + + Assert.Equal(HttpStatusCode.OK, httpResponse.StatusCode); + Assert.Equal(1, response.Persisted); + } + finally + { + options.MaximumCompressedBodySize = originalCompressedLimit; + options.MaximumDecompressedBodySize = originalDecompressedLimit; + } + } + + [Fact] + public async Task Post_CompressedBodyOverDecompressedLimit_ReturnsRequestEntityTooLarge() + { + var options = GetService().EventIngestionV3; + long originalCompressedLimit = options.MaximumCompressedBodySize; + long originalDecompressedLimit = options.MaximumDecompressedBodySize; + options.MaximumCompressedBodySize = 1024; + options.MaximumDecompressedBodySize = 128; + try + { + string payload = $$"""{"id":"v3-decompressed-gzip-limit","type":"log","message":"{{new string('x', 512)}}"}"""; + byte[] compressed; + await using (var output = new MemoryStream()) + { + await using (var gzip = new GZipStream(output, CompressionMode.Compress, leaveOpen: true)) + await gzip.WriteAsync(Encoding.UTF8.GetBytes(payload), TestCancellationToken); + compressed = output.ToArray(); + } + + using var content = new ByteArrayContent(compressed); + content.Headers.ContentType = new MediaTypeHeaderValue("application/x-ndjson"); + content.Headers.ContentEncoding.Add("gzip"); + using HttpResponseMessage response = await PostAsync(content); + + Assert.Equal(HttpStatusCode.RequestEntityTooLarge, response.StatusCode); + } + finally + { + options.MaximumCompressedBodySize = originalCompressedLimit; + options.MaximumDecompressedBodySize = originalDecompressedLimit; + } + } + + [Fact] + public async Task Post_InvalidJsonAfterPersistedPrefix_ReturnsReplayablePartialResult() + { + var options = GetService().EventIngestionV3; + int originalMicroBatchSize = options.MicroBatchSize; + options.MicroBatchSize = 1; + try + { + const string payload = """ + {"id":"v3-partial-prefix-0001","type":"log","reference_id":"v3-partial-prefix-ref-0001"} + {"id":"broken" + """; + using HttpResponseMessage response = await PostAsync(new StringContent(payload, Encoding.UTF8, "application/x-ndjson")); + string json = await response.Content.ReadAsStringAsync(TestCancellationToken); + using JsonDocument document = JsonDocument.Parse(json); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + JsonElement partialResult = document.RootElement.GetProperty("partial_result"); + Assert.Equal(1, partialResult.GetProperty("received").GetInt32()); + Assert.Equal(1, partialResult.GetProperty("persisted").GetInt32()); + Assert.Contains("Retry the complete request", document.RootElement.GetProperty("retry_guidance").GetString() ?? String.Empty); + + await RefreshDataAsync(); + Assert.Single((await _eventRepository.GetByReferenceIdAsync(TestConstants.ProjectId, "v3-partial-prefix-ref-0001")).Documents); + } + finally + { + options.MicroBatchSize = originalMicroBatchSize; + } + } + + [Fact] + public async Task Post_NullNestedHeaderValue_ReturnsValidationProblem() + { + const string payload = """{"id":"v3-null-header-0001","type":"log","request":{"headers":{"x-test":null}}}"""; + + using HttpResponseMessage response = await PostAsync(new StringContent(payload, Encoding.UTF8, "application/x-ndjson")); + + Assert.Equal(HttpStatusCode.UnprocessableEntity, response.StatusCode); + Assert.Equal("application/problem+json", response.Content.Headers.ContentType?.MediaType); + } + + [Fact] + public async Task Post_AtQuota_DiscardsKnownStackAndBlocksNewEvent() + { + var organizationRepository = GetService(); + var organization = await organizationRepository.GetByIdAsync(TestConstants.OrganizationId); + var project = await _projectRepository.GetByIdAsync(TestConstants.ProjectId); + Assert.NotNull(organization); + Assert.NotNull(project); + + organization.MaxEventsPerMonth = 1; + organization.GetCurrentUsage(TimeProvider).Total = 1; + await organizationRepository.SaveAsync(organization, o => o.ImmediateConsistency().Cache()); + var cache = GetService(); + await cache.RemoveAsync($"usage:limits:{organization.Id}"); + await cache.RemoveByPrefixAsync("usage:total:"); + + var discarded = new EventIngestionV3Event + { + Id = "v3-at-quota-discarded-0001", + Type = Event.KnownTypes.Error, + ExceptionType = "Example.DiscardedException", + StackTrace = "at Example.Discarded.Run() in /src/Discarded.cs:line 10", + ReferenceId = "v3-at-quota-discarded-ref-0001" + }; + StackFingerprint fingerprint = GetService().Create(discarded, organization, project); + await _stackRepository.AddAsync(new Stack + { + OrganizationId = organization.Id, + ProjectId = project.Id, + Type = Event.KnownTypes.Error, + Status = StackStatus.Discarded, + SignatureHash = fingerprint.SignatureHash, + SignatureInfo = new SettingsDictionary(fingerprint.SignatureData), + DuplicateSignature = $"{project.Id}:{fingerprint.SignatureHash}", + Title = "discarded at quota", + FirstOccurrence = TimeProvider.GetUtcNow().UtcDateTime, + LastOccurrence = TimeProvider.GetUtcNow().UtcDateTime + }, o => o.ImmediateConsistency().Cache()); + + string discardedJson = JsonSerializer.Serialize(discarded, Exceptionless.Core.Serialization.EventIngestionJsonContext.Default.EventIngestionV3Event); + const string activeJson = """{"id":"v3-at-quota-active-0001","type":"log","source":"new-source","reference_id":"v3-at-quota-active-ref-0001"}"""; + using HttpResponseMessage httpResponse = await PostAsync(new StringContent($"{discardedJson}\n{activeJson}", Encoding.UTF8, "application/x-ndjson")); + EventIngestionV3Response response = await DeserializeAsync(httpResponse); + + Assert.Equal(2, response.Received); + Assert.Equal(1, response.Discarded); + Assert.Equal(1, response.Blocked); + Assert.Equal(0, response.Persisted); + await RefreshDataAsync(); + Assert.Empty((await _eventRepository.GetByReferenceIdAsync(project.Id, discarded.ReferenceId)).Documents); + Assert.Empty((await _eventRepository.GetByReferenceIdAsync(project.Id, "v3-at-quota-active-ref-0001")).Documents); + } + + [Fact] + public async Task Post_EventOverUtf8RecordSizeLimit_ReturnsRequestEntityTooLarge() + { + var options = GetService().EventIngestionV3; + long originalLimit = options.MaximumEventSize; + options.MaximumEventSize = 60; + try + { + using HttpResponseMessage response = await PostAsync(new StringContent("{\"id\":\"v3-event-size-limit\",\"type\":\"log\",\"message\":\"payload\"}", Encoding.UTF8, "application/x-ndjson")); + + Assert.Equal(HttpStatusCode.RequestEntityTooLarge, response.StatusCode); + } + finally + { + options.MaximumEventSize = originalLimit; + } + } + + [Fact] + public async Task Post_WithoutClientAuthorization_ReturnsUnauthorized() + { + using var request = new HttpRequestMessage(HttpMethod.Post, new Uri(_server.BaseAddress, "/api/v3/events")) + { + Content = new StringContent("{\"id\":\"event-1\",\"type\":\"log\"}", Encoding.UTF8, "application/x-ndjson") + }; + + using HttpResponseMessage response = await _server.CreateClient().SendAsync(request, TestCancellationToken); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task Post_ExplicitProjectMismatch_ReturnsNotFound() + { + using var request = new HttpRequestMessage(HttpMethod.Post, new Uri(_server.BaseAddress, "/api/v3/projects/507f1f77bcf86cd799439011/events")); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", TestConstants.ApiKey); + request.Content = new StringContent("{\"id\":\"event-1\",\"type\":\"log\"}", Encoding.UTF8, "application/x-ndjson"); + + using HttpResponseMessage response = await _server.CreateClient().SendAsync(request, TestCancellationToken); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task Post_ExplicitProject_AcquiresActualOrganizationStreamPermit() + { + var project = await _projectRepository.GetByIdAsync(SampleDataService.INTERNAL_PROJECT_ID); + Assert.NotNull(project); + Assert.NotEqual(TestConstants.OrganizationId, project.OrganizationId); + + var limiter = GetService(); + int permitLimit = GetService().EventIngestionV3.MaximumActiveStreamsPerOrganization; + var heldLeases = new List(permitLimit); + try + { + for (int index = 0; index < permitLimit; index++) + { + RateLimitLease lease = await limiter.AcquireOrganizationActiveStreamAsync(project.OrganizationId, TestCancellationToken); + Assert.True(lease.IsAcquired); + heldLeases.Add(lease); + } + + using var request = new HttpRequestMessage( + HttpMethod.Post, + new Uri(_server.BaseAddress, $"/api/v3/projects/{project.Id}/events")); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", TestConstants.UserApiKey); + request.Content = new StringContent( + "{\"id\":\"v3-explicit-project-limit-0001\",\"type\":\"log\"}", + Encoding.UTF8, + "application/x-ndjson"); + + using HttpResponseMessage response = await _server.CreateClient().SendAsync(request, TestCancellationToken); + + Assert.Equal(HttpStatusCode.TooManyRequests, response.StatusCode); + } + finally + { + foreach (RateLimitLease lease in heldLeases) + lease.Dispose(); + } + } + + [Fact] + public async Task Post_UnsupportedMediaType_ReturnsUnsupportedMediaType() + { + using HttpResponseMessage response = await PostAsync(new StringContent("{\"id\":\"event-1\",\"type\":\"log\"}", Encoding.UTF8, "application/json")); + + Assert.Equal(HttpStatusCode.UnsupportedMediaType, response.StatusCode); + } + + [Fact] + public async Task Post_JsonOverDepthLimit_ReturnsBadRequest() + { + string nested = String.Concat(Enumerable.Repeat("{\"value\":", EventIngestionV3Limits.MaximumJsonDepth + 1)); + nested += "true" + new string('}', EventIngestionV3Limits.MaximumJsonDepth + 1); + string payload = $"{{\"id\":\"event-1\",\"type\":\"log\",\"data\":{nested}}}"; + + using HttpResponseMessage response = await PostAsync(new StringContent(payload, Encoding.UTF8, "application/x-ndjson")); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + private async Task PostAsync(HttpContent content) + { + using var request = new HttpRequestMessage(HttpMethod.Post, new Uri(_server.BaseAddress, "/api/v3/events")); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", TestConstants.ApiKey); + request.Content = content; + return await _server.CreateClient().SendAsync(request, TestCancellationToken); + } + + private static async Task DeserializeAsync(HttpResponseMessage response) + { + string json = await response.Content.ReadAsStringAsync(); + return JsonSerializer.Deserialize(json, Exceptionless.Core.Serialization.EventIngestionJsonContext.Default.EventIngestionV3Response) + ?? throw new InvalidOperationException(json); + } + + private sealed class UnknownLengthJsonContent : HttpContent + { + private readonly byte[] _bytes; + + public UnknownLengthJsonContent(byte[] bytes) + { + _bytes = bytes; + Headers.ContentType = new MediaTypeHeaderValue("application/x-ndjson"); + } + + protected override Task SerializeToStreamAsync(Stream stream, TransportContext? context) + { + return stream.WriteAsync(_bytes).AsTask(); + } + + protected override bool TryComputeLength(out long length) + { + length = 0; + return false; + } + } +} diff --git a/tests/Exceptionless.Tests/Endpoints/EventIngestionV3ProcessingStatusEndpointTests.cs b/tests/Exceptionless.Tests/Endpoints/EventIngestionV3ProcessingStatusEndpointTests.cs new file mode 100644 index 0000000000..dafbbd13c7 --- /dev/null +++ b/tests/Exceptionless.Tests/Endpoints/EventIngestionV3ProcessingStatusEndpointTests.cs @@ -0,0 +1,92 @@ +using Exceptionless.Core; +using Exceptionless.Core.Models; +using Exceptionless.Core.Services; +using Exceptionless.Core.Utility; +using Exceptionless.Tests.Extensions; +using Exceptionless.Tests.Utility; +using Exceptionless.Web.Models; +using Xunit; + +namespace Exceptionless.Tests.Endpoints; + +public sealed class EventIngestionV3ProcessingStatusEndpointTests : IntegrationTestsBase +{ + public EventIngestionV3ProcessingStatusEndpointTests(ITestOutputHelper output, AppWebHostFactory factory) : base(output, factory) + { + GetService().EventIngestionV3.EnableProcessingStatus = true; + } + + protected override async Task ResetDataAsync() + { + await base.ResetDataAsync(); + await GetService().CreateDataAsync(); + } + + [Fact] + public async Task GetProcessingStatusAsync_CompletedAndPendingEvents_ReturnsAggregate() + { + const string completedClientId = "v3-status-completed"; + const string pendingClientId = "v3-status-pending"; + DateTime utcNow = TimeProvider.GetUtcNow().UtcDateTime; + var identity = Assert.Single(await GetService().PrepareAsync( + [new() { Id = completedClientId, Type = Event.KnownTypes.Log }], + SampleDataService.TEST_PROJECT_ID, + utcNow, + TestCancellationToken)); + await GetService().ExecuteAsync( + IngestionSideEffectExecutor.TerminalStage, + SampleDataService.TEST_PROJECT_ID, + [identity.EventId], + _ => Task.CompletedTask, + TestCancellationToken); + + var result = await SendRequestAsAsync(request => request + .Post() + .AsTestOrganizationClientUser() + .BaseUri(_server.BaseAddress) + .AppendPaths("api", "v3", "projects", SampleDataService.TEST_PROJECT_ID, "events", "processing", "status") + .Content(new EventIngestionV3ProcessingStatusRequest { ClientIds = [completedClientId, pendingClientId] }) + .StatusCodeShouldBeOk()); + + var summary = Assert.IsType(result); + Assert.Equal(2, summary.Requested); + Assert.Equal(1, summary.Completed); + Assert.Equal(1, summary.Pending); + } + + [Fact] + public Task GetProcessingStatusAsync_TooManyClientIds_ReturnsUnprocessableEntity() + { + return SendRequestAsync(request => request + .Post() + .AsTestOrganizationClientUser() + .BaseUri(_server.BaseAddress) + .AppendPaths("api", "v3", "projects", SampleDataService.TEST_PROJECT_ID, "events", "processing", "status") + .Content(new EventIngestionV3ProcessingStatusRequest + { + ClientIds = Enumerable.Range(0, 1001).Select(index => $"event-{index}").ToArray() + }) + .StatusCodeShouldBeUnprocessableEntity()); + } + + [Fact] + public async Task GetProcessingStatusAsync_StatusDisabled_ReturnsNotFound() + { + var options = GetService().EventIngestionV3; + options.EnableProcessingStatus = false; + try + { + await SendRequestAsync(request => request + .Post() + .AsTestOrganizationClientUser() + .BaseUri(_server.BaseAddress) + .AppendPaths("api", "v3", "projects", SampleDataService.TEST_PROJECT_ID, "events", "processing", "status") + .Content(new EventIngestionV3ProcessingStatusRequest { ClientIds = ["event"] }) + .StatusCodeShouldBeNotFound()); + } + finally + { + options.EnableProcessingStatus = true; + } + } +} diff --git a/tests/Exceptionless.Tests/Endpoints/EventPostProcessingEndpointTests.cs b/tests/Exceptionless.Tests/Endpoints/EventPostProcessingEndpointTests.cs new file mode 100644 index 0000000000..f6ac431293 --- /dev/null +++ b/tests/Exceptionless.Tests/Endpoints/EventPostProcessingEndpointTests.cs @@ -0,0 +1,123 @@ +using Exceptionless.Core; +using Exceptionless.Core.Queues.Models; +using Exceptionless.Core.Services; +using Exceptionless.Core.Utility; +using Exceptionless.Tests.Extensions; +using Exceptionless.Tests.Utility; +using Exceptionless.Web.Models; +using Exceptionless.Web.Utility; +using Xunit; + +namespace Exceptionless.Tests.Endpoints; + +public sealed class EventPostProcessingEndpointTests : IntegrationTestsBase +{ + public EventPostProcessingEndpointTests(ITestOutputHelper output, AppWebHostFactory factory) : base(output, factory) + { + GetService().EventIngestionV3.EnableProcessingStatus = true; + } + + protected override async Task ResetDataAsync() + { + await base.ResetDataAsync(); + await GetService().CreateDataAsync(); + } + + [Fact] + public async Task GetStatusesAsync_TrackedPost_ReportsQueuedThenCompleted() + { + // An empty post still traverses the V2 queue and reaches a terminal processing state, + // without making this correlation test depend on event-index creation. + const string json = "[]"; + using HttpResponseMessage postResponse = await SendRequestAsync(r => r + .Post() + .AsTestOrganizationClientUser() + .AppendPaths("projects", SampleDataService.TEST_PROJECT_ID, "events") + .Header(Headers.TrackEventPost, "true") + .Content(json, "application/json") + .StatusCodeShouldBeAccepted()); + + Assert.True(postResponse.Headers.TryGetValues(Headers.EventPostId, out var values)); + string queueEntryId = Assert.Single(values); + + // Tracking is initialized by the queue worker so the request path performs no + // benchmark-only cache writes. Initialize it here without running the whole job. + Assert.True(await GetService().InitializeProcessingTrackingAsync(queueEntryId, SampleDataService.TEST_PROJECT_ID)); + + var queued = await GetStatusesAsync(queueEntryId); + Assert.Equal(1, queued.Requested); + Assert.Equal(1, queued.Queued); + Assert.Equal(0, queued.Completed); + Assert.Equal(0, queued.Unknown); + + await SendRequestAsync(r => r + .Post() + .AsFreeOrganizationClientUser() + .AppendPaths("projects", SampleDataService.TEST_PROJECT_ID, "events", "posts", "status") + .Content(new EventPostProcessingStatusRequest { Ids = [queueEntryId] }) + .StatusCodeShouldBeNotFound()); + + await GetService().MarkProcessingCompletedAsync(queueEntryId, new EventPost(false) + { + OrganizationId = TestConstants.OrganizationId, + ProcessingCorrelationId = queueEntryId, + ProjectId = SampleDataService.TEST_PROJECT_ID, + }); + + var completed = await GetStatusesAsync(queueEntryId); + Assert.Equal(1, completed.Completed); + Assert.Equal(0, completed.Queued); + Assert.Equal(0, completed.Unknown); + } + + [Fact] + public Task GetStatusesAsync_TooManyIdentifiers_ReturnsUnprocessableEntity() + { + return SendRequestAsync(r => r + .Post() + .AsTestOrganizationClientUser() + .AppendPaths("projects", SampleDataService.TEST_PROJECT_ID, "events", "posts", "status") + .Content(new EventPostProcessingStatusRequest { Ids = Enumerable.Range(0, 1001).Select(index => $"post-{index}").ToArray() }) + .StatusCodeShouldBeUnprocessableEntity()); + } + + [Fact] + public async Task ProcessingStatusDisabled_DoesNotCreatePublicTrackingSurface() + { + var options = GetService().EventIngestionV3; + options.EnableProcessingStatus = false; + try + { + using HttpResponseMessage postResponse = await SendRequestAsync(request => request + .Post() + .AsTestOrganizationClientUser() + .AppendPaths("projects", SampleDataService.TEST_PROJECT_ID, "events") + .Header(Headers.TrackEventPost, "true") + .Content("[]", "application/json") + .StatusCodeShouldBeAccepted()); + + Assert.False(postResponse.Headers.Contains(Headers.EventPostId)); + await SendRequestAsync(request => request + .Post() + .AsTestOrganizationClientUser() + .AppendPaths("projects", SampleDataService.TEST_PROJECT_ID, "events", "posts", "status") + .Content(new EventPostProcessingStatusRequest { Ids = ["unknown"] }) + .StatusCodeShouldBeNotFound()); + } + finally + { + options.EnableProcessingStatus = true; + } + } + + private async Task GetStatusesAsync(string queueEntryId) + { + var result = await SendRequestAsAsync(r => r + .Post() + .AsTestOrganizationClientUser() + .AppendPaths("projects", SampleDataService.TEST_PROJECT_ID, "events", "posts", "status") + .Content(new EventPostProcessingStatusRequest { Ids = [queueEntryId] }) + .StatusCodeShouldBeOk()); + return Assert.IsType(result); + } +} diff --git a/tests/Exceptionless.Tests/Exceptionless.Tests.csproj b/tests/Exceptionless.Tests/Exceptionless.Tests.csproj index 8278f2a81e..b7f37c5d53 100644 --- a/tests/Exceptionless.Tests/Exceptionless.Tests.csproj +++ b/tests/Exceptionless.Tests/Exceptionless.Tests.csproj @@ -21,6 +21,7 @@ + diff --git a/tests/Exceptionless.Tests/Jobs/EventPostJobTests.cs b/tests/Exceptionless.Tests/Jobs/EventPostJobTests.cs index fb07d885e1..b8e7733953 100644 --- a/tests/Exceptionless.Tests/Jobs/EventPostJobTests.cs +++ b/tests/Exceptionless.Tests/Jobs/EventPostJobTests.cs @@ -43,7 +43,7 @@ public EventPostJobTests(ITestOutputHelper output, AppWebHostFactory factory) : _job = GetService(); _eventQueue = GetService>(); _storage = GetService(); - _eventPostService = new EventPostService(_eventQueue, _storage, TimeProvider, Log); + _eventPostService = new EventPostService(_eventQueue, _storage, GetService(), TimeProvider, Log); _organizationData = GetService(); _organizationRepository = GetService(); _projectData = GetService(); @@ -71,7 +71,7 @@ protected override async Task ResetDataAsync() public async Task CanRunJob() { var ev = GenerateEvent(); - Assert.NotNull(await EnqueueEventPostAsync(ev)); + string queueEntryId = Assert.IsType(await EnqueueEventPostAsync(ev, trackProcessing: true)); Assert.Equal(1, (await _eventQueue.GetQueueStatsAsync()).Enqueued); var files = await _storage.GetFileListAsync(cancellationToken: TestCancellationToken); Assert.Single(files); @@ -88,6 +88,32 @@ public async Task CanRunJob() files = await _storage.GetFileListAsync(cancellationToken: TestCancellationToken); Assert.Empty(files); + + var processingStatuses = await _eventPostService.GetProcessingStatusesAsync([queueEntryId]); + Assert.True(processingStatuses[queueEntryId].IsCompleted); + } + + [Fact] + public async Task CanTrackEmptyPostToTerminalCompletion() + { + var eventPost = new EventPost(false) + { + OrganizationId = TestConstants.OrganizationId, + ProjectId = TestConstants.ProjectId, + ApiVersion = 2, + CharSet = "utf-8", + MediaType = "application/json", + TrackProcessing = true, + UserAgent = "exceptionless-test" + }; + await using var stream = new MemoryStream(); + string queueEntryId = Assert.IsType(await _eventPostService.EnqueueAsync(eventPost, stream, TestCancellationToken)); + + var result = await _job.RunAsync(TestCancellationToken); + + Assert.True(result.IsSuccess); + var processingStatuses = await _eventPostService.GetProcessingStatusesAsync([queueEntryId]); + Assert.True(processingStatuses[queueEntryId].IsCompleted); } [Fact] @@ -239,12 +265,12 @@ private async Task CreateDataAsync(BillingPlan? plan = null) } } - private Task EnqueueEventPostAsync(PersistentEvent ev) + private Task EnqueueEventPostAsync(PersistentEvent ev, string? processingCorrelationId = null, bool trackProcessing = false) { - return EnqueueEventPostAsync([ev]); + return EnqueueEventPostAsync([ev], processingCorrelationId, trackProcessing); } - private Task EnqueueEventPostAsync(List ev) + private Task EnqueueEventPostAsync(List ev, string? processingCorrelationId = null, bool trackProcessing = false) { var first = ev.First(); @@ -256,7 +282,9 @@ private async Task CreateDataAsync(BillingPlan? plan = null) CharSet = "utf-8", ContentEncoding = "gzip", MediaType = "application/json", + ProcessingCorrelationId = processingCorrelationId, UserAgent = "exceptionless-test", + TrackProcessing = trackProcessing, }; var stream = new MemoryStream(_serializer.SerializeToBytes(ev).Compress()); diff --git a/tests/Exceptionless.Tests/Pipeline/QueueNotificationActionTests.cs b/tests/Exceptionless.Tests/Pipeline/QueueNotificationActionTests.cs new file mode 100644 index 0000000000..e95598b1c0 --- /dev/null +++ b/tests/Exceptionless.Tests/Pipeline/QueueNotificationActionTests.cs @@ -0,0 +1,200 @@ +using System.Reflection; +using System.Runtime.ExceptionServices; +using Exceptionless.Core; +using Exceptionless.Core.Models; +using Exceptionless.Core.Pipeline; +using Exceptionless.Core.Plugins.EventProcessor; +using Exceptionless.Core.Plugins.WebHook; +using Exceptionless.Core.Queues.Models; +using Exceptionless.Core.Repositories; +using Foundatio.Queues; +using Foundatio.Repositories.Models; +using Microsoft.Extensions.Logging; +using Xunit; + +namespace Exceptionless.Tests.Pipeline; + +public sealed class QueueNotificationActionTests : TestWithServices +{ + private readonly QueueNotificationAction _action; + private readonly WebHookRepositoryProxy _webHookRepository; + private readonly IQueue _notificationQueue; + private readonly IQueue _webHookNotificationQueue; + + public QueueNotificationActionTests(ITestOutputHelper output) : base(output) + { + IWebHookRepository webHookRepository = WebHookRepositoryProxy.Create( + GetService(), + out _webHookRepository); + _notificationQueue = GetService>(); + _webHookNotificationQueue = GetService>(); + _action = new QueueNotificationAction( + _notificationQueue, + _webHookNotificationQueue, + webHookRepository, + GetService(), + GetService(), + GetService()); + } + + [Fact] + public async Task ProcessIngestionV3BatchAsync_SameOrganizationAndProject_LoadsWebHooksOnce() + { + var organization = CreateOrganization("organization-a"); + var project = CreateProject(organization.Id, "project-a"); + EventContext[] contexts = + [ + CreateContext("event-a", organization, project), + CreateContext("event-b", organization, project), + CreateContext("event-c", organization, project) + ]; + + await _action.ProcessIngestionV3BatchAsync(contexts); + + Assert.Equal(1, _webHookRepository.LoadCount); + } + + [Fact] + public async Task ProcessIngestionV3BatchAsync_MultipleOrganizationProjectPairs_LoadsEachOnce() + { + var firstOrganization = CreateOrganization("organization-a"); + var firstProject = CreateProject(firstOrganization.Id, "project-a"); + var secondOrganization = CreateOrganization("organization-b"); + var secondProject = CreateProject(secondOrganization.Id, "project-b"); + EventContext[] contexts = + [ + CreateContext("event-a", firstOrganization, firstProject), + CreateContext("event-b", firstOrganization, firstProject), + CreateContext("event-c", secondOrganization, secondProject) + ]; + + await _action.ProcessIngestionV3BatchAsync(contexts); + + Assert.Equal(2, _webHookRepository.LoadCount); + } + + [Fact] + public async Task ProcessIngestionV3BatchAsync_WebHookLoadFails_PropagatesForRetry() + { + var organization = CreateOrganization("organization-a"); + var project = CreateProject(organization.Id, "project-a"); + _webHookRepository.LoadException = new InvalidOperationException("failed"); + + var exception = await Assert.ThrowsAsync(() => + _action.ProcessIngestionV3BatchAsync([CreateContext("event-a", organization, project)])); + + Assert.Equal("failed", exception.Message); + } + + [Fact] + public async Task ProcessAsync_V2Notifications_UsesLegacyDeterministicIdentifiersWithoutDurableClaims() + { + var organization = CreateOrganization("organization-v2"); + var project = CreateProject(organization.Id, "project-v2"); + project.NotificationSettings["user-1"] = new NotificationSettings { ReportNewErrors = true }; + var hook = new WebHook + { + Id = "webhook-v2", + OrganizationId = organization.Id, + ProjectId = project.Id, + Url = "https://example.com/webhook", + EventTypes = [WebHook.KnownEventTypes.NewError], + Version = WebHook.KnownVersions.Version2 + }; + _webHookRepository.Results = new FindResults( + [new FindHit(hook.Id, hook, 1)], + 1); + EventContext context = CreateContext("event-v2", organization, project, isIngestionV3: false); + context.IsNew = true; + + await _action.ProcessAsync(context); + + var eventEntry = await _notificationQueue.DequeueAsync(TestCancellationToken); + var webHookEntry = await _webHookNotificationQueue.DequeueAsync(TestCancellationToken); + Assert.NotNull(eventEntry); + Assert.NotNull(webHookEntry); + Assert.Equal("event-notification:event-v2", eventEntry.Value.DeduplicationId); + Assert.False(eventEntry.Value.UseDurableDeduplication); + Assert.Equal("event-webhook:event-v2:webhook-v2:General", webHookEntry.Value.DeduplicationId); + Assert.False(webHookEntry.Value.UseDurableDeduplication); + } + + private static Organization CreateOrganization(string id) => new() + { + Id = id, + Name = id, + HasPremiumFeatures = true + }; + + private static Project CreateProject(string organizationId, string id) => new() + { + Id = id, + OrganizationId = organizationId, + Name = id + }; + + private static EventContext CreateContext(string eventId, Organization organization, Project project, bool isIngestionV3 = true) + { + var persistentEvent = new PersistentEvent + { + Id = eventId, + OrganizationId = organization.Id, + ProjectId = project.Id, + StackId = String.Concat("stack-", eventId), + Type = Event.KnownTypes.Error + }; + + return new EventContext(persistentEvent, organization, project) + { + IsIngestionV3 = isIngestionV3, + Stack = new Stack + { + Id = persistentEvent.StackId, + OrganizationId = organization.Id, + ProjectId = project.Id, + Type = persistentEvent.Type, + Status = StackStatus.Open, + TotalOccurrences = 7 + } + }; + } + + private class WebHookRepositoryProxy : DispatchProxy + { + private IWebHookRepository _inner = null!; + + public int LoadCount { get; private set; } + public Exception? LoadException { get; set; } + public FindResults? Results { get; set; } + + public static IWebHookRepository Create(IWebHookRepository inner, out WebHookRepositoryProxy implementation) + { + IWebHookRepository proxy = DispatchProxy.Create(); + implementation = (WebHookRepositoryProxy)(object)proxy; + implementation._inner = inner; + return proxy; + } + + protected override object? Invoke(MethodInfo? targetMethod, object?[]? args) + { + ArgumentNullException.ThrowIfNull(targetMethod); + if (String.Equals(targetMethod.Name, nameof(IWebHookRepository.GetByOrganizationIdOrProjectIdAsync), StringComparison.Ordinal)) + { + LoadCount++; + if (LoadException is not null) + return Task.FromException>(LoadException); + return Task.FromResult(Results ?? FindResults.Empty); + } + + try + { + return targetMethod.Invoke(_inner, args); + } + catch (TargetInvocationException ex) when (ex.InnerException is not null) + { + ExceptionDispatchInfo.Capture(ex.InnerException).Throw(); + throw; + } + } + } +} diff --git a/tests/Exceptionless.Tests/Queues/DurableDuplicateDetectionQueueBehaviorTests.cs b/tests/Exceptionless.Tests/Queues/DurableDuplicateDetectionQueueBehaviorTests.cs new file mode 100644 index 0000000000..7e3a178f17 --- /dev/null +++ b/tests/Exceptionless.Tests/Queues/DurableDuplicateDetectionQueueBehaviorTests.cs @@ -0,0 +1,209 @@ +using Exceptionless.Core.Queues; +using Exceptionless.Core.Queues.Models; +using Foundatio.Caching; +using Foundatio.Queues; +using Foundatio.Resilience; +using Foundatio.Serializer; +using Xunit; + +namespace Exceptionless.Tests.Queues; + +public sealed class DurableDuplicateDetectionQueueBehaviorTests : TestWithServices +{ + public DurableDuplicateDetectionQueueBehaviorTests(ITestOutputHelper output) : base(output) { } + + [Fact] + public async Task EnqueueAsync_CompletedItemIsDequeued_DuplicateRemainsSuppressed() + { + var queue = GetService>(); + var notification = new EventNotification + { + EventId = Guid.NewGuid().ToString("N"), + IsNew = true, + IsRegression = false, + TotalOccurrences = 1, + DeduplicationId = Guid.NewGuid().ToString("N"), + UseDurableDeduplication = true + }; + + await queue.EnqueueAsync(notification); + var cache = GetService(); + var legacyClaim = await cache.GetAsync(notification.UniqueIdentifier); + var durableClaim = await cache.GetAsync(DurableDuplicateDetectionQueueBehavior.GetCacheKey(notification.UniqueIdentifier)); + Assert.False(legacyClaim.HasValue); + Assert.Equal("completed", durableClaim.Value); + + var entry = await queue.DequeueAsync(TestCancellationToken); + Assert.NotNull(entry); + await entry.CompleteAsync(); + + await queue.EnqueueAsync(notification); + + var statistics = await queue.GetQueueStatsAsync(); + Assert.Equal(1, statistics.Enqueued); + } + + [Fact] + public async Task EnqueueAsync_DurableWebHook_BypassesLegacyClaim() + { + var queue = GetService>(); + var notification = new WebHookNotification + { + OrganizationId = "organization-1", + ProjectId = "project-1", + WebHookId = "webhook-1", + Type = WebHookType.General, + Url = "https://example.com/webhook", + Data = new { Message = "test" }, + DeduplicationId = "event-webhook:event-1:webhook-1:General", + UseDurableDeduplication = true + }; + + await queue.EnqueueAsync(notification); + + var cache = GetService(); + var legacyClaim = await cache.GetAsync(notification.UniqueIdentifier); + var durableClaim = await cache.GetAsync(DurableDuplicateDetectionQueueBehavior.GetCacheKey(notification.UniqueIdentifier)); + Assert.False(legacyClaim.HasValue); + Assert.Equal("completed", durableClaim.Value); + } + + [Fact] + public async Task EnqueueAsync_DurableEnqueueFails_PendingExpiryAllowsRecoveryWithoutFalseCompletion() + { + var cache = GetService(); + var notification = new EventNotification + { + EventId = "event-1", + IsNew = true, + IsRegression = false, + TotalOccurrences = 1, + DeduplicationId = "event-notification:event-1", + UseDurableDeduplication = true + }; + var legacyBehavior = new ConditionalDuplicateDetectionQueueBehavior( + cache, + GetService(), + TimeSpan.FromDays(7)); + var durableBehavior = new DurableDuplicateDetectionQueueBehavior( + cache, + GetService(), + TimeSpan.FromDays(7)); + var failureBehavior = new OneShotEnqueuingFailureBehavior(); + using var queue = CreateQueue(legacyBehavior, durableBehavior, failureBehavior); + + await Assert.ThrowsAsync(() => queue.EnqueueAsync(notification)); + + string durableKey = DurableDuplicateDetectionQueueBehavior.GetCacheKey(notification.UniqueIdentifier); + var pendingClaim = await cache.GetAsync(durableKey); + var legacyClaim = await cache.GetAsync(notification.UniqueIdentifier); + Assert.Equal("pending", pendingClaim.Value); + Assert.False(legacyClaim.HasValue); + Assert.Equal(0, (await queue.GetQueueStatsAsync()).Enqueued); + + var pendingException = await Assert.ThrowsAsync(() => queue.EnqueueAsync(notification)); + Assert.Contains("currently being enqueued", pendingException.Message, StringComparison.Ordinal); + Assert.Equal("pending", (await cache.GetAsync(durableKey)).Value); + Assert.Equal(0, (await queue.GetQueueStatsAsync()).Enqueued); + + TimeProvider.Advance(TimeSpan.FromMinutes(1).Add(TimeSpan.FromSeconds(1))); + await queue.EnqueueAsync(notification); + + Assert.Equal("completed", (await cache.GetAsync(durableKey)).Value); + Assert.Equal(1, (await queue.GetQueueStatsAsync()).Enqueued); + } + + [Fact] + public async Task EnqueueAsync_NonDurableItem_DoesNotSuppressDuplicateInstance() + { + var queue = GetService>(); + var notification = new EventNotification + { + EventId = Guid.NewGuid().ToString("N"), + IsNew = true, + IsRegression = false, + TotalOccurrences = 1 + }; + + await queue.EnqueueAsync(notification); + var entry = await queue.DequeueAsync(TestCancellationToken); + Assert.NotNull(entry); + await entry.CompleteAsync(); + await queue.EnqueueAsync(notification); + + var statistics = await queue.GetQueueStatsAsync(); + Assert.Equal(2, statistics.Enqueued); + } + + [Fact] + public async Task EnqueueAsync_NonDurableItem_SuppressesDuplicateWhileOriginalIsQueued() + { + var queue = GetService>(); + var notification = new EventNotification + { + EventId = Guid.NewGuid().ToString("N"), + IsNew = true, + IsRegression = false, + TotalOccurrences = 1, + DeduplicationId = Guid.NewGuid().ToString("N") + }; + + await queue.EnqueueAsync(notification); + await queue.EnqueueAsync(notification); + + var statistics = await queue.GetQueueStatsAsync(); + Assert.Equal(1, statistics.Enqueued); + } + + [Fact] + public async Task EnqueueAsync_NonDurableWebHook_PreservesLegacyDequeueScopedDeduplication() + { + var queue = GetService>(); + var notification = new WebHookNotification + { + OrganizationId = "organization-1", + ProjectId = "project-1", + WebHookId = "webhook-1", + Type = WebHookType.General, + Url = "https://example.com/webhook", + Data = new { Message = "test" }, + DeduplicationId = "event-webhook:event-1:webhook-1:General" + }; + + await queue.EnqueueAsync(notification); + await queue.EnqueueAsync(notification); + Assert.Equal(1, (await queue.GetQueueStatsAsync()).Enqueued); + + var entry = await queue.DequeueAsync(TestCancellationToken); + Assert.NotNull(entry); + await entry.CompleteAsync(); + + await queue.EnqueueAsync(notification); + Assert.Equal(2, (await queue.GetQueueStatsAsync()).Enqueued); + } + + private InMemoryQueue CreateQueue(params IQueueBehavior[] behaviors) + { + return new InMemoryQueue(new InMemoryQueueOptions + { + Behaviors = behaviors, + Serializer = GetService(), + TimeProvider = TimeProvider, + ResiliencePolicyProvider = GetService(), + LoggerFactory = GetService() + }); + } + + private sealed class OneShotEnqueuingFailureBehavior : QueueBehaviorBase where T : class + { + private int _shouldFail = 1; + + protected override Task OnEnqueuing(object sender, EnqueuingEventArgs args) + { + if (Interlocked.Exchange(ref _shouldFail, 0) == 1) + throw new InvalidOperationException("Injected enqueue failure."); + + return Task.CompletedTask; + } + } +} diff --git a/tests/Exceptionless.Tests/Repositories/StackRepositoryTests.cs b/tests/Exceptionless.Tests/Repositories/StackRepositoryTests.cs index 54b4093187..7193a0aa60 100644 --- a/tests/Exceptionless.Tests/Repositories/StackRepositoryTests.cs +++ b/tests/Exceptionless.Tests/Repositories/StackRepositoryTests.cs @@ -1,8 +1,11 @@ +using Exceptionless.Core; using Exceptionless.Core.Extensions; using Exceptionless.Core.Models; +using Exceptionless.Core.Models.Ingestion; using Exceptionless.Core.Repositories; using Exceptionless.Core.Repositories.Queries; using Exceptionless.Core.Utility; +using Exceptionless.Core.Services; using Exceptionless.DateTimeExtensions; using Exceptionless.Tests.Utility; using Foundatio.Caching; @@ -75,20 +78,215 @@ public async Task CanGetByStatus() public async Task CanGetByStackHashAsync() { long count = _cache.Count; - long hits = _cache.Hits; - long misses = _cache.Misses; var stack = await _repository.AddAsync(_stackData.GenerateStack(projectId: TestConstants.ProjectId, organizationId: TestConstants.OrganizationId, dateFixed: DateTime.UtcNow.SubtractMonths(1)), o => o.Cache()); Assert.NotNull(stack?.Id); - Assert.Equal(count + 2, _cache.Count); - Assert.Equal(hits, _cache.Hits); - Assert.Equal(misses, _cache.Misses); + Assert.True(_cache.Count >= count + 3); + long countAfterAdd = _cache.Count; + long hitsAfterAdd = _cache.Hits; + long missesAfterAdd = _cache.Misses; var result = await _repository.GetStackBySignatureHashAsync(stack.ProjectId, stack.SignatureHash); JsonAssert.AssertJsonEquivalent(_serializer.SerializeToString(stack), _serializer.SerializeToString(result)); - Assert.Equal(count + 2, _cache.Count); - Assert.Equal(hits + 1, _cache.Hits); - Assert.Equal(misses, _cache.Misses); + Assert.Equal(countAfterAdd, _cache.Count); + Assert.True(_cache.Hits >= hitsAfterAdd + 1); + Assert.InRange(_cache.Misses, missesAfterAdd, missesAfterAdd + 1); + } + + [Fact] + public async Task StackRouteCache_TwoConsumersObserveCreateDiscardReopenAndRemove() + { + var options = GetService(); + var routeCache = GetService(); + var lockProvider = GetService(); + var firstResolver = new StackRouteResolver(_repository, routeCache, lockProvider, options); + var secondResolver = new StackRouteResolver(_repository, routeCache, lockProvider, options); + var stack = _stackData.GenerateStack(projectId: TestConstants.ProjectId, organizationId: TestConstants.OrganizationId); + + Assert.Empty(await firstResolver.ResolveAsync(stack.ProjectId, [stack.SignatureHash], TestCancellationToken)); + await _repository.AddAsync(stack, o => o.ImmediateConsistency().Cache()); + await firstResolver.UpdateAsync(stack.ProjectId, stack.SignatureHash, StackRouteResolver.CreateRoute(stack)); + + var openRoutes = await secondResolver.ResolveAsync(stack.ProjectId, [stack.SignatureHash], TestCancellationToken); + Assert.Equal(StackStatus.Open, openRoutes[stack.SignatureHash].Status); + + stack.Status = StackStatus.Discarded; + await _repository.SaveAsync(stack, o => o.ImmediateConsistency().Cache()); + await firstResolver.UpdateAsync(stack.ProjectId, stack.SignatureHash, StackRouteResolver.CreateRoute(stack)); + var discardedRoutes = await secondResolver.ResolveAsync(stack.ProjectId, [stack.SignatureHash], TestCancellationToken); + Assert.Equal(StackStatus.Discarded, discardedRoutes[stack.SignatureHash].Status); + + stack.Status = StackStatus.Open; + await _repository.SaveAsync(stack, o => o.ImmediateConsistency().Cache()); + await firstResolver.UpdateAsync(stack.ProjectId, stack.SignatureHash, StackRouteResolver.CreateRoute(stack)); + var reopenedRoutes = await secondResolver.ResolveAsync(stack.ProjectId, [stack.SignatureHash], TestCancellationToken); + Assert.Equal(StackStatus.Open, reopenedRoutes[stack.SignatureHash].Status); + + await _repository.RemoveAsync(stack, o => o.ImmediateConsistency().Cache()); + await firstResolver.RemoveAsync(stack.ProjectId, stack.SignatureHash); + Assert.Empty(await secondResolver.ResolveAsync(stack.ProjectId, [stack.SignatureHash], TestCancellationToken)); + } + + [Fact] + public async Task StackRouteCache_OlderRepositoryFill_CannotOverwriteNewerStatus() + { + var options = GetService(); + var routeCache = GetService(); + string key = StackRouteResolver.GetCacheKey(TestConstants.ProjectId, "stale-route-race"); + var newer = new StackRouteCacheEntry( + true, + 20, + "507f1f77bcf86cd799439011", + StackStatus.Discarded); + var stale = new StackRouteCacheEntry( + true, + 10, + "507f1f77bcf86cd799439011", + StackStatus.Open); + + await routeCache.SetAsync(key, newer, options.EventIngestionV3.StackRouteCacheDuration); + await routeCache.SetAsync(key, stale, options.EventIngestionV3.StackRouteCacheDuration); + + var cached = await routeCache.GetAllAsync([key]); + Assert.True(cached[key].HasValue); + Assert.Equal(StackStatus.Discarded, cached[key].Value.Status); + Assert.Equal(20, cached[key].Value.Version); + } + + [Fact] + public async Task StackRouteCache_AuthoritativeWrite_AdvancesPastClockSkew() + { + var options = GetService(); + var routeCache = GetService(); + string key = StackRouteResolver.GetCacheKey(TestConstants.ProjectId, "authoritative-clock-skew"); + await routeCache.SetAsync( + key, + new StackRouteCacheEntry(true, 20, "507f1f77bcf86cd799439011", StackStatus.Open), + options.EventIngestionV3.StackRouteCacheDuration); + + await routeCache.SetAuthoritativeAsync( + key, + new StackRouteCacheEntry(true, 10, "507f1f77bcf86cd799439011", StackStatus.Discarded), + options.EventIngestionV3.StackRouteCacheDuration); + + var cached = await routeCache.GetAllAsync([key]); + Assert.True(cached[key].HasValue); + Assert.Equal(StackStatus.Discarded, cached[key].Value.Status); + Assert.Equal(21, cached[key].Value.Version); + } + + [Fact] + public async Task StackRouteCache_BulkFill_CannotOverwriteAuthoritativeProjectGeneration() + { + var options = GetService(); + var routeCache = GetService(); + string firstKey = StackRouteResolver.GetCacheKey(TestConstants.ProjectId, "bulk-route-a"); + string secondKey = StackRouteResolver.GetCacheKey(TestConstants.ProjectId, "bulk-route-b"); + var initial = new Dictionary + { + [firstKey] = new(true, 10, TestConstants.StackId, StackStatus.Open), + [secondKey] = new(true, 10, TestConstants.StackId2, StackStatus.Open) + }; + await routeCache.SetAllAsync(initial, options.EventIngestionV3.StackRouteCacheDuration); + + await routeCache.SetAllAuthoritativeAsync(new Dictionary + { + [firstKey] = new(true, 5, TestConstants.StackId, StackStatus.Discarded), + [secondKey] = new(true, 5, TestConstants.StackId2, StackStatus.Fixed) + }, options.EventIngestionV3.StackRouteCacheDuration); + await routeCache.SetAllAsync(initial, options.EventIngestionV3.StackRouteCacheDuration); + + var cached = await routeCache.GetAllAsync([firstKey, secondKey]); + Assert.Equal(StackStatus.Discarded, cached[firstKey].Value.Status); + Assert.Equal(StackStatus.Fixed, cached[secondKey].Value.Status); + Assert.Equal(11, cached[firstKey].Value.Version); + Assert.Equal(11, cached[secondKey].Value.Version); + } + + [Fact] + public async Task StackRouteCache_SignatureChange_TombstonesOldRoute() + { + var resolver = GetService(); + var stack = _stackData.GenerateStack(projectId: TestConstants.ProjectId, organizationId: TestConstants.OrganizationId); + await _repository.AddAsync(stack, o => o.ImmediateConsistency().Cache()); + string originalSignatureHash = stack.SignatureHash; + Assert.Contains(originalSignatureHash, await resolver.ResolveAsync(stack.ProjectId, [originalSignatureHash], TestCancellationToken)); + + // The in-memory cache stores object references, unlike the serialized distributed cache + // used in production. Mutate a detached copy so the repository can observe both versions. + stack = _serializer.Deserialize(_serializer.SerializeToString(stack)) + ?? throw new InvalidOperationException("Unable to clone stack."); + stack.SignatureHash = Guid.NewGuid().ToString("N"); + stack.DuplicateSignature = String.Concat(stack.ProjectId, ":", stack.SignatureHash); + stack.UpdatedUtc = stack.UpdatedUtc.AddTicks(1); + await _repository.SaveAsync(stack, o => o.ImmediateConsistency().Cache()); + + Assert.Empty(await resolver.ResolveAsync(stack.ProjectId, [originalSignatureHash], TestCancellationToken)); + var current = await resolver.ResolveAsync(stack.ProjectId, [stack.SignatureHash], TestCancellationToken); + Assert.Equal(stack.Id, current[stack.SignatureHash].StackId); + } + + [Fact] + public async Task StackRouteCache_SoftDeletedStack_TombstonesRoute() + { + var resolver = GetService(); + var stack = _stackData.GenerateStack(projectId: TestConstants.ProjectId, organizationId: TestConstants.OrganizationId); + await _repository.AddAsync(stack, o => o.ImmediateConsistency().Cache()); + Assert.Contains(stack.SignatureHash, await resolver.ResolveAsync(stack.ProjectId, [stack.SignatureHash], TestCancellationToken)); + + stack.IsDeleted = true; + stack.UpdatedUtc = stack.UpdatedUtc.AddTicks(1); + await _repository.SaveAsync(stack, o => o.ImmediateConsistency().Cache()); + + Assert.Empty(await resolver.ResolveAsync(stack.ProjectId, [stack.SignatureHash], TestCancellationToken)); + } + + [Fact] + public async Task StackRouteCache_BulkProjectDelete_RotatesPastInflightOldGenerationFill() + { + var options = GetService(); + var resolver = GetService(); + var routeCache = GetService(); + var stack = _stackData.GenerateStack(projectId: TestConstants.ProjectId, organizationId: TestConstants.OrganizationId); + await _repository.AddAsync(stack, o => o.ImmediateConsistency().Cache()); + StackRoute staleRoute = (await resolver.ResolveAsync(stack.ProjectId, [stack.SignatureHash], TestCancellationToken))[stack.SignatureHash]; + Assert.Equal(stack.Id, (await _repository.GetStackBySignatureHashAsync(stack.ProjectId, stack.SignatureHash))?.Id); + long oldGeneration = await routeCache.GetProjectGenerationAsync(stack.ProjectId); + + await _repository.SoftDeleteByProjectIdAsync(stack.OrganizationId, stack.ProjectId); + long currentGeneration = await routeCache.GetProjectGenerationAsync(stack.ProjectId); + Assert.True(currentGeneration > oldGeneration); + + // Simulate the completion of a repository lookup that started before deletion. + await routeCache.SetAsync( + StackRouteResolver.GetCacheKey(stack.ProjectId, stack.SignatureHash, oldGeneration), + StackRouteCacheEntry.FromRoute(staleRoute), + options.EventIngestionV3.StackRouteCacheDuration); + + Assert.Empty(await resolver.ResolveAsync(stack.ProjectId, [stack.SignatureHash], TestCancellationToken)); + Assert.Null(await _repository.GetStackBySignatureHashAsync(stack.ProjectId, stack.SignatureHash)); + } + + [Fact] + public async Task TryMarkRegressedAsync_UnrelatedStackUpdateAfterRouteRead_TransitionsFixedGeneration() + { + var resolver = GetService(); + var stack = _stackData.GenerateStack(projectId: TestConstants.ProjectId, organizationId: TestConstants.OrganizationId); + stack.MarkFixed(new McSherry.SemanticVersioning.SemanticVersion(1, 0), TimeProvider); + await _repository.AddAsync(stack, o => o.ImmediateConsistency().Cache()); + StackRoute route = (await resolver.ResolveAsync(stack.ProjectId, [stack.SignatureHash], TestCancellationToken))[stack.SignatureHash]; + + stack.Title = "Updated after route projection"; + stack.UpdatedUtc = stack.UpdatedUtc.AddTicks(1); + await _repository.SaveAsync(stack, o => o.ImmediateConsistency().Cache()); + + bool transitioned = await resolver.TryMarkRegressedAsync(route, "507f1f77bcf86cd799439012", TestCancellationToken); + + Assert.True(transitioned); + var updated = await _repository.GetByIdAsync(stack.Id); + Assert.NotNull(updated); + Assert.Equal(StackStatus.Regressed, updated.Status); + Assert.Equal("507f1f77bcf86cd799439012", updated.RegressionEventId); } [Fact] @@ -177,6 +375,68 @@ public async Task CanIncrementEventCounterAsync() Assert.Equal(utcNow.AddDays(1), stack.LastOccurrence); } + [Fact] + public async Task ApplyIngestionStackUsageAsync_AmbiguousRetryAndFullSave_ApplySettlementOnce() + { + var stack = await _repository.AddAsync( + _stackData.GenerateStack(projectId: TestConstants.ProjectId, organizationId: TestConstants.OrganizationId), + o => o.ImmediateConsistency()); + DateTime occurrence = DateTime.UtcNow.Floor(TimeSpan.FromMilliseconds(1)); + const long firstSettlement = 123456; + + await _repository.ApplyIngestionStackUsageAsync( + stack.OrganizationId, + stack.ProjectId, + stack.Id, + occurrence, + occurrence, + 3, + firstSettlement, + sendNotifications: false); + // The same request after an ambiguous Elasticsearch response must be a no-op. + await _repository.ApplyIngestionStackUsageAsync( + stack.OrganizationId, + stack.ProjectId, + stack.Id, + occurrence, + occurrence, + 3, + firstSettlement, + sendNotifications: false); + + stack = await _repository.GetByIdAsync(stack.Id); + Assert.NotNull(stack); + Assert.Equal(3, stack.TotalOccurrences); + Assert.Equal(firstSettlement, stack.IngestionStackUsageSequence); + + // Full-document saves must retain the fence or a late retry could double-apply. + stack.Title = "Unrelated stack update"; + await _repository.SaveAsync(stack, o => o.ImmediateConsistency()); + await _repository.ApplyIngestionStackUsageAsync( + stack.OrganizationId, + stack.ProjectId, + stack.Id, + occurrence, + occurrence, + 3, + firstSettlement, + sendNotifications: false); + await _repository.ApplyIngestionStackUsageAsync( + stack.OrganizationId, + stack.ProjectId, + stack.Id, + occurrence.AddSeconds(1), + occurrence.AddSeconds(1), + 2, + firstSettlement + 1, + sendNotifications: false); + + stack = await _repository.GetByIdAsync(stack.Id); + Assert.NotNull(stack); + Assert.Equal(5, stack.TotalOccurrences); + Assert.Equal(firstSettlement + 1, stack.IngestionStackUsageSequence); + } + [Fact] public async Task SetEventCounterAsync_WhenIncomingValuesAreOlderOrLower_ShouldOnlyApplyMonotonicUpdates() { diff --git a/tests/Exceptionless.Tests/Serializer/EventIngestionJsonContextTests.cs b/tests/Exceptionless.Tests/Serializer/EventIngestionJsonContextTests.cs new file mode 100644 index 0000000000..cfd7c0f5a3 --- /dev/null +++ b/tests/Exceptionless.Tests/Serializer/EventIngestionJsonContextTests.cs @@ -0,0 +1,135 @@ +using System.IO.Pipelines; +using System.Text; +using System.Text.Json; +using Exceptionless.Core.Models.Ingestion; +using Exceptionless.Core.Serialization; +using Xunit; + +namespace Exceptionless.Tests.Serializer; + +public sealed class EventIngestionJsonContextTests +{ + [Fact] + public async Task DeserializeAsyncEnumerable_MultipleTopLevelValues_ReturnsEveryEvent() + { + const string payload = """ + {"id":"event-1","type":"error","stack_trace":"at Example.Run()"} + {"id":"event-2","type":"log","message":"completed"} + """; + + var events = await DeserializeAsync(payload); + + Assert.Collection(events, + first => + { + Assert.Equal("event-1", first.Id); + Assert.Equal("error", first.Type); + Assert.Equal("at Example.Run()", first.StackTrace); + }, + second => + { + Assert.Equal("event-2", second.Id); + Assert.Equal("log", second.Type); + Assert.Equal("completed", second.Message); + }); + } + + [Fact] + public async Task DeserializeAsyncEnumerable_MissingRequiredId_DefersToPerEventValidation() + { + const string payload = """{"type":"log","message":"completed"}"""; + + var ev = Assert.Single(await DeserializeAsync(payload)); + Assert.Null(ev.Id); + } + + [Fact] + public async Task DeserializeAsyncEnumerable_UnknownProperty_IgnoresProperty() + { + const string payload = """{"id":"event-1","type":"log","future_value":42}"""; + + var events = await DeserializeAsync(payload); + + Assert.Single(events); + Assert.Equal("event-1", events[0].Id); + } + + [Fact] + public async Task DeserializeAsyncEnumerable_ClientMetadata_ReadsOptionalFields() + { + const string payload = """{"id":"event-1","type":"log","version":"3.4.0","level":"warning","client":{"name":"exceptionless.rust","version":"0.1.0"}}"""; + + var events = await DeserializeAsync(payload); + + var ev = Assert.Single(events); + Assert.Equal("3.4.0", ev.Version); + Assert.Equal("warning", ev.Level); + Assert.NotNull(ev.Client); + Assert.Equal("exceptionless.rust", ev.Client.Name); + Assert.Equal("0.1.0", ev.Client.Version); + } + + [Fact] + public async Task DeserializeAsyncEnumerable_ClientWithoutVersion_DefersToPerEventValidation() + { + const string payload = """{"id":"event-1","type":"log","client":{"name":"exceptionless.rust"}}"""; + + var ev = Assert.Single(await DeserializeAsync(payload)); + Assert.NotNull(ev.Client); + Assert.Null(ev.Client.Version); + } + + [Fact] + public Task DeserializeAsyncEnumerable_TopLevelArray_ThrowsJsonException() + { + const string payload = """[{"id":"event-1","type":"log"}]"""; + + return Assert.ThrowsAsync(() => DeserializeAsync(payload)); + } + + [Fact] + public void Serialize_HtmlSensitiveCharacters_UsesSafeEncoding() + { + var value = new EventIngestionV3Event + { + Id = "event-1", + Type = "log", + Message = "