Skip to content

[BUG] Decide Elasticsearch bulk export success from HTTP status and the errors flag - #4297

Open
thc1006 wants to merge 12 commits into
open-telemetry:mainfrom
thc1006:bugfix/elasticsearch-response-handling
Open

[BUG] Decide Elasticsearch bulk export success from HTTP status and the errors flag#4297
thc1006 wants to merge 12 commits into
open-telemetry:mainfrom
thc1006:bugfix/elasticsearch-response-handling

Conversation

@thc1006

@thc1006 thc1006 commented Jul 24, 2026

Copy link
Copy Markdown
Member

Fixes #4295

Short summary for review: #4297 (comment)

The bug

Both export paths decided whether a bulk write succeeded with body.find("\"failed\" : 0"). failed is per-shard information for one item, not the batch outcome (which is the top-level errors flag), and the literal even baked in pretty-printing whitespace, so it was wrong in both directions: a batch with a rejected item read as success, and a compact successful response read as failure.

The fix

A single IsBulkResponseSuccessful(status_code, body, expected_items, reason) that both paths call. A non-2xx status is a failure first. Then the body has to be a JSON object with a boolean errors flag and an items array holding one operation result per operation the request submitted, and errors has to be false. A malformed body counts as a failure, and the first item error is reported so the log says why.

The count is there because the exporter posts an unfiltered /_bulk with exactly one index operation per record, and Elasticsearch answers those with one entry in items per operation. Without it, {"errors":false} on its own was reported as a successful write of the whole batch. An exporter that claims success without a write acknowledgement gives the caller no reason to retry, which is the worse of the two directions to be wrong in, and a misconfigured proxy or a non-Elasticsearch endpoint answering with coincidentally shaped JSON both landed there.

Matching the count is not enough on its own. Elasticsearch answers each operation with an object keyed by the action name, so {"errors":false,"items":[null,null]} for a two record batch had the right length and was reported as a successful write of both records. That requirement already existed, but only on the errors:true path, where the walk that extracts the first item error skips anything that is not an object. The same body was therefore a failure with errors:true and a success with errors:false, so a responder that sends neither shape correctly picked the verdict with a flag it also controls. The requirement now runs before errors is read, and the skip inside the walk goes with it.

An acknowledgement is an entry with exactly one member, named index, holding a result object with a status. Elasticsearch keys each entry by the action it answers and the exporter writes every record with an index action, so that is what an answer to this request looks like, and the bulk schema makes status a required member of the result alongside _index. The parser holds each acknowledgement to a string _index and an integer status, and stops there. _index is checked for presence and type only, never compared: an alias resolves to the concrete index behind it, so the name a conforming server reports is not always the name the request was addressed to. Nothing beyond _index, status and a non-null error changes the verdict.

Nothing weaker ties the entry to the operation that was sent. Being an object was not the line it looked like: {"errors":false,"items":[{},{}]} matched the count and was an object per entry. Neither is carrying some action or other: {"unknown":{"status":201}} answers something this exporter never submitted, and {"index":{"status":201},"delete":{}} answers one operation with two. The count check is what makes those dangerous rather than untidy. A hundred records answered with a hundred filler entries reads as a successful write, the processor drops the batch, and the records are gone.

The status is then read against the flag rather than instead of it. A false errors asserts that every operation was applied, so {"errors":false,"items":[{"index":{"status":400}}]} is the response contradicting itself, and a response that contradicts itself is not evidence that anything was written. A conforming server never sends that combination, which is why this rejects nothing the flag alone would have accepted.

2xx is the whole success band on both layers, and that is a compatibility choice rather than a property of the endpoint. Elasticsearch documents the bulk response as HTTP 200, and an index operation as 201 for a new document or 200 for an overwrite, so 202, 206 and 299 are values a compliant server will not send. Accepting the band keeps the check working against proxies and Elasticsearch compatible servers that answer inside it, and it costs nothing here because every record is written with an index operation: there is no create returning 409 for a duplicate or delete returning 404 that could hide in the widened range. The boundaries are pinned at 199/200/299/300 by a case, so narrowing this to 200 and 200/201 later is a two line change with its edges already held. Say if you would rather have it narrow now.

