Skip to content

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

Open
thc1006 wants to merge 31 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 31 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

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.

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 here, and that is a property of this exporter rather than a policy choice: every record is written with an index operation, which Elasticsearch answers with 200 or 201. There is no create returning 409 for a duplicate or delete returning 404 to weigh, so the band is the same one the response's own HTTP status uses, pinned at 200/299/300 by a case.

The check stops there. It never asks what an operation's status means beyond the band, and it does not read error bodies under errors:false, since the status already covers them.

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 stops installing it, which would make this exclusion the convention rather than the exception, but that PR is not merged and I would rather not lean on it. By the reading that holds today the file belongs 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 prefer the convention held.

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 2xx range is pinned at its 199/200/299/300 boundaries. 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 the 200/299/300 band boundary. 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.

Verification

  • [ PASSED ] 16 tests. with WITH_ELASTICSEARCH=ON: eleven helper cases, the recordable and client construction cases, and the three above. The two asynchronous cases skip in that configuration; WITH_ASYNC_EXPORT_PREVIEW=ON runs them and skips the three synchronous ones, [ PASSED ] 15 tests. Both configurations 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).

  • 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 94.11765% with 4 lines in your changes missing coverage. Please review.
✅ Project coverage is 81.17%. Comparing base (11fa0db) to head (09848c8).
⚠️ Report is 5 commits behind head on main.

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

Impacted file tree graph

@@            Coverage Diff             @@
##             main    #4297      +/-   ##
==========================================
- Coverage   81.29%   81.17%   -0.12%     
==========================================
  Files         447      451       +4     
  Lines       19028    19250     +222     
==========================================
+ Hits        15467    15624     +157     
- Misses       3561     3626      +65     
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 93.55% <93.55%> (ø)

... and 11 files with indirect coverage changes

🚀 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 9 commits July 25, 2026 17:25
…string

Both export paths decided whether a bulk write succeeded with

    body.find("\"failed\" : 0") == std::string::npos

which is wrong in both directions. "failed" is per-shard information for
a single item, so a batch where one item was rejected and another
reported "failed": 0 was recorded as a success and the rejected records
were silently lost. The literal also contains pretty-printing spaces, so
a compact response never matched and a completely successful export was
reported as a failure.

Both paths now parse the body and check the top level "errors" field,
which is what the bulk API defines as the outcome for the batch, and
report the first item error rather than only that something went wrong.
A malformed or unparseable body counts as a failure. The asynchronous
path also checks the HTTP status, which it previously ignored while the
synchronous path did not.

nlohmann/json.hpp was already included by this file and already linked
in both the CMake and Bazel targets, so this adds no dependency.

Fixes open-telemetry#4295

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
AsyncResponseHandler::OnResponse is noexcept, and the helper it now calls
can throw: dump() and the string concatenation that builds the failure
reason both allocate. clang-tidy reported bugprone-exception-escape on
OnResponse, which was a warning this branch added rather than one that
was already there.

The helper is now noexcept and absorbs anything thrown while inspecting
the body, which is the right answer anyway: a response that cannot be
inspected is not a successful export. The default reason is set inside
the try so the handler itself does no allocation.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
The Bazel noexcept configuration compiles with -fno-exceptions, where
try is a hard error rather than a warning, so the previous commit broke
that job. The repository already has an idiom for this,
OPENTELEMETRY_HAVE_EXCEPTIONS, used the same way in attribute_utils.h.

Nothing is lost when it is off: without exceptions an allocation failure
terminates rather than unwinding, so there is nothing for the handler to
catch in the first place.

The macro arrives through opentelemetry/version.h, which includes
macros.h with an IWYU pragma export, so no new include is needed.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
Codecov reported 0% patch coverage: none of the 34 changed lines were
exercised. The reason was structural rather than an oversight about
which tests to write. The helper sat in an anonymous namespace inside
the .cc, so no test translation unit could reach it, and the existing
Elasticsearch tests are almost all DISABLED_ because they need a live
server.

It is a pure function from a response body to a verdict, so it belongs
somewhere a test can call it. It moves to a detail header, following the
detail/ pattern already used by ext/http/client/detail/default_factory.h,
and is added to the Bazel hdrs so the test target picks it up.

Four tests cover it: pretty-printed and compact successes, a batch with
one rejected item where the reason must name the underlying error, an
unusable body, and a missing or non-boolean errors field. The first two
are the cases the old substring check got wrong in each direction.

nlohmann/json.hpp is no longer used by the .cc and is removed from it.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
IWYU flagged es_log_record_exporter.cc for a missing nlohmann/json.hpp:
it calls GetJSON().dump() at the point it builds the request body, which
is a direct use of the type. When the bulk response helper moved to its
own header I removed the include with it, but this use remained, and
IWYU treats reaching it through es_log_recordable.h as an indirect
dependency. The include goes back.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
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>
An errors:false body was trusted even when it carried no item results, so
a truncated or unrelated response such as {"errors":false} or an empty
"items" array reported a dropped batch as a success. The check now
requires "items" to be an array whose size matches the number of records
exported: both the sync and async paths pass that count, and the async
handler stores it alongside the response.

