Skip to content

Commit 52a1904

Browse files
committed
Design V3 for simple cross-platform clients
1 parent c64a708 commit 52a1904

12 files changed

Lines changed: 544 additions & 1 deletion

docs/event-ingestion-v3-architecture.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@ methodology for [issue #2368](https://github.com/exceptionless/Exceptionless/iss
55
It is deliberately separate from the public API documentation until the V3
66
contract is ready for rollout.
77

8+
The companion [client protocol and ecosystem plan](event-ingestion-v3-client-protocol.md)
9+
defines the minimum sender, capability profiles, Sentry and Raygun comparison,
10+
and compatibility rules for future event and non-event telemetry.
11+
812
## Objectives
913

1014
Event ingestion V3 is a breaking, performance-first API with these invariants:
@@ -22,6 +26,9 @@ Event ingestion V3 is a breaking, performance-first API with these invariants:
2226
not enqueue it for another process to deserialize.
2327
- Clients send raw stack traces. The server computes the grouping fingerprint
2428
and builds the persisted structured error.
29+
- A new language can implement the core sender using only its standard HTTPS
30+
and JSON facilities. Advanced reliability and framework features are
31+
independently testable capability profiles, not ingestion prerequisites.
2532
- Events assigned to discarded stacks terminate before billable admission,
2633
full error materialization, event persistence, stack statistics, or side
2734
effects.
@@ -245,6 +252,9 @@ Completion requires all of the following evidence:
245252
- Multi-instance tests for route status changes and quota reservation.
246253
- V2 serialization audit results before and after shared-service adoption.
247254
- OpenAPI V3 baseline and HTTP samples.
255+
- Language-neutral client specification, golden fixtures, black-box conformance
256+
tests, and timed new-language implementation evidence meeting the client
257+
effort gates.
248258
- Microbenchmark reports and end-to-end load results for the scenario matrix.
249259
- A staged rollout through the internal project and selected projects with a
250260
documented rollback switch.
@@ -280,6 +290,18 @@ hashed raw-trace fallback and increment a parser-fallback metric without logging
280290
the trace. Optional `stacking.signature_data` and `stacking.title` provide
281291
explicit manual stacking.
282292

293+
Only `id` and `type` are required. Maintained SDKs should also identify
294+
themselves with `client.name` and `client.version`; applications can send
295+
first-class `version` and `level` values without knowing internal event data
296+
keys. Unknown event properties are ignored so additions remain compatible.
297+
298+
`/api/v3/events` remains an event-only NDJSON stream. It will not acquire a
299+
per-line kind/payload wrapper or a stream header. A future optional,
300+
length-delimited advanced transport can carry the exact same event JSON beside
301+
attachments, minidumps, profiles, or other typed items. This keeps the minimum
302+
client smaller than Sentry's envelope implementation without forcing binary or
303+
heterogeneous data into the event object.
304+
283305
The executable reference client and repeatable load harness live in
284306
`benchmarks/Exceptionless.Ingestion.Load`; usage is documented in
285307
`benchmarks/README.md`. It can submit the same conceptual corpus through the

docs/event-ingestion-v3-client-protocol.md

Lines changed: 379 additions & 0 deletions
Large diffs are not rendered by default.

src/Exceptionless.Core/Models/Ingestion/EventIngestionV3Event.cs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,14 @@ public sealed record EventIngestionV3Event
3232

3333
public string[]? Tags { get; init; }
3434

35+
[StringLength(EventIngestionV3Limits.MaximumVersionLength, MinimumLength = 1)]
36+
public string? Version { get; init; }
37+
38+
[StringLength(EventIngestionV3Limits.MaximumLevelLength, MinimumLength = 1)]
39+
public string? Level { get; init; }
40+
41+
public EventIngestionV3Client? Client { get; init; }
42+
3543
[StringLength(EventIngestionV3Limits.MaximumExceptionTypeLength, MinimumLength = 1)]
3644
public string? ExceptionType { get; init; }
3745

@@ -49,6 +57,17 @@ public sealed record EventIngestionV3Event
4957
public JsonElement? Data { get; init; }
5058
}
5159