The band is compared in the type the number was parsed as, not through an int. The value comes from the server, is_number_integer() holds for unsigned as well, and nlohmann does not range check get<int>(), so 2^32 + 200 narrows straight back into the band on a 32 bit int and reads as applied. Comparing directly also removes the need for a rejected-status sentinel: an earlier revision returned the first rejected status as an int and used 0 to mean "nothing was rejected", which cannot tell those apart, so {"errors":false,"items":[{"index":{"status":0}}]} was reported as a successful write. Both are covered by cases at 0, -0, -1, 199, 200, 201, 299, 300, 2^32 + 200 and UINT64_MAX, plus a non-integer and a string.

The status is what makes the response two things rather than one, so the synchronous handler now keeps both halves together. It publishes the status and the body only on the transition that records the outcome, and Export takes them in a single call. Reading them one lock at a time, with each response overwriting them unconditionally, let a client that delivers two responses for one request pair a status from one with a body from the other, and that pair is what the verdict is computed from. The asynchronous handler keeps its body in a local for the same reason, since nothing outside the call reads it.

An operation that did not apply says so twice, and both are read the same way whichever value the flag has. Elasticsearch documents error as present only on a failed operation, and the errors:true path already relied on that to name the first failure. Under errors:false it was ignored, so {"index":{"status":201,"error":{"type":"mapper_parsing_exception"}}} was accepted while the same body with errors:true was rejected. That is the same shape as the items entry problem above: one body, two verdicts, chosen by a flag the broken responder also controls. The status alone does not cover it, since the contradiction is between the status and the error rather than between the status and the flag. Both signals are now collected in the pass that already validates the items, which also removes the second walk the errors:true branch needed.

A null member is not a cause. find reports a key that is present holding null, and a serialiser that writes absent optionals that way is saying the operation applied, which is what the flag says too, so rejecting it would fail exports that are fine and catch nothing. Both shapes have a case.

The check stops there. It never asks what an operation's status means beyond the band, and it reads no other member of the result.

An earlier revision of this branch had the count check and dropped it, on the grounds that a filter_path response need not carry items. That reasoning does not apply here: this exporter never sends filter_path. If it ever does, the request side should say so rather than the parser accepting two incompatible response shapes.

The HTTP status matters and was the second-round finding: the synchronous path previously only logged a non-2xx status and still returned success, so HTTP 500 with {"errors":false} was reported as kSuccess. The status is now part of the result on both paths; the async handler drops its duplicated check and the sync handler stores the status for Export to pass in.

Structure

The helper lives in include/opentelemetry/exporters/elasticsearch/detail/es_bulk_response.h so tests can reach it, following the detail/ pattern already used by ext/http/client/detail/default_factory.h. It is a detail header rather than an anonymous-namespace function in the .cc (an earlier revision put it there, which is why it could not be tested).

It is excluded from the installed package, both the header and the detail directory, since the file name pattern alone still leaves an empty directory behind in the package. The component's *.h glob would otherwise ship it, and it includes <nlohmann/json.hpp>, which would make nlohmann a public dependency of the installed Elasticsearch headers for the first time. es_log_recordable.h is the precedent one line above in the same call: it is the only other header here that includes nlohmann, and it is already excluded for the same reason. Verified by installing into a clean prefix and listing what lands under include/opentelemetry/exporters/elasticsearch, which is now just es_log_record_exporter.h.

Worth your call rather than mine: the only other detail/ directory in the tree is ext/http/client/detail, and it is installed today, so detail/ has meant "public but unstable" here rather than "private". #4327 stopped installing it when it merged on 4 August, so excluding this one follows the convention rather than standing against it. On the reading that held before that, the file would belong in src/ instead, which would need no exclusion at all. I kept it where the tests can reach it and excluded it, and I am happy to move it if you would rather it were not reachable as a header at all.

On the Bazel side the header is not in the exporter target's hdrs. hdrs is a target's public interface, so listing it there would let a Bazel consumer include a header the CMake package deliberately does not install. It has its own target, visible only to this package, and the exporter reaches it through implementation_deps so it is not re-exported; the test depends on it directly.

Checked rather than assumed. A consumer that depends only on :es_log_record_exporter:

fatal error: opentelemetry/exporters/elasticsearch/detail/es_bulk_response.h: No such file or directory

and the same consumer with :es_bulk_response added builds. This is the repository's first implementation_deps and its first target level visibility, so say if you would rather the header simply stayed in hdrs and the two package surfaces differed, the way //ext:headers currently does.

One other thing in this diff that is not strictly part of the fix: the bulk URI drops ?pretty. The old check depended on pretty printed whitespace, so asking the server for it no longer serves any purpose, but it does change the request and I would rather name it than have it found.

Guarded with OPENTELEMETRY_HAVE_EXCEPTIONS so the try/catch compiles under the -fno-exceptions Bazel config. <nlohmann/json.hpp> stays included by the .cc because it still calls GetJSON().dump() directly.

Tests

The helper's cases are covered directly: pretty and compact success, a rejected item (reason names the underlying error), a non-2xx status with {"errors":false} (the sync false-success invariant), errors:true with no extractable item error, malformed and empty bodies, and a missing or non-boolean errors field. The accepted statuses are pinned on both sides and for both places they are read: 199, 200, 201, 202, 299 and 300 against the request, where only 200 passes, and the same set against an operation, where 200 and 201 pass. The acknowledgement count is pinned in both directions: no items, items:null, too few, too many, and the exact count. Entries that do not acknowledge an index operation are pinned separately: null entries, scalars, a nested array, {}, an action the exporter never submitted, index holding a scalar, two members in one entry, two members where one of them is valid, index with no status, and the same null shape with errors:true, which has to fail whichever way the flag reads. That case is mutation checked: removing the index lookup from the helper while leaving the member count and the status requirement turns it red, so it pins the operation identity rather than the JSON shape around it. A separate case covers the response contradicting itself: a 400 under errors:false, one rejection among several acknowledgements, and a response pairing the two statuses an applied operation answers with. The shared success and rejection fixtures now carry the status and _index a real bulk response has; they were abridged to what the old substring check looked at.

Now that #4298 has landed, a fake session that responds from inside SendRequest() no longer deadlocks the synchronous path, so three cases run through the exporter itself rather than through the helper:

The third is the one the helper tests cannot give you. A handler that stored a fixed status, or an Export() that never asked for one, passes every helper case.

Those three drive the synchronous path, and they skip in the configuration the coverage job builds. code.coverage configures all-options-abiv2-preview, which turns on ENABLE_ASYNC_EXPORT, so nothing there called Export() at all and six lines of this change went unexecuted: the handler's submitted_operations_ member, the bulk URI, the handler construction, and the parse with its failure log.

Two more cases cover that path. Export() returns before a response arrives there, so the parsed result decides only which internal log line is written, and they read it through a captured log handler the way batch_span_processor_test does: an accepted response logs no export failure, a rejected one does. Measured with lcov on the coverage preset, the six lines go from zero hits to covered. Of the two, only the rejected one discriminates: putting the substring check back on the asynchronous path makes it fail, because "failed" : 0 appears in a body that has a rejected item.

[  FAILED  ] ElasticsearchLogsExporterAsyncTests.ARejectedResponseIsReportedAsAFailure

The accepted one passes either way and is there as its control.

Both sets are fixtures that skip in SetUp rather than cases that compile out. gtest_add_tests registers from the source, so a case missing from the binary is still handed to CTest, and a gtest filter that matches nothing exits zero, which reports a pass without running. Putting the skip in SetUp rather than at the top of each body also keeps GTEST_SKIP, which returns, from leaving the rest of a body unreachable, which MSVC reports as C4702 and the maintainer mode jobs turn into an error.

The catch (...) guard in the helper stays uncovered. It is unreachable by input, since the parser rejects malformed bodies rather than throwing, and reaching it needs allocation fault injection, which has no precedent here.

The fake client is the same one #4331 adds to this file. Whichever lands first, the other drops the duplicate when it rebases.

Three things a later reading turned up

One success band, said once. The response status was compared as a plain int against 200 and 299, and each operation status went through a separate predicate that compares in the type nlohmann parsed it as. Same band, two spellings, and only one of them safe against a number the server chooses. Both go through one pair of overloads now, documented in one place, and the response status reaches the signed one through a cast an int cannot lose anything to. The two overloads are not one signed parameter because an operation status arrives as JSON: is_number_integer() is true for unsigned as well, and 2^32 + 200 narrows back into the band on a 32 bit int.

