Skip to content

Commit 229ee19

Browse files
committed
Drop the item-count check; trust the top-level errors flag
Review found the added items.size() == records check to be half-baked schema validation beyond the scope of #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>
1 parent 47be24f commit 229ee19

3 files changed

Lines changed: 57 additions & 106 deletions

File tree

‎exporters/elasticsearch/include/opentelemetry/exporters/elasticsearch/detail/es_bulk_response.h‎

Lines changed: 31 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33

44
#pragma once
55

6-
#include <cstddef>
76
#include <nlohmann/json.hpp>
87
#include <string>
98

@@ -20,36 +19,32 @@ namespace detail
2019
/**
2120
* Decide whether an Elasticsearch bulk response reports the whole batch as written.
2221
*
23-
* The bulk API reports the outcome for the batch in the top level "errors" field. A "failed"
24-
* count belongs to the per-shard information of a single item and says nothing about the other
25-
* items, so matching it as a substring both misses real failures and depends on how the
26-
* response happens to be formatted.
22+
* The bulk API reports the outcome for the batch in the top level "errors" field. A "failed" count
23+
* belongs to the per-shard information of a single item and says nothing about the other items, so
24+
* matching it as a substring both misses real failures and depends on how the response happens to
25+
* be formatted. A 2xx response whose top-level "errors" is false means every operation was applied,
26+
* which is the batch-level success signal the exporter needs.
2727
*
28-
* Callers include noexcept response handlers, so nothing may escape from here. Anything that
29-
* stops the body being inspected counts as a failed export. With exceptions disabled there is
30-
* nothing to catch, since an allocation failure terminates instead.
28+
* Callers include noexcept response handlers, so nothing may escape from here. Anything that stops
29+
* the body being inspected counts as a failed export. With exceptions disabled there is nothing to
30+
* catch, since an allocation failure terminates instead.
3131
*
3232
* A non-2xx HTTP status is a failure regardless of the body: the top-level "errors" flag only
33-
* describes item-level outcomes and cannot override a transport or application error, so both
34-
* the synchronous and asynchronous paths must pass the status here rather than inspecting the
35-
* body alone.
36-
*
37-
* A compliant bulk response also carries one entry in "items" per submitted operation. A response
38-
* that omits "items" or returns a different count cannot confirm the batch was written, so it is
39-
* a failure too: otherwise an "errors":false body with no items would report a dropped batch as a
40-
* success. Callers pass the number of records they exported so the counts can be compared.
33+
* describes item-level outcomes and cannot override a transport or application error, so both the
34+
* synchronous and asynchronous paths must pass the status here rather than inspecting the body
35+
* alone.
4136
*
4237
* @param status_code the HTTP status code of the response
4338
* @param body the raw response body
44-
* @param expected_item_count the number of records submitted in the bulk request
45-
* @param failure_reason set to a short explanation when the function returns false
46-
* @return true when the status is 2xx and the response reports every item as written
39+
* @param failure_reason set to a best-effort explanation when the function returns false; an
40+
* allocation failure on an error path may leave it empty
41+
* @return true when the status is 2xx and the response reports the batch as written
4742
*/
4843
inline bool IsBulkResponseSuccessful(int status_code,
4944
const std::string &body,
50-
std::size_t expected_item_count,
5145
std::string &failure_reason) noexcept
5246
{
47+
failure_reason.clear();
5348
#if OPENTELEMETRY_HAVE_EXCEPTIONS
5449
try
5550
{
@@ -61,8 +56,6 @@ inline bool IsBulkResponseSuccessful(int status_code,
6156
return false;
6257
}
6358

64-
failure_reason = "the response body could not be inspected";
65-
6659
const nlohmann::json parsed = nlohmann::json::parse(body, nullptr, false);
6760
if (parsed.is_discarded() || !parsed.is_object())
6861
{
@@ -77,45 +70,33 @@ inline bool IsBulkResponseSuccessful(int status_code,
7770
return false;
7871
}
7972

80-
// Require one item result per exported record before trusting the outcome flag, so a
81-
// truncated or unrelated body cannot be read as a whole-batch success.
82-
const auto items = parsed.find("items");
83-
if (items == parsed.end() || !items->is_array())
84-
{
85-
failure_reason = "the response body has no array \"items\" field";
86-
return false;
87-
}
88-
if (items->size() != expected_item_count)
89-
{
90-
failure_reason = "the response reported " + std::to_string(items->size()) +
91-
" item result(s) for " + std::to_string(expected_item_count) +
92-
" exported record(s)";
93-
return false;
94-
}
95-
9673
if (!errors->get<bool>())
9774
{
9875
return true;
9976
}
10077

101-
// Report the first item error rather than only saying that something failed.
102-
for (const auto &item : *items)
78+
// errors is true: report the first item error rather than only saying that something failed.
79+
const auto items = parsed.find("items");
80+
if (items != parsed.end() && items->is_array())
10381
{
104-
if (!item.is_object())
105-
{
106-
continue;
107-
}
108-
for (auto operation = item.begin(); operation != item.end(); ++operation)
82+
for (const auto &item : *items)
10983
{
110-
if (!operation->is_object())
84+
if (!item.is_object())
11185
{
11286
continue;
11387
}
114-
const auto error = operation->find("error");
115-
if (error != operation->end())
88+
for (auto operation = item.begin(); operation != item.end(); ++operation)
11689
{
117-
failure_reason = "at least one item failed, first error: " + error->dump();
118-
return false;
90+
if (!operation->is_object())
91+
{
92+
continue;
93+
}
94+
const auto error = operation->find("error");
95+
if (error != operation->end())
96+
{
97+
failure_reason = "at least one item failed, first error: " + error->dump();
98+
return false;
99+
}
119100
}
120101
}
121102
}

‎exporters/elasticsearch/src/es_log_record_exporter.cc‎

Lines changed: 3 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -221,11 +221,9 @@ class AsyncResponseHandler : public http_client::EventHandler
221221
AsyncResponseHandler(
222222
std::shared_ptr<ext::http::client::Session> session,
223223
std::function<bool(opentelemetry::sdk::common::ExportResult)> &&result_callback,
224-
std::size_t expected_item_count,
225224
bool console_debug = false)
226225
: session_{std::move(session)},
227226
result_callback_{std::move(result_callback)},
228-
expected_item_count_{expected_item_count},
229227
console_debug_{console_debug}
230228
{}
231229

@@ -253,8 +251,7 @@ class AsyncResponseHandler : public http_client::EventHandler
253251
"[ES Log Exporter] Got response from Elasticsearch, response body: " << body_);
254252
}
255253
std::string failure_reason;
256-
if (!detail::IsBulkResponseSuccessful(response.GetStatusCode(), body_, expected_item_count_,
257-
failure_reason))
254+
if (!detail::IsBulkResponseSuccessful(response.GetStatusCode(), body_, failure_reason))
258255
{
259256
OTEL_INTERNAL_LOG_ERROR("[ES Log Exporter] Logs were not written to Elasticsearch correctly, "
260257
<< failure_reason << ", response body: " << body_);
@@ -318,9 +315,6 @@ class AsyncResponseHandler : public http_client::EventHandler
318315
// A string to store the response body
319316
std::string body_ = "";
320317

321-
// The number of records submitted, used to check the response reports a result for each
322-
std::size_t expected_item_count_ = 0;
323-
324318
// Whether to print the results from the callback
325319
bool console_debug_ = false;
326320
};
@@ -431,7 +425,7 @@ sdk::common::ExportResult ElasticsearchLogRecordExporter::Export(
431425
synchronization_data->force_flush_cv.notify_all();
432426
return true;
433427
},
434-
span_count, options_.console_debug_);
428+
options_.console_debug_);
435429
session->SendRequest(handler);
436430
return sdk::common::ExportResult::kSuccess;
437431
#else
@@ -460,8 +454,7 @@ sdk::common::ExportResult ElasticsearchLogRecordExporter::Export(
460454
// Parse the response output to determine if Elasticsearch consumed it correctly
461455
std::string responseBody = handler->GetResponseBody();
462456
std::string failure_reason;
463-
if (!detail::IsBulkResponseSuccessful(handler->GetStatusCode(), responseBody, records.size(),
464-
failure_reason))
457+
if (!detail::IsBulkResponseSuccessful(handler->GetStatusCode(), responseBody, failure_reason))
465458
{
466459
OTEL_INTERNAL_LOG_ERROR("[ES Log Exporter] Logs were not written to Elasticsearch correctly, "
467460
<< failure_reason << ", response body: " << responseBody);

‎exporters/elasticsearch/test/es_log_record_exporter_test.cc‎

Lines changed: 23 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -164,91 +164,68 @@ constexpr const char *kOneItemRejected =
164164
TEST(ElasticsearchBulkResponseTests, ReportsSuccessWhenErrorsIsFalse)
165165
{
166166
std::string reason;
167-
EXPECT_TRUE(logs_exporter::detail::IsBulkResponseSuccessful(200, kPrettySuccess, 1, reason));
168-
EXPECT_TRUE(logs_exporter::detail::IsBulkResponseSuccessful(200, kCompactSuccess, 1, reason));
167+
EXPECT_TRUE(logs_exporter::detail::IsBulkResponseSuccessful(200, kPrettySuccess, reason));
168+
EXPECT_TRUE(logs_exporter::detail::IsBulkResponseSuccessful(200, kCompactSuccess, reason));
169+
}
170+
171+
// A successful check must not leave a stale failure reason from a previous call behind.
172+
TEST(ElasticsearchBulkResponseTests, ClearsFailureReasonOnSuccess)
173+
{
174+
std::string reason = "stale";
175+
EXPECT_TRUE(logs_exporter::detail::IsBulkResponseSuccessful(200, kCompactSuccess, reason));
176+
EXPECT_TRUE(reason.empty());
169177
}
170178

171179
TEST(ElasticsearchBulkResponseTests, ReportsFailureWhenAnyItemWasRejected)
172180
{
173181
std::string reason;
174-
EXPECT_FALSE(logs_exporter::detail::IsBulkResponseSuccessful(200, kOneItemRejected, 2, reason));
182+
EXPECT_FALSE(logs_exporter::detail::IsBulkResponseSuccessful(200, kOneItemRejected, reason));
175183
EXPECT_NE(reason.find("mapper_parsing_exception"), std::string::npos);
176184
}
177185

178186
TEST(ElasticsearchBulkResponseTests, ReportsFailureOnUnusableBody)
179187
{
180188
std::string reason;
181-
EXPECT_FALSE(logs_exporter::detail::IsBulkResponseSuccessful(200, "not json at all", 1, reason));
189+
EXPECT_FALSE(logs_exporter::detail::IsBulkResponseSuccessful(200, "not json at all", reason));
182190
EXPECT_FALSE(reason.empty());
183191

184-
EXPECT_FALSE(logs_exporter::detail::IsBulkResponseSuccessful(200, "", 1, reason));
192+
EXPECT_FALSE(logs_exporter::detail::IsBulkResponseSuccessful(200, "", reason));
185193
EXPECT_FALSE(reason.empty());
186194

187195
// An array rather than the expected object.
188-
EXPECT_FALSE(logs_exporter::detail::IsBulkResponseSuccessful(200, "[1,2,3]", 1, reason));
196+
EXPECT_FALSE(logs_exporter::detail::IsBulkResponseSuccessful(200, "[1,2,3]", reason));
189197
EXPECT_FALSE(reason.empty());
190198
}
191199

192200
TEST(ElasticsearchBulkResponseTests, ReportsFailureWhenErrorsFieldIsMissingOrNotBoolean)
193201
{
194202
std::string reason;
195203
EXPECT_FALSE(
196-
logs_exporter::detail::IsBulkResponseSuccessful(200, R"({"took":30,"items":[]})", 0, reason));
204+
logs_exporter::detail::IsBulkResponseSuccessful(200, R"({"took":30,"items":[]})", reason));
197205
EXPECT_FALSE(logs_exporter::detail::IsBulkResponseSuccessful(
198-
200, R"({"errors":"false","items":[]})", 0, reason));
206+
200, R"({"errors":"false","items":[]})", reason));
199207
}
200208