Also move the non-2xx status check inside the exception guard so building
its failure reason cannot terminate a noexcept caller on allocation
failure, drop the ?pretty query now that the parser no longer depends on
whitespace, and log a synchronous non-2xx response once from Export()
rather than a second time with the full body from the response handler.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
Review found the added items.size() == records check to be half-baked
schema validation beyond the scope of open-telemetry#4295: it checks array length but
not that each entry is a real operation result, and it would reject a
legitimate filter_path response. A 2xx response whose top-level "errors"
is false is Elasticsearch's authoritative batch-success signal, so the
condition returns to 2xx + JSON object + boolean errors. Also clear
failure_reason on entry so a success cannot leave a stale reason behind,
and document the exception path's reason as best-effort.

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.

…response-handling

# Conflicts:
#	CHANGELOG.md
thc1006 and others added 3 commits July 27, 2026 03:13
Add two response-parser tests and soften one doc comment; no change to the
validator logic.

- TreatsThe2xxRangeAsTheSuccessBand: 199/200/299/300 pin the status boundary.
- DoesNotRequireItemsForSuccess: a body with errors:false and no items (e.g.
  filter_path-filtered) is a success, locking out re-adding an items-count check.
- Reword the header comment so it no longer claims every 2xx means every
  operation was applied; a successful 2xx with errors:false is treated as batch
  success.
…response-handling

# Conflicts:
#	CHANGELOG.md
thc1006 added 2 commits August 3, 2026 02:38
Resolves the overlap with open-telemetry#4298, which landed after this branch last synced.

Both changes rewrite the same region of the synchronous ResponseHandler.
open-telemetry#4298 owns transport completion: OnResponse records a completion state under
the handler mutex and Export() waits on a predicate, first writer wins. This
branch owns the application outcome: it keeps the HTTP status alongside the
body so Export() can hand both to IsBulkResponseSuccessful().

The two layer rather than compete. recordCompletionLocked(Success) now means
the transport delivered a response, not that the export succeeded. A failure
event that fired first still keeps Failure, so Export() returns kFailure
without consulting the body.

response_received_ is gone, replaced by the completion state.
es_bulk_response.h includes nlohmann/json.hpp. The component's *.h glob
would install it, which would make nlohmann a public dependency of the
installed Elasticsearch headers for the first time.

es_log_recordable.h is the existing precedent: it is the only other
Elasticsearch header that includes nlohmann, and it is already excluded
for the same reason.
@thc1006
thc1006 force-pushed the bugfix/elasticsearch-response-handling branch from 5298a83 to 189419b Compare August 2, 2026 19:04
The helper tests call IsBulkResponseSuccessful() directly, so they cannot
show that the status and the body reach it. A handler that stored a fixed
status, or an Export() that never asked for one, passes all of them.

Three cases through the exporter with a fake HTTP client: an accepted bulk
response, the rejected item from open-telemetry#4295 whose shard counter still reads
"failed" : 0, and a 500 carrying a body the parser would otherwise accept.

The fake client is the same one open-telemetry#4331 adds to this file. Whichever lands
first, the other drops the duplicate when it rebases.
@thc1006
thc1006 force-pushed the bugfix/elasticsearch-response-handling branch from 189419b to 0283411 Compare August 2, 2026 19:13
The file name pattern stops the header shipping but the directory is still
traversed and created, so the package carried an empty
include/opentelemetry/exporters/elasticsearch/detail.
The exporter posts an unfiltered /_bulk with one index operation per
record, and Elasticsearch answers those with one entry in items per
operation. The check accepted any 2xx body carrying errors:false, so
{"errors":false\} alone was reported as a successful write of every
record in the batch.

An exporter that reports success without a write acknowledgement gives
the caller no reason to retry, which is the more dangerous direction of
the two. A misconfigured proxy, a non-Elasticsearch endpoint answering
with coincidentally shaped JSON, or a server returning an incomplete
envelope all reached the same result.

An earlier revision of this branch dropped a count check as half baked
schema validation, on the grounds that a filter_path response need not
carry items. That reasoning does not apply to this exporter: it never
sends filter_path. Confirming the response answers as many operations as
were submitted is not the same as validating each item's contents.
@thc1006
thc1006 force-pushed the bugfix/elasticsearch-response-handling branch from 49cf38c to 223618d Compare August 2, 2026 21:43
thc1006 added 8 commits August 3, 2026 06:12
RejectedItemIsAFailedExport used a body with two items while exporting one
record, so after the acknowledgement count went in it was failing on the
count rather than on the rejected item. It still passed, which is the worst
way for a test to be wrong.
The helper takes std::size_t but reached the definition only through
<nlohmann/json.hpp> or <string>. The CHANGELOG line named the substring
match alone, which stopped being the whole story once the HTTP status
and the acknowledgement count joined the decision.
GTEST_SKIP returns, so a skip at the top of each body left the rest
unreachable, which MSVC reports as C4702 and the maintainer mode jobs
turn into an error. A fixture that skips in SetUp keeps every case in
the binary, which gtest_add_tests still needs, and the anonymous
namespace keeps clang-tidy's misc-use-internal-linkage quiet.
The signature gained expected_items and the return contract gained the
exact-count requirement, but the comment still described the two
argument form.
… to run