Worth saying that the HTTP side was never at risk of that. StatusCode is a uint16_t, so reaching the int parameter widens rather than narrows. What changed is that one rule is now written once rather than twice.

An acknowledgement has to name the index it wrote to. Every Elasticsearch index result carries _index, on a failed operation as much as an applied one, so a result without one is not an answer to an index operation. This now requires it, and that is a tightening: a backend that answers a bulk request without _index in its index results was accepted before and is a failed export now.

Presence and type only, deliberately. The name reported is the index the write resolved to, and an alias or a date math index makes that different from the one submitted, so comparing it against options_.index_ would fail an ordinary setup. That is also why the exporter's own index name is not passed down.

The cases had no bound. gtest_add_tests registered them with no timeout, and what these cases catch fails by hanging rather than by asserting, which stops the job instead of reporting. They carry a 120 second CTest timeout now: 27 registered entries, three of which the suite disables and CTest therefore skips, and all 24 enabled ones carry it, read back from ctest --show-only=json-v1 rather than assumed from having written it.

Against the same header with the target index check taken out, the case that holds it fails on all three of its new shapes: no _index at all, an _index that is a number, and one that is null.

Verification

  • Twenty four cases, none failing in any configuration, and 100% of them passing through ctest -R exporter.Elasticsearch. WITH_ELASTICSEARCH=ON gives [ PASSED ] 20 tests. with the four asynchronous cases skipped, and WITH_ASYNC_EXPORT_PREVIEW=ON gives [ PASSED ] 19 tests. with the five synchronous wiring cases skipped instead. A build with error logging compiled out, -DOTEL_INTERNAL_LOG_LEVEL=0, gives [ PASSED ] 15 tests. and skips the nine that read the outcome out of the log, because below error level an absent log cannot be told from one that was never written. Removing that skip makes two of them fail on correct code, 2 of 2. All build with no warnings under OTELCPP_MAINTAINER_MODE=ON.
  • Reverting Export() to the old substring check and rebuilding fails exactly the two cases that should discriminate:
[  FAILED  ] ElasticsearchLogsExporterWiringTests.RejectedItemIsAFailedExport
[  FAILED  ] ElasticsearchLogsExporterWiringTests.ServerErrorIsAFailedExportEvenWithAnAcceptedBody

AcceptedBulkResponseIsASuccessfulExport still passes there, which is correct: the happy path works under either check, so it is not a discriminator.

  • Success is decided from the compact /_bulk response; parsing no longer depends on pretty-printing.

  • ./ci/do_ci.sh format exits 0 with no diff (clang-format 18, cmake-format 0.6.13, buildifier 3.5.0). Worth running rather than cmake-format --check: the job reformats in place and diffs, and it wraps a comment narrower than --check accepts with the same config, so --check reported clean on a file the job then rewrote.

  • The lines the coverage report still marks in es_bulk_response.h are the defensive
    catch (...) that keeps anything from escaping the noexcept response handlers. No response body reaches it, since the parser rejects malformed and invalid-UTF-8 input before any throwing call, so exercising it would mean injecting an allocation failure through a global operator new override. That is program-wide machinery this repository does not use elsewhere, and it behaves differently in the shared-library configurations, so I left the guard uncovered rather than add it. I can add it if you would rather have the coverage.

  • clang-tidy was measured against main rather than in isolation, and over the test target so that the test file is compiled as well as the exporter. On the all-options-abiv2-preview preset both trees report the same three checks and the same twenty two warning lines, and nothing in es_bulk_response.h, so the branch adds nothing to warning_limit. The include-what-you-use jobs are green on all three presets.

thc1006 added a commit to thc1006/opentelemetry-cpp that referenced this pull request Jul 24, 2026
Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
@codecov

codecov Bot commented Jul 24, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.15385% with 3 lines in your changes missing coverage. Please review.
✅ Project coverage is 82.91%. Comparing base (60c3d11) to head (4a4b935).

