Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
22a2fcf
Document V3 ingestion architecture
ejsmith Jul 13, 2026
dd4a593
Add V3 ingestion contracts and benchmarks
ejsmith Jul 13, 2026
400dfcc
Implement V3 ingestion processing pipeline
ejsmith Jul 13, 2026
ffdb907
Expose streaming V3 ingestion Minimal API
ejsmith Jul 13, 2026
8f1c09b
Add V3 ingestion load and rollout tooling
ejsmith Jul 13, 2026
c64a708
Compare V2 and V3 ingestion throughput
ejsmith Jul 13, 2026
52a1904
Design V3 for simple cross-platform clients
ejsmith Jul 13, 2026
b3ce174
Harden V3 event ingestion pipeline
ejsmith Jul 13, 2026
8130c39
Merge origin/main into feature/v3-event-ingestion
niemyjski Jul 16, 2026
c7b7460
Apply event handler control-flow style
niemyjski Jul 16, 2026
5a40172
Fix V3 runtime option binding
niemyjski Jul 16, 2026
bca7409
Merge origin/main into feature/v3-event-ingestion
niemyjski Jul 26, 2026
9aeb92d
Harden V3 ingestion boundaries
niemyjski Jul 26, 2026
83b09e4
Make Redis lease fence test deterministic
niemyjski Jul 26, 2026
6d92911
Split V3 usage tests by concern
niemyjski Jul 26, 2026
515525e
Merge remote-tracking branch 'origin/main' into feature/v3-event-inge…
niemyjski Aug 5, 2026
a42dfb6
Merge remote-tracking branch 'origin/main' into feature/v3-event-inge…
niemyjski Aug 8, 2026
74a2a72
Fix origin main merge artifacts in UsageService
niemyjski Aug 8, 2026
17500ec
Preserve usage notification semantics
niemyjski Aug 12, 2026
efafc10
Merge remote-tracking branch 'origin/main' into HEAD
niemyjski Aug 19, 2026
d2755ff
Fix V3 usage test constructor after base merge
niemyjski Aug 19, 2026
17bb0a3
Avoid creating empty event usage buckets
niemyjski Aug 19, 2026
fcd376e
Remove the forked V3 processing pipeline
ejsmith Oct 6, 2026
91bdd78
Merge origin/main into feature/v3-event-ingestion
ejsmith Oct 6, 2026
17cad19
Process V3 events through the existing event pipeline
ejsmith Oct 6, 2026
0834f87
Document the pipeline-based V3 ingestion contract
ejsmith Oct 6, 2026
a5e4bd6
Release V3 event ids when processing fails before the pipeline
ejsmith Oct 6, 2026
c430a27
Update ingestion benchmarks for the pipeline-based V3 endpoint
ejsmith Oct 6, 2026
dfa56ea
Keep V2 routing behavior unchanged for V3 ingestion
ejsmith Oct 6, 2026
7a0955c
Harden V3 reading, idempotency, and data handling
ejsmith Oct 6, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -86,3 +86,5 @@ docs/_cache/
.devcontainer/devcontainer-lock.json

*.lscache
# BenchmarkDotNet output
BenchmarkDotNet.Artifacts/
2 changes: 2 additions & 0 deletions Exceptionless.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@
<File Path="tests/http/users.http" />
<File Path="tests/http/webhooks.http" />
</Folder>
<Project Path="benchmarks/Exceptionless.Benchmarks/Exceptionless.Benchmarks.csproj" />
<Project Path="benchmarks/Exceptionless.Ingestion.Load/Exceptionless.Ingestion.Load.csproj" />
<Project Path="src/Exceptionless.AppHost/Exceptionless.AppHost.csproj" />
<Project Path="src/Exceptionless.Core/Exceptionless.Core.csproj" />
<Project Path="src/Exceptionless.EmailTemplates/Exceptionless.EmailTemplates.csproj" />
Expand Down
7 changes: 7 additions & 0 deletions benchmarks/Directory.Build.props
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<Project>
<Import Project="..\src\Directory.Build.props" />

<PropertyGroup>
<IsPackable>false</IsPackable>
</PropertyGroup>
</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="BenchmarkDotNet" Version="0.15.8" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\..\src\Exceptionless.Core\Exceptionless.Core.csproj" />
</ItemGroup>

<ItemGroup>
<!-- Compile the production reader source so the microbenchmark cannot drift to a substitute parser. -->
<Compile Include="..\..\src\Exceptionless.Web\Utility\EventIngestionV3StreamReader.cs"
Link="Production\EventIngestionV3StreamReader.cs" />
</ItemGroup>
</Project>
11 changes: 11 additions & 0 deletions benchmarks/Exceptionless.Benchmarks/Program.cs
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
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[] _v3NdjsonPayload = null!;
private byte[] _v3ArrayPayload = null!;
private JsonSerializerOptions _readOptions = null!;

[Params(1, 100, 1000)]
public int EventCount { get; set; }