The coverage job configures all-options-abiv2-preview, which turns on
ENABLE_ASYNC_EXPORT, so the wiring cases skip there and nothing calls
Export() at all. That left six lines of this change unexecuted in the
one configuration Codecov measures: the handler's submitted_operations_
member, the bulk URI, the handler construction, and the parse with its
failure log.

Export() returns before a response arrives on that path, so the parsed
result decides only which internal log line is written. These two cases
read it through a captured log handler, the way the batch span processor
test does.
hdrs is a target's public interface, so listing the detail header there
let a Bazel consumer include a header the CMake package deliberately does
not install. It moves to its own target that only this package can depend
on, reached through implementation_deps so the exporter does not
re-export it, and the test depends on it directly.

Checked with a consumer that depends only on the exporter target: the
include now fails to resolve, and adding the internal target to that
consumer makes it build again.
Its only assertion sat behind OTEL_INTERNAL_LOG_LEVEL, so a consumer
that lowers the level would have compiled a case with nothing to check.
Asserting the Export() result as well matches the accepted case and
states the contract this path has: success is reported whatever the
response said, which is why the outcome is read from the log.
The acknowledgement count made the array length evidence that the batch was
written, but nothing looked at what the entries were. A body such as
{"errors":false,"items":[null,null]} for a two record batch matched the
count and was reported as a successful write of both records.

The entries were already required to be objects, but only on the errors:true
path, where the walk that extracts the first item error skips anything else.
The same body with errors:true was a failure while errors:false was a
success, so a responder that sends neither shape correctly picked the verdict
with a flag it also controls. The requirement moves above the errors branch
so that it holds on both paths, and the skip inside the walk goes with it.

Elasticsearch answers each operation with an object keyed by the action name,
so no conforming response changes verdict. The check stops at the entry being
an object: an item status that disagrees with the top level errors flag is
the server contradicting its own summary, and re-deriving that flag from item
statuses would only duplicate it.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
thc1006 added 5 commits August 3, 2026 20:05
An "items" entry only had to be an object, which is where the walk that
extracts the first item error happened to guard rather than a line with a
reason behind it. {"errors":false,"items":[{},{}]} for a two record batch
matched the count, was an object per entry, and was reported as a successful
write of both records.

Elasticsearch's bulk schema answers each operation with an object keyed by
the action whose value is that operation's result, and makes "status" a
required member of the result. An entry with no action key, or one whose
action holds no status, acknowledges nothing. Only the presence of a status
is read: its value is the operation's own HTTP status, and whether any
operation failed is what the top-level "errors" flag already reports, so
re-deriving it here would duplicate that flag and put status policy in the
exporter.

The shared response fixtures gain the "status" and "_index" that a real bulk
response carries. They were abridged to the fields the old substring check
looked at.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
The check trusted "errors" once the entries looked like operation results, so
{"errors":false,"items":[{"index":{"status":400}}]} was reported as a
successful write of a record Elasticsearch had rejected.

A false "errors" asserts that every operation was applied, and each
acknowledgement carries the operation's own status, so the response can be
checked against itself. A conforming server never sends that combination,
which is why this rejects nothing the flag alone would have accepted: it
catches only a response that contradicts itself.

2xx is the whole success band here. The exporter writes every record with an
index operation, which Elasticsearch answers with 200 or 201, so there is no
create returning 409 or delete returning 404 to weigh, and the band is the
one the response's own HTTP status already uses.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
An entry only had to carry some action whose result held a status, so
{"errors":false,"items":[{"unknown":{"status":201}}]} and an entry answering
two operations at once both passed. Neither is evidence that the index
operations this exporter submitted were processed, and the count check they
satisfy is what makes them dangerous: a proxy or an Elastic compatible
endpoint answering a hundred records with a hundred filler entries reported a
successful write, the batch was dropped, and the records were gone.

Elasticsearch keys each entry by the action it answers, and the exporter
writes every record with an index action, so a conforming answer to this
request has exactly one member per entry and it is named "index". Requiring
that, rather than any action at all, is what ties the acknowledgement to the
operation that was sent.

The case is mutation checked: removing the "index" lookup while leaving the
member count and the status requirement in place turns it red, so it pins the
operation identity rather than the JSON shape around it.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
The three ways an entry can fail to be an index acknowledgement reported one
reason between them, so the log said an entry was wrong without saying which
kind of wrong. A padded array, an answer to an action that was never submitted,
and a result with no status now say so separately.

With every entry checked to be one index result, the error walk no longer needs
to look for the operation or guard against it not being an object, and the
comments that restated all of this in prose are gone with it.

The bulk action in Export() points at the parser, which requires the
acknowledgement to name that same action.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
Two comments described a check this branch removes, which is the kind of
rationale @dbarker asked to keep in the pull request and the git history rather
than in the code, on open-telemetry#4327. They now describe the bodies themselves: two
successes that differ only in whitespace, and a rejected item whose shard
counter still reads as a success to anything reading the body as text.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
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

3 participants