Files with missing lines Patch % Lines
.../exporters/elasticsearch/detail/es_bulk_response.h 95.78% 3 Missing ⚠️
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main    #4297      +/-   ##
==========================================
+ Coverage   82.61%   82.91%   +0.30%     
==========================================
  Files         511      512       +1     
  Lines       20132    20205      +73     
==========================================
+ Hits        16631    16750     +119     
+ Misses       3501     3455      -46     
Files with missing lines Coverage Δ
...orters/elasticsearch/src/es_log_record_exporter.cc 50.38% <100.00%> (+38.17%) ⬆️
.../exporters/elasticsearch/detail/es_bulk_response.h 95.78% <95.78%> (ø)
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

thc1006 added a commit to thc1006/opentelemetry-cpp that referenced this pull request Jul 25, 2026
Review of open-telemetry#4297 found the synchronous path reported a non-2xx response
as a success: ResponseHandler::OnResponse only logged the status and
unconditionally set response_received_, waitForResponse returned true,
and Export then checked the body alone, so HTTP 500 with a body of
{"errors":false} became kSuccess. My description claiming the sync path
already checked the status was wrong: it observed the status but never
let it affect the ExportResult.

IsBulkResponseSuccessful now takes the status code and treats a non-2xx
as a failure before looking at the body. The top-level "errors" flag is
item-level and cannot override a transport or application error. Both
paths call it: the async handler drops its duplicated status check, and
the sync handler stores the status so Export can pass it in.

Tests assert the invariant directly, including HTTP 500 with
{"errors":false} on the sync-shaped path, plus the errors:true generic
reason and the missing item-error branches. A full handler-level mock
across HttpClient/Session is out of scope: the exporter has no such mock
today (its network tests are DISABLED_), and the status invariant is
what the bug was, so it is covered at the validator instead.

Fixes open-telemetry#4295

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
@thc1006 thc1006 changed the title [BUG] Parse the Elasticsearch bulk response instead of matching a substring [BUG] Decide Elasticsearch bulk export success from HTTP status and the errors flag Jul 25, 2026
thc1006 added a commit to thc1006/opentelemetry-cpp that referenced this pull request Jul 25, 2026
Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
thc1006 added a commit to thc1006/opentelemetry-cpp that referenced this pull request Jul 25, 2026
Review of open-telemetry#4297 found the synchronous path reported a non-2xx response
as a success: ResponseHandler::OnResponse only logged the status and
unconditionally set response_received_, waitForResponse returned true,
and Export then checked the body alone, so HTTP 500 with a body of
{"errors":false} became kSuccess. My description claiming the sync path
already checked the status was wrong: it observed the status but never
let it affect the ExportResult.

IsBulkResponseSuccessful now takes the status code and treats a non-2xx
as a failure before looking at the body. The top-level "errors" flag is
item-level and cannot override a transport or application error. Both
paths call it: the async handler drops its duplicated status check, and
the sync handler stores the status so Export can pass it in.

Tests assert the invariant directly, including HTTP 500 with
{"errors":false} on the sync-shaped path, plus the errors:true generic
reason and the missing item-error branches. A full handler-level mock
across HttpClient/Session is out of scope: the exporter has no such mock
today (its network tests are DISABLED_), and the status invariant is
what the bug was, so it is covered at the validator instead.

Fixes open-telemetry#4295

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
@thc1006
thc1006 force-pushed the bugfix/elasticsearch-response-handling branch from d751066 to 229ee19 Compare July 25, 2026 09:28
@thc1006
thc1006 marked this pull request as ready for review July 25, 2026 19:12
@thc1006
thc1006 requested a review from a team as a code owner July 25, 2026 19:12
Copilot AI review requested due to automatic review settings July 25, 2026 19:12

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@thc1006

thc1006 commented Aug 11, 2026

Copy link
Copy Markdown
Member Author

Eighteen days and no human has looked at this yet. The one review on it is Copilot reporting that it could not run because the requester was out of quota, and CI has been green throughout. So here is the short entry point that CONTRIBUTING.md asks for, because the description above is long and I suspect that is part of why this is easier to skip than to start.

