Repository navigation
Conversation
1a60290 to
b3ce174
Compare
# Conflicts: # src/Exceptionless.Core/Configuration/AppOptions.cs # src/Exceptionless.Web/Api/Handlers/EventHandler.cs
|
Follow-up feedback and thermo-nuclear audit complete. Feedback inventory and classification:
Thermo-nuclear full-diff result:
Verification:
No concrete external blocker remains. PR draft/admin state was intentionally left unchanged. |
…stion # Conflicts: # src/Exceptionless.Core/Models/Organization.cs # src/Exceptionless.Web/Program.cs # tests/Exceptionless.Tests/Api/OpenApiSnapshotTests.cs
# Conflicts: # src/Exceptionless.Core/Repositories/Configuration/Indexes/OrganizationIndex.cs # src/Exceptionless.Core/Services/UsageService.cs # src/Exceptionless.Web/Program.cs # tests/Exceptionless.Tests/Api/OpenApiSnapshotTests.cs # tests/Exceptionless.Tests/Services/UsageServiceTests.cs
ejsmith
left a comment
There was a problem hiding this comment.
Review: V3 streaming ingestion
Verdict: request changes. NDJSON over a normal URL with a bearer token, with the server parsing raw stack traces, is the right shape. But this PR is a second ingestion pipeline, not just a streaming endpoint. It has its own materializer, stack assignment, quota leases, stack-count pipeline, notification path and side-effect worker: ~18.3k lines across 143 files. The fork already disagrees with V2 on grouping, so a project that switches to V3 would get every stack duplicated.
Line numbers are at 17bb0a30d.
Against the goals
Easy to send a single event: not yet
Content-Type: application/jsonis rejected with 415; onlyapplication/x-ndjsonis accepted.- A pretty-printed object fails. The reader splits on newlines, so line 1 is just
{, which throwsJsonReaderExceptionand returns 400 for the whole request. That's what Postman,JSON.stringify(x, null, 2)or a hand-written curl body produce. Confirmed with a probe test againstEventIngestionV3StreamReader. idandtypeare both required. V2 inferstype(error vs log) and doesn't require an id.- Field limits reject the event instead of truncating it: a message over 2,000 chars, a 101-char tag, 51 tags, or a malformed
reference_iddrops the whole event (EventIngestionV3Processor.Validate).EventMaterializer.cs:127-133already truncates the V2 way, but that code can't run because validation rejects first. Losing an error because its message is long is the wrong trade for an error tracker.
Streaming a huge set: works, but brittle
- One bad line kills the rest of the stream. A syntax error or a wrong-typed routing field (
"id": 1, confirmed) throws out of the reader and returns 400. The response then says "Retry the complete request", which fails the same way every time. Lines are already framed, so the server could count that line as invalid (with its index) and continue. - Errors identify events only by client
id(max 100), so an event with a missing or bad id can't be matched back to what the client sent. - Exceeding 10k events returns 413 after earlier microbatches were already committed.
Retry-Afteris never sent, even though the protocol doc tells clients to honor it.- Over-quota events return 200 with
blocked: n, so clients get no signal to back off.
Fast: fewer hops than V2, but chatty
For a warm 100-event microbatch (cached route, events carrying dates), each step runs one after another:
- ~11–14 Redis calls, plus a per-organization distributed lock.
- 2 Elasticsearch calls: a multi-get duplicate pre-check, then the bulk create.
On top of that:
- The side-effect worker re-saves every event that has request or environment data.
- A Redis key is kept per stored event for 7 days.
- Reading stops while each microbatch processes.
- The per-organization reservation lock serializes every microbatch for one organization across all replicas. It also doesn't provide the guarantee it claims, because commit and release don't take it (
UsageService.cs:715-722). OverageMiddlewareis bypassed, so an exhausted organization still pays for fingerprinting, routing, the duplicate check and the lock on every event before the events are blocked.
Easy for clients: the protocol doc (Profiles 0–3, the conformance kit) is genuinely good. But there's no way to send a structured error, so the existing .NET/JS SDKs would lose fidelity and get different grouping by switching.
Correctness blockers
- V3 groups errors differently from V2. Six real .NET errors were compared: the 6.2.0 SDK's
ToErrorModelagainstex.ToString()sent through V3. Every signature hash differed.- V3 strips method parameters, which V2 keeps (
StackTraceParser.cs:232,337-341vsMethodExtensions.GetSignature). - Async methods are named differently.
- Source maps are skipped;
15_SourceMapPluginruns before the signature in V2. - Manual stacking uses a new hash scheme.
- The result is duplicated stacks, with fixed/ignored/discarded status, history and notification state lost.
- The compatibility test can't catch this because it builds the "V2" error from V3's own parser output.
- V3 strips method parameters, which V2 keeps (
- The parser misgroups real traces (
StackTraceParser.cs:184,200-210,343-361):- Any line containing
@becomes a frame, soError: user bob@example.com not foundmakes one stack per email address and stores part of the address in the stack. - A message line starting with "At …" is treated as a frame.
- Location-only frames (anonymous JS, Node internals) put line:col and the bundle hash into the fingerprint, so there's a new stack every deploy.
- Python uses the outermost frame, so unrelated Django errors share a stack.
- The Go fallback hashes pointer values, giving a new stack per occurrence.
- About 200 nested
--->inner exceptions exceed the serializer's max depth.
- Any line containing
- Usage undercount (
UsageService.cs:826-857). A late settlement into a bucket the saver never visited for that organization or project is added and then expires. Billing is undercounted and the monthly limit can be exceeded. - Stored but never counted (
EventBatchWriter.cs:259-311). Regression handling runs between the bulk add and enqueueing the side-effect work item. A lock timeout or crash there leaves events persisted with no stack counts or notifications. - Stale discard routes (
StackRouteResolver.cs:114). Route versions are last-writer-wins oncurrentVersion + 1, not Elasticsearch order. A discard/reopen race can silently discard an open stack's events, or keep storing a discarded one's, for up to the 1-hour cache.
"V2 unchanged" isn't true
- Usage saver: it now saves one bucket later, but
GetEventsLeftAsyncstill sums only the current and previous buckets, which leaves a limit gap at every 5-minute boundary. It also now caps by the month's remaining events, which changes how V2 trims batches. - Stack saves:
StackRepositoryenablesOriginalsEnabled, adding an extra get before every stack save, plus Redis lock and cache work on every save. A lock timeout can now fail V2 saves after Elasticsearch has already succeeded. - Notifications: V2 notifications and webhooks now go through duplicate-detection markers.
- Redis keys: 7-day
:v3:processedkeys are written for every organization and project in every bucket, even for V2-only traffic. - Benchmark instrumentation in the public contract: a track-processing header appears in the V2 OpenAPI, plus an exposed CORS header and an
EventPostServicetracking path.
Suggested direction
Split it so the first PR is only the transport:
- One endpoint, forgiving input. Accept
application/jsonand NDJSON. Frame by JSON token depth instead of newlines, so all of these work: a single object, a pretty-printed object, a top-level array (streamed one element at a time, not buffered) and NDJSON. Keep the per-record byte cap. For NDJSON, resync at the next line after a bad one. - Minimal event =
{"message":"…"}. Makeidoptional (recommended for retries), infertypethe way V2 does, and truncate oversized fields the way V2 does. - Feed the existing pipeline. Materialize each microbatch to
PersistentEventand callEventPipeline.RunAsync(events, organization, project)inline, exactly asEventPostsJobdoes. Source maps, bot throttling, identical stacking, sessions/404s, notifications and stats then come for free, with no fork. - Optimize the shared pipeline in follow-ups that also help V2: check for discarded stacks before quota, and use create-only bulk with per-item results (201 = persisted and billable, 409 = duplicate).
- Server-side raw stack parsing as its own PR, with golden fixtures from real SDK output so V2 and V3 must produce identical hashes. Accept a structured
errorfrom day one so existing SDKs can move without regrouping. - Client signals:
- Report errors with each event's index and id.
- Make retry guidance specific to the status code.
- Send
Retry-Afteron 429 and 503. - Return 402 for an exhausted organization instead of 200 with
blocked.
Separately, the branch conflicts with main (370 commits behind), and CI hasn't run on the current head.
V3 should be a transport over the existing event pipeline rather than a second implementation of stacking, quota, statistics, and notifications. The fork had already diverged from V2 grouping, so remove it and restore the shared V2 code it modified. Removed: server-side stack parsing and fingerprinting, stack route cache, quota reservation stores, batch writer, side-effect work item and stack usage pipeline, durable queue deduplication, and the benchmark-only processing status endpoints and V2 tracking header. The previous implementation remains available at 17bb0a3 for the follow-up stack parsing work. The V3 endpoint is rebuilt on top of the pipeline in the following commits.
V3 is now a streaming transport over EventPipeline, the same pipeline EventPostsJob runs for V2. Each microbatch is mapped to PersistentEvents and processed inline, so stacking, source maps, bot filtering, sessions, notifications, statistics, and usage accounting match V2 exactly. Client contract changes: - Accept application/json and NDJSON. Event boundaries come from the JSON structure, so a single object, a pretty-printed object, a streamed JSON array, and newline-delimited or concatenated objects all work. - Every event property is optional. The type defaults to error or log as in V2, and oversized fields are truncated by the pipeline instead of rejecting the event. - Nested objects use the V2 data models, and a structured error can be sent alongside the simpler exception_type and stack_trace fields. - An invalid, oversized, or malformed event is reported with its index and skipped; malformed NDJSON resumes at the next line. - Event ids deduplicate resends within the idempotency window. Ids are released when an event is blocked, invalid, or fails, so a resend is processed again. - 402 is returned before reading when the organization is out of events, 429 and 503 include Retry-After, the disabled endpoint returns 404, and partial results include status-specific retry guidance.
Update the architecture and client protocol documents, configuration defaults, and HTTP examples for the forgiving request format, optional fields, per-event errors, and status-specific retry behavior.
A failure after claiming event ids but before the pipeline ran left the ids claimed, so a resend was acknowledged as a duplicate and never stored. Release the claims on any failure up to the pipeline, and process events that were completely read before a body error so they are reported in the partial result.
Benchmark the new stream reader for NDJSON and JSON arrays, and measure submission latency and query visibility in the load harness now that the processing status endpoints are gone.
Remove the GET-only metadata on the SPA fallback, which turned main's 404 for non-GET application routes into 405. Document the V3 request body with an OpenAPI operation transformer instead of Accepts metadata, so routing does not send requests with a missing or unsupported content type to the fallback before the endpoint can accept or reject them. Reduce the OverageMiddleware and EventPostRequestBodyStream changes to the functional V3 additions.
- Skip an event over the size limit with a byte scanner that tracks strings and nesting, so a single huge token is neither retained nor rescanned. Rescan a long incomplete token only after the unread bytes double, keeping reading linear. - Claim event ids as pending and mark them stored after the pipeline. A resend that finds a pending claim is reported as in progress and returns 503 with Retry-After instead of being acknowledged as a duplicate that could be lost if the original request fails. - Normalize custom event data the way V2 normalizes deserialized data, so a non-object value under a mapped key such as @error is stored under an escaped key instead of failing the microbatch. - Resume after invalid NDJSON only at a line that begins with '{', so the nested objects of a broken pretty-printed event are not read as events. - Detect a byte order mark regardless of how the first bytes arrive.
|
Closing in favor of the revised plan in #2368 (comment). Research into comparable products showed that acknowledging after a durable enqueue (not after full processing) and evolving |
Summary
Adds a V3 event ingestion endpoint that makes sending one event trivial and streaming many events efficient. V3 is a transport over the existing event pipeline, so V2 and V3 process events identically.
POST /api/v3/eventsandPOST /api/v3/projects/{projectId}/events(Minimal APIs, client token).PipeReaderand never buffered whole.{"message":"..."};typedefaults toerrororloglike V2. Oversized fields are truncated by the pipeline instead of rejecting the event.exception_type+ rawstack_trace(stored as a V2 simple error) or as a structurederror(the V2 error model), which groups exactly like V2.EventPipeline.RunAsync, the same callEventPostsJobmakes. Stacking, source maps, bot throttling, sessions, 404s, notifications, statistics, and usage accounting are shared with V2, with no object storage write, queue round trip, or second deserialization.indexand skipped (malformed NDJSON resumes at the next line that begins with{). Oversized events are skipped by a byte scanner without being retained, and reading stays linear for long tokens.idmakes resending safe within the idempotency window (1 day by default). Ids are claimed as pending, marked stored after the pipeline, and released when an event is blocked, invalid, or fails. A resend that arrives while the original is still processing gets a retryableevent_in_progressinstead of a possibly falseduplicate.200with counts (persisted,discarded,duplicate,blocked,invalid,failed) and per-event errors;402before reading when the organization is suspended or out of events;429/503withRetry-After;404when V3 is disabled; problem details includepartial_resultand status-specific retry guidance./docs/v3/openapi.json, HTTP examples, architecture and client protocol documents, BenchmarkDotNet suites, and a V2/V3 load harness.Design change from the earlier revision
The earlier revision implemented a second ingestion pipeline (server-side stack parsing and fingerprinting, a stack route cache, distributed quota reservations, a batch writer, a stack usage pipeline, and a side-effect worker). Review found it grouped errors differently from V2, which would have duplicated every stack for a project moving to V3, skipped V2 behavior such as source maps, and changed shared V2 code. That implementation was removed and the shared V2 files now match
main.This reverses the issue's decision to keep V3 off
PipelineBase. Pipeline performance work now belongs in the shared pipeline so V2 benefits too. Follow-ups:SimpleErrorPluginTODO), verified against real SDK output so V2 and V3 produce identical signatures. The prototype parser is preserved in17bb0a30d.Compatibility
V3 is a new, opt-in surface. V2 routes, payloads, the queued handoff, the
202response, and the shared pipeline, usage, stack, and notification code are unchanged frommain. Shared changes are additive:OverageMiddlewareskips/api/v3(the V3 endpoint performs its own submission, suspension, and event-limit checks),EventPostRequestBodyStreamgains optional rejection messages, and the OpenAPI setup now produces separatev2andv3documents with an unchangedv2snapshot.Verification
dotnet build Exceptionless.slnx— 0 warnings, 0 errors.Part of #2368