[GlobalSetup]
public async Task SetupAsync()
{
// Payloads are written with the app options, like a client following the V3 contract. The
// read options match what the V2 parser and EventIngestionV3Processor use.
var jsonOptions = new JsonSerializerOptions().ConfigureExceptionlessDefaults();
_readOptions = new JsonSerializerOptions(jsonOptions) { 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 = Event.KnownTypes.Error,
Date = DateTimeOffset.UnixEpoch.AddSeconds(index),
Message = message,
ExceptionType = exceptionType,
StackTrace = stackTrace
};
}

_v2Payload = EventCount == 1
? JsonSerializer.SerializeToUtf8Bytes(v2Events[0])
: JsonSerializer.SerializeToUtf8Bytes(v2Events);

using var ndjson = new MemoryStream();
foreach (var v3Event in v3Events)
{
JsonSerializer.Serialize(ndjson, v3Event, jsonOptions);
ndjson.WriteByte((byte)'\n');
}

_v3NdjsonPayload = ndjson.ToArray();
_v3ArrayPayload = JsonSerializer.SerializeToUtf8Bytes(v3Events, jsonOptions);

// Fail fast if a payload stops matching the V3 contract, so the benchmarks never time the
// reader's invalid-event path.
if (await ReadV3Async(_v3NdjsonPayload) != EventCount || await ReadV3Async(_v3ArrayPayload) != EventCount)
throw new InvalidOperationException("The V3 benchmark payloads must contain only valid events.");
}

[Benchmark(Baseline = true)]
public int DeserializeV2Payload()
{
string input = Encoding.UTF8.GetString(_v2Payload);
return input.GetJsonType() switch
{
JsonType.Object => JsonSerializer.Deserialize<PersistentEvent>(input, _readOptions) is null ? 0 : 1,
JsonType.Array => JsonSerializer.Deserialize<PersistentEvent[]>(input, _readOptions)?.Length ?? 0,
_ => 0
};
}

[Benchmark]
public Task<int> ReadV3NdjsonAsync() => ReadV3Async(_v3NdjsonPayload);

[Benchmark]
public Task<int> ReadV3JsonArrayAsync() => ReadV3Async(_v3ArrayPayload);

private async Task<int> ReadV3Async(byte[] payload)
{
using var stream = new MemoryStream(payload, writable: false);
var pipeReader = PipeReader.Create(stream);
var reader = new EventIngestionV3StreamReader(pipeReader, MaximumEventSize, _readOptions);
int count = 0;
try
{
while (await reader.ReadAsync(CancellationToken.None) is { } record)
{
if (record.Event is not null)
count++;
}
}
finally
{
await pipeReader.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);
}

/// <summary>
/// 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.
/// </summary>
[MemoryDiagnoser]
public class LargeStackEventIngestionDeserializationBenchmarks
{
private const int MaximumEventSize = 512 * 1024;
private byte[] _v2Payload = null!;
private byte[] _v3Payload = null!;
private JsonSerializerOptions _readOptions = null!;

[Params(16 * 1024, 128 * 1024)]
public int StackTraceLength { get; set; }

[GlobalSetup]
public async Task SetupAsync()
{
string stackTrace = new('x', StackTraceLength);
var jsonOptions = new JsonSerializerOptions().ConfigureExceptionlessDefaults();
_readOptions = new JsonSerializerOptions(jsonOptions) { RespectNullableAnnotations = false };
_v2Payload = JsonSerializer.SerializeToUtf8Bytes(new Dictionary<string, object?>
{
["type"] = "error",
["message"] = "Operation failed",
["data"] = new Dictionary<string, object?>
{
["@simple_error"] = new
{
type = "Example.Exception",
message = "Operation failed",
stack_trace = stackTrace
}
}
});
_v3Payload = JsonSerializer.SerializeToUtf8Bytes(
new EventIngestionV3Event
{
Id = "large-stack-event",
Type = Event.KnownTypes.Error,
Message = "Operation failed",
ExceptionType = "Example.Exception",
StackTrace = stackTrace
},
jsonOptions);

if (await ReadV3Async(_v3Payload) != StackTraceLength)
throw new InvalidOperationException("The V3 benchmark payload must contain one valid event.");
}

[Benchmark(Baseline = true)]
public PersistentEvent? DeserializeV2Payload()
{
string input = Encoding.UTF8.GetString(_v2Payload);
return JsonSerializer.Deserialize<PersistentEvent>(input, _readOptions);
}

[Benchmark]
public Task<int> ReadV3Async() => ReadV3Async(_v3Payload);

private async Task<int> ReadV3Async(byte[] payload)
{
using var stream = new MemoryStream(payload, writable: false);
var pipeReader = PipeReader.Create(stream);
var reader = new EventIngestionV3StreamReader(pipeReader, MaximumEventSize, _readOptions);
try
{
return await reader.ReadAsync(CancellationToken.None) is { Event: { } parsed }
? parsed.StackTrace?.Length ?? 0
: 0;
}
finally
{
await pipeReader.CompleteAsync();
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
</PropertyGroup>

<ItemGroup>
<InternalsVisibleTo Include="Exceptionless.Tests" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\..\src\Exceptionless.Core\Exceptionless.Core.csproj" />
</ItemGroup>
</Project>
Loading
Loading