What it does. Both export paths decided a bulk write had succeeded with body.find("\"failed\" : 0"). That substring is per shard information about one item rather than the batch outcome, and it bakes in pretty printed whitespace, so it was wrong in both directions: a batch with a rejected item read as success, and a compact successful response read as failure. One helper now decides it from the HTTP status, the top level errors flag, and one acknowledgement per record the request submitted.

Where to look, if you would rather spot check than read it all.

  • exporters/elasticsearch/src/es_log_record_exporter.cc, +50/-40, is the entire production change. Everything else is the new helper, its tests, and build files.
  • detail/es_bulk_response.h is the file worth reading. It is the whole decision in one function.
  • The behaviour a user would notice: a response that does not acknowledge every record is now a failure rather than a success. That is the point of the change, and the description says why an exporter claiming success without an acknowledgement is the worse direction to be wrong in.
  • The synchronous path used to log a non-2xx status and still return success, so HTTP 500 with {"errors":false} was reported as kSuccess. That is fixed here too.

Two choices I flagged as open, which I am now closing rather than leaving on you. I raised them because both are firsts for this repository, not because I think they are wrong, and re-reading the description I can see that asking for two decisions on top of a review is a good reason to defer the whole thing.

  1. The helper is a detail/ header, excluded from the installed package. It is a header rather than an anonymous namespace function in the .cc so the tests can reach it, which an earlier revision could not. The install output is verified against a clean prefix.
  2. On the Bazel side it has its own package private target reached through implementation_deps, so a Bazel consumer cannot include a header the CMake package deliberately does not install. Checked both ways: a consumer depending only on :es_log_record_exporter fails to find the header, and the same consumer with :es_bulk_response builds.

Both stay as they are unless you would rather they did not. Say the word on either and I will change it, but nothing is waiting on an answer.

If this is still unlooked-at once it leaves draft I will bring it to the C++ SIG meeting, which is what CONTRIBUTING.md suggests at this point. Not a nudge, just so you know where it goes next rather than it sitting here indefinitely.

Edited 16 Aug. It is a draft now, and that is my doing rather than anything about the code. Reading it alongside #4331 and #4337 turned up two gaps I had not pinned: no asynchronous case carried a non-200 status through to the parser, and no successful case carried more than one record, so either could have been hard-coded without a test noticing. Both are measured now, with the mutation that used to pass and no longer does. The original text said 18 August; that clock is paused while it sits in draft, and I have folded the follow-up comment in here rather than leave two to read.

@thc1006
thc1006 marked this pull request as draft August 13, 2026 17:45
@thc1006
thc1006 force-pushed the bugfix/elasticsearch-response-handling branch 3 times, most recently from 598ea43 to 98cc0f2 Compare August 14, 2026 22:02
…he errors flag

The exporter decided a bulk export had succeeded by searching the response body
for the substring "failed" : 0. That is a shard counter belonging to one item,
not a verdict on the batch, so a body that never contained it read as a failure
and a body that contained it anywhere read as a success.

Decide from the documented contract instead: a 2xx HTTP status, a top level
"errors" flag that is false, and one acknowledged operation result per record
submitted. Each entry of "items" has to be the result of the index operation the
exporter actually sent, carrying an integer status in the 2xx band, so a well
formed body that acknowledges nothing cannot pass. When "errors" is true the
first rejected item names the status in the log.

The status is compared in the type it was parsed as. Reading it through an int
first is not safe: the value comes from the server, is_number_integer() holds
for unsigned as well, and 2^32 + 200 narrows back into the 2xx band on a 32 bit
int. Comparing directly also removes the need for a rejected-status sentinel,
which could not tell a real status of 0 apart from "nothing was rejected".

The parser lives in detail/es_bulk_response.h, excluded from both the CMake
install set and the Bazel public headers.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
The errors:true path already treats an item's error member as proof the
operation did not apply. The errors:false path ignored it, so
{"errors":false,"items":[{"index":{"status":201,"error":{...}}}]}
was accepted while the same body with errors:true was rejected. Same
evidence, opposite verdict, decided by a flag a broken responder also
controls, which is the shape this helper exists to close.

Both signals are now read in the single pass that already validates the
items, which also drops the second walk the errors:true branch needed.
find() reports a key that is present with a null value, so the first
version rejected {"error":null}. A serialiser that writes absent
optionals as null is saying the operation applied, which is what the
flag says too, and rejecting it would fail exports that are fine while
catching nothing. Only a cause contradicts the flag.
Two things the helper is given were never held on the way in.