60+
public sealed record EventIngestionV3Client
61+
{
62+
[Required]
63+
[StringLength(EventIngestionV3Limits.MaximumClientNameLength, MinimumLength = 1)]
64+
public required string Name { get; init; }
65+
66+
[Required]
67+
[StringLength(EventIngestionV3Limits.MaximumClientVersionLength, MinimumLength = 1)]
68+
public required string Version { get; init; }
69+
}
70+
5271
public sealed record EventIngestionV3Stacking
5372
{
5473
[StringLength(EventIngestionV3Limits.MaximumMessageLength, MinimumLength = 1)]

src/Exceptionless.Core/Models/Ingestion/EventIngestionV3EventSizer.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,11 @@ public static long GetEstimatedSize(EventIngestionV3Event source)
1010
long size = 128;
1111
size += GetSize(source.Id) + GetSize(source.Type) + GetSize(source.Source) + GetSize(source.Message);
1212
size += GetSize(source.ReferenceId) + GetSize(source.ExceptionType) + GetSize(source.StackTrace);
13+
size += GetSize(source.Version) + GetSize(source.Level);
1314
size += GetSize(source.Tags);
1415
size += GetSize(source.Data);
16+
if (source.Client is not null)
17+
size += 32 + GetSize(source.Client.Name) + GetSize(source.Client.Version);
1518
if (source.Stacking is not null)
1619
size += 32 + GetSize(source.Stacking.Title) + GetSize(source.Stacking.SignatureData);
1720

src/Exceptionless.Core/Models/Ingestion/EventIngestionV3Limits.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@ public static class EventIngestionV3Limits
1111
public const int MaximumStackTraceLength = 256 * 1024;
1212
public const int MaximumUserIdentityLength = 255;
1313
public const int MaximumUserNameLength = 255;
14+
public const int MaximumVersionLength = 255;
15+
public const int MaximumLevelLength = 32;
16+
public const int MaximumClientNameLength = 255;
17+
public const int MaximumClientVersionLength = 255;
1418
public const int MaximumTags = 50;
1519
public const int MaximumTagLength = 255;
1620
public const int MaximumMetadataEntries = 100;

src/Exceptionless.Core/Serialization/EventIngestionJsonContext.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ namespace Exceptionless.Core.Serialization;
1212
UseStringEnumConverter = false)]
1313
[JsonSerializable(typeof(EventIngestionV3Event))]
1414
[JsonSerializable(typeof(EventIngestionV3Event[]))]
15+
[JsonSerializable(typeof(EventIngestionV3Client))]
1516
[JsonSerializable(typeof(EventIngestionV3User))]
1617
[JsonSerializable(typeof(EventIngestionV3Request))]
1718
[JsonSerializable(typeof(EventIngestionV3Environment))]