201209
// A non-2xx status is a failure even when the body reports errors:false. This is the invariant
202210
// the substring check and the body-only check both missed, on both the sync and async paths.
203211
TEST(ElasticsearchBulkResponseTests, ReportsFailureOnNon2xxStatus)
204212
{
205213
std::string reason;
206-
// Empty batch, so zero item results are expected; the status is what varies here.
207214
const char *ok_body = R"({"errors":false,"items":[]})";
208-
EXPECT_FALSE(logs_exporter::detail::IsBulkResponseSuccessful(500, ok_body, 0, reason));
209-
EXPECT_FALSE(logs_exporter::detail::IsBulkResponseSuccessful(429, ok_body, 0, reason));
210-
EXPECT_FALSE(logs_exporter::detail::IsBulkResponseSuccessful(400, ok_body, 0, reason));
211-
EXPECT_TRUE(logs_exporter::detail::IsBulkResponseSuccessful(200, ok_body, 0, reason));
215+
EXPECT_FALSE(logs_exporter::detail::IsBulkResponseSuccessful(500, ok_body, reason));
216+
EXPECT_FALSE(logs_exporter::detail::IsBulkResponseSuccessful(429, ok_body, reason));
217+
EXPECT_FALSE(logs_exporter::detail::IsBulkResponseSuccessful(400, ok_body, reason));
218+
EXPECT_TRUE(logs_exporter::detail::IsBulkResponseSuccessful(200, ok_body, reason));
212219
}
213220