Every asynchronous case answered HTTP 200, so the status never had to
travel. Measured: passing a constant 200 instead of the response status
left all 17 cases green, and an async path that ignores the status is the
defect this change is about.

Every successful case carried one record, so the count never had to
travel either. The only two item case is a rejection, which fails whatever
count it is compared against. Measured: fixing the expected count at one
also left all 17 green, on both paths.

Three cases close it. An async server error under an accepted body, and a
batch of two acknowledged records on each path.

Measured both ways, each in the build where its path is live. With the
status pinned at 200 the async server error case fails alone. With the
count pinned at one the async batch case fails alone, and in a build
without async export the synchronous batch case fails alone. Clean: 19
pass with async export, 20 without, 3 of 3 each.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
Two of the four assertions that look for the export failure in the
captured log were guarded against a build with error logging compiled
out, and the two added last were not. Below error level
OTEL_INTERNAL_LOG_ERROR expands to nothing, so an absent log looks the
same as a log that was never written: the server error case fails on
correct code, and the two record case stops noticing a hard coded
expected count because the failure it looks for could not be written
either way.

The fixture says it once instead. One skip point, because GTEST_SKIP
returns and a second below it leaves the rest of the body unreachable,
which MSVC reports under maintainer mode. The four assertions are plain
now.

Measured with -DOTEL_INTERNAL_LOG_LEVEL=0. Without the skip two cases
fail on correct code, 2 of 2. With it the four skip and the file passes,
15 of 15. The ordinary builds are unchanged: 19 with async export, 20
without.

The fixture also puts back the handler it found rather than installing a
fresh default, which would displace one another case in the same binary
had installed.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
…ts index

Three things, one of which changes what is accepted.

The success band was written twice. The response status was compared as a
plain int against 200 and 299, and each operation status went through a
separate predicate that compares in the type nlohmann parsed it as. Same band,
two spellings, and only one of them safe against a status the server can
choose. Both go through one pair of overloads now, documented once, and the
response status reaches the signed one through a cast an int cannot lose
anything to.

An index result names the index it wrote to, and this did not read it. Every
Elasticsearch index result carries _index whether the operation applied or not,
so a result without one is not an answer to an index operation, and this now
says so. Presence and type only, deliberately: the name reported is the index
the write resolved to, and an alias or a date math index makes that different
from the one submitted, so comparing them would fail a normal setup. That is
also why the exporter's own index name is not passed down here.

This is a tightening. A backend that answers a bulk request without _index in
its index results was accepted before and is a failed export now.

And the cases had no bound. The asynchronous ones wait on a response that a
regression can stop delivering, and a case that hangs stops the job rather than
reporting, which is a worse failure than a red test. The 24 enabled cases carry a
120 second CTest timeout now, and so do the three the suite disables, read back
from ctest --show-only=json-v1 rather than assumed from having written it.

Against the same header with the target index check taken out, the case that
holds it fails on all three of its new shapes: no _index at all, an _index that
is a number, and one that is null. All 24 cases pass through ctest, and the
binary reports 19 passed with 5 skipped where the internal log is compiled out.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
The Format job runs cmake-format in place and diffs, and it wraps a comment narrower than --check accepts, so --check with the same config reported clean on a file the job then rewrote. This is what it produces, applied as it produced it, and the file is stable under a second pass.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
@thc1006
thc1006 force-pushed the bugfix/elasticsearch-response-handling branch from 2be3585 to 04152bf Compare August 15, 2026 08:48
thc1006 added a commit to thc1006/opentelemetry-cpp that referenced this pull request Aug 15, 2026
…the log assertions

Three things the cases were getting away with.

The deferred helper published the handler into a slot beside the promise and signalled
with the promise. EXPECT_EQ is not fatal, so when the five second handoff wait timed out
the helper carried on and read that slot while the exporter's thread could still be
writing it. A wait that times out has not observed the promise becoming ready, so those
two accesses have nothing ordering them and the failure path was itself undefined. The
handler now travels in the promise and is taken from the future, and each precondition
reports and returns instead of leaving the rest of the helper to run on state it just
said was wrong.