src/Exceptionless.Core/Services/EventIngestionV3Processor.cs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,17 @@ public async Task<EventIngestionV3Response> ProcessAsync(
163163
return $"exception_type cannot exceed {EventIngestionV3Limits.MaximumExceptionTypeLength} characters.";
164164
if (source.StackTrace?.Length > EventIngestionV3Limits.MaximumStackTraceLength)
165165
return $"stack_trace cannot exceed {EventIngestionV3Limits.MaximumStackTraceLength} characters.";
166+
if (source.Version is not null && (String.IsNullOrWhiteSpace(source.Version) || source.Version.Length > EventIngestionV3Limits.MaximumVersionLength))
167+
return $"version must contain between 1 and {EventIngestionV3Limits.MaximumVersionLength} characters.";
168+
if (source.Level is not null && (String.IsNullOrWhiteSpace(source.Level) || source.Level.Length > EventIngestionV3Limits.MaximumLevelLength))
169+
return $"level must contain between 1 and {EventIngestionV3Limits.MaximumLevelLength} characters.";
170+
if (source.Client is not null)
171+
{
172+
if (String.IsNullOrWhiteSpace(source.Client.Name) || source.Client.Name.Length > EventIngestionV3Limits.MaximumClientNameLength)
173+
return $"client.name must contain between 1 and {EventIngestionV3Limits.MaximumClientNameLength} characters.";
174+
if (String.IsNullOrWhiteSpace(source.Client.Version) || source.Client.Version.Length > EventIngestionV3Limits.MaximumClientVersionLength)
175+
return $"client.version must contain between 1 and {EventIngestionV3Limits.MaximumClientVersionLength} characters.";
176+
}
166177
if (source.Stacking?.Title?.Length > EventIngestionV3Limits.MaximumMessageLength)
167178
return $"stacking.title cannot exceed {EventIngestionV3Limits.MaximumMessageLength} characters.";
168179
string? stackingError = ValidateDictionary(source.Stacking?.SignatureData, "stacking.signature_data", value => value.Length <= EventIngestionV3Limits.MaximumMetadataValueLength);

src/Exceptionless.Core/Services/EventMaterializer.cs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,19 @@ public PersistentEvent Materialize(EventIngestionV3Event source, StackFingerprin
4646
ev.SetManualStackingInfo(fingerprint.Title, fingerprint.SignatureData.ToDictionary(pair => pair.Key, pair => pair.Value));
4747
}
4848

49+
if (!String.IsNullOrWhiteSpace(source.Version))
50+
ev.SetVersion(source.Version);
51+
if (!String.IsNullOrWhiteSpace(source.Level))
52+
ev.SetLevel(source.Level.Trim());
53+
if (source.Client is not null)
54+
{
55+
ev.SetSubmissionClient(new SubmissionClient
56+
{
57+
UserAgent = source.Client.Name.Trim(),
58+
Version = source.Client.Version.Trim()
59+
});
60+
}
61+
4962
if (source.User is not null)
5063
{
5164
ev.SetUserIdentity(new UserInfo(source.User.Identity ?? String.Empty, source.User.Name)

tests/Exceptionless.Tests/Controllers/Data/openapi-v3.json

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -303,6 +303,25 @@
303303
},
304304
"components": {
305305
"schemas": {
306+
"EventIngestionV3Client": {
307+
"required": [
308+
"name",
309+
"version"
310+
],
311+
"type": "object",
312+
"properties": {
313+
"name": {
314+
"maxLength": 255,
315+
"minLength": 1,
316+
"type": "string"
317+
},
318+
"version": {
319+
"maxLength": 255,
320+
"minLength": 1,
321+
"type": "string"
322+
}
323+
}
324+
},
306325
"EventIngestionV3Environment": {
307326
"type": "object",
308327
"properties": {
@@ -483,6 +502,32 @@
483502
"type": "string"
484503
}
485504
},
505+
"version": {
506+
"maxLength": 255,
507+
"minLength": 1,
508+
"type": [
509+
"null",
510+
"string"
511+
]
512+
},
513+
"level": {
514+
"maxLength": 32,
515+
"minLength": 1,
516+
"type": [
517+
"null",
518+
"string"
519+
]
520+
},
521+
"client": {
522+
"oneOf": [
523+
{
524+
"type": "null"
525+
},
526+
{
527+
"$ref": "#/components/schemas/EventIngestionV3Client"
528+
}
529+
]
530+
},
486531
"exception_type": {
487532
"maxLength": 2000,
488533
"minLength": 1,

tests/Exceptionless.Tests/Endpoints/EventIngestionV3EndpointTests.cs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,29 @@ public async Task Post_GzipError_ParsesStructuredStackOnServer()
9494
Assert.Equal(42, frame.LineNumber);
9595
}
9696

97+
[Fact]
98+
public async Task Post_ClientMetadata_PersistsFirstClassEventData()
99+
{
100+
const string payload = """
101+
{"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"}}
102+
""";
103+
104+
using HttpResponseMessage httpResponse = await PostAsync(new StringContent(payload, Encoding.UTF8, "application/x-ndjson"));
105+
EventIngestionV3Response response = await DeserializeAsync(httpResponse);
106+
107+
Assert.Equal(1, response.Persisted);
108+
await RefreshDataAsync();
109+
var ev = Assert.Single((await _eventRepository.GetByReferenceIdAsync(TestConstants.ProjectId, "v3-client-metadata-ref-0001")).Documents);
110+
Assert.Equal("3.4.0", ev.GetVersion());
111+
Assert.Equal("info", ev.GetLevel());
112+
var client = ev.GetSubmissionClient(
113+
GetService<Foundatio.Serializer.ITextSerializer>(),
114+
GetService<ILogger<EventIngestionV3EndpointTests>>());
115+
Assert.NotNull(client);
116+
Assert.Equal("exceptionless.go", client.UserAgent);
117+
Assert.Equal("1.2.0", client.Version);
118+
}
119+
97120
[Fact]
98121
public async Task Post_ReplayedEvent_ReturnsDuplicateWithoutSecondWrite()
99122
{

0 commit comments

Comments
 (0)