214221
// errors:true with no extractable item error still fails, with the generic reason.
215222
TEST(ElasticsearchBulkResponseTests, ReportsFailureWhenErrorsTrueWithoutItemError)
216223
{
217224
std::string reason;
218225
EXPECT_FALSE(logs_exporter::detail::IsBulkResponseSuccessful(200, R"({"errors":true,"items":[]})",
219-
0, reason));
226+
reason));
220227
EXPECT_FALSE(logs_exporter::detail::IsBulkResponseSuccessful(
221-
200, R"({"errors":true,"items":[null]})", 1, reason));
228+
200, R"({"errors":true,"items":[null]})", reason));
222229
EXPECT_FALSE(logs_exporter::detail::IsBulkResponseSuccessful(
223-
200, R"({"errors":true,"items":[{"index":42}]})", 1, reason));
224-
}
225-
226-
// A 2xx errors:false body must still carry one item result per exported record. A missing or short
227-
// "items" array cannot confirm the batch was written, so a dropped or truncated response that
228-
// happens to be valid JSON is a failure rather than a silent success.
229-
TEST(ElasticsearchBulkResponseTests, ReportsFailureWhenItemCountDoesNotMatch)
230-
{
231-
std::string reason;
232-
233-
// errors:false but no "items" field at all.
234-
EXPECT_FALSE(
235-
logs_exporter::detail::IsBulkResponseSuccessful(200, R"({"errors":false})", 1, reason));
236-
EXPECT_NE(reason.find("items"), std::string::npos);
237-
238-
// errors:false with an empty "items" array for a non-empty batch.
239-
EXPECT_FALSE(logs_exporter::detail::IsBulkResponseSuccessful(
240-
200, R"({"errors":false,"items":[]})", 1, reason));
241-
242-
// Fewer item results than records exported.
243-
EXPECT_FALSE(logs_exporter::detail::IsBulkResponseSuccessful(
244-
200, R"({"errors":false,"items":[{"index":{"status":201}}]})", 2, reason));
245-
246-
// One result per record is a success.
247-
EXPECT_TRUE(logs_exporter::detail::IsBulkResponseSuccessful(
248-
200, R"({"errors":false,"items":[{"index":{"status":201}},{"index":{"status":201}}]})", 2,
249-
reason));
250-
251-
// An empty batch with an empty "items" array is a success.
252-
EXPECT_TRUE(logs_exporter::detail::IsBulkResponseSuccessful(200, R"({"errors":false,"items":[]})",
253-
0, reason));
230+
200, R"({"errors":true,"items":[{"index":42}]})", reason));
254231
}

0 commit comments

Comments
 (0)