The cases that read the exporter's error lines now sit on their own fixture. Below error
level OTEL_INTERNAL_LOG_ERROR expands to nothing rather than being filtered, so with
-DOTEL_INTERNAL_LOG_LEVEL=0 the ones expecting the winner to report failed on correct
code and the ones expecting silence from the loser passed without testing anything.
Measured: with the fixture, twelve pass and those six skip; move one back and it fails
expecting one line and finding none.

The accepted bulk body now names an index. open-telemetry#4297 requires a string _index in every index
acknowledgement, so without it every success case here would be read as a failure once
these two meet, which is the opposite of what this file's comment promised.

The two deferred cases are renamed for what they hold. The session publishes the handler
before SendRequest() returns, so they cover a callback delivered after the handoff while
the export is still running, not a waiter proven to be inside cv_.wait().

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
…le 2xx band

One predicate answered for both the bulk request and each index operation, and it took
anything from 200 to 299. The comment above it already said what the two actually
answer with, 200 for the request and 200 or 201 for an operation, and then widened past
both.

202 is why that matters. It says the request was accepted and the processing is not
finished, and may never finish. Reading it as success is not a lenient reading of a
vague answer, it is the opposite of what the answer says, and success here is what lets
the caller drop the records it just handed over. A response of 202 carrying items that
each say 202 was a success under the old band, with nothing else in the body to catch
it.

So the request is held to 200 and an operation to 200 or 201, through two predicates
that each say what they are for. The two overloads stay, because an operation status
still arrives through nlohmann::json and 2^32 + 200 still narrows back to 200 on a 32
bit int.

One case carried the old rule in its body rather than in the boundary case: it paired a
200 operation with a 299 one and expected success. It now pairs 200 with 201, which is
the pair a real response produces.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
…t outside the lock

Two things in the same few lines.

The debug line was written before the outcome was decided, so a second response for the
same request described itself as well, even though its status and body had just been
dropped. Two lines then disagree about what the export returned, and the one the caller
was not given reads exactly like the one it was.

It also ran while this thread held mutex_. The log handler is replaceable application
code, so a handler that reaches back into this exporter would have been doing it from
inside the lock its own call needs. The message is now built under the lock, where the
body still exists, and written after it is released.

The case is in a fixture of its own because the line is a debug line: below debug level
the call does not exist, and the compiled in level is only half of it, since what it
dispatches is filtered again at runtime and the default there is Warning. Without
setting both the handler is installed and never told anything, which is how the case
first passed for the wrong reason.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
…cess means now

The bound guards the synchronous wiring cases, which wait on a completion. The
asynchronous fake calls back from inside SendRequest() and Export() returns without
waiting, so it is not what a stranded completion would hang.

The changelog said an acknowledgement carries a 2xx status. It carries one that says the
operation applied, which is a narrower thing, and the batch status it sits under is
narrower still.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
The @return still described the 2xx band on both the request and the operation, which is
what the code stopped accepting.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
…log level with the handler

Three small ones.

IsAcknowledgedStatus() is noexcept and reached straight for get(), leaving the type check
to whoever called it. The one caller does check, so nothing is wrong today, but the two
ways a later one could get it wrong are not symmetrical: a string throws, and out of a
noexcept function that is std::terminate rather than a wrong answer, while a float does
not throw at all, it truncates, so 200.5 would have answered for 200. The check is now
where the assumption is.

The asynchronous cases saved and restored the log handler but not the log level. The
level is process wide, another case in the same binary can lower it, and a failure the
runtime filtered out looks exactly like one the parser never reported, so they would have
stopped discriminating without saying so. This is not hypothetical in this file: the
debug case added earlier passed for that reason until the level went in beside the
handler.

The header now says what it does not do. A repeated key is folded before the document
reaches this, so a body carrying both "errors": true and "errors": false arrives as
whichever one the parser kept, and nothing here can tell the other was sent. Deciding an
outcome from the documented fields is what this is for; standing up to a responder that
is trying to be believed is not.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
@thc1006
thc1006 marked this pull request as ready for review August 16, 2026 01:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[BUG] Elasticsearch exporter decides bulk success by substring instead of the errors field

2 participants