Skip to content

Commit d751066

Browse files
committed
Require one bulk item result per exported record
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>
1 parent 4eba0cc commit d751066

3 files changed

Lines changed: 102 additions & 55 deletions

File tree

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

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

44
#pragma once
55

6+
#include <cstddef>
67
#include <nlohmann/json.hpp>
78
#include <string>
89

@@ -33,24 +34,33 @@ namespace detail
3334
* the synchronous and asynchronous paths must pass the status here rather than inspecting the
3435
* body alone.
3536
*
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.
41+
*
3642
* @param status_code the HTTP status code of the response
3743
* @param body the raw response body
44+
* @param expected_item_count the number of records submitted in the bulk request
3845
* @param failure_reason set to a short explanation when the function returns false
3946
* @return true when the status is 2xx and the response reports every item as written
4047
*/
4148
inline bool IsBulkResponseSuccessful(int status_code,
4249
const std::string &body,
50+
std::size_t expected_item_count,
4351
std::string &failure_reason) noexcept
4452
{
45-
if (status_code < 200 || status_code > 299)
46-
{
47-
failure_reason = "unexpected HTTP status " + std::to_string(status_code);
48-
return false;
49-
}
5053
#if OPENTELEMETRY_HAVE_EXCEPTIONS
5154
try
5255
{
5356
#endif
57+
// Inside the try so that even the string building below cannot escape a noexcept caller.
58+
if (status_code < 200 || status_code > 299)
59+
{
60+
failure_reason = "unexpected HTTP status " + std::to_string(status_code);
61+
return false;
62+
}
63+
5464
failure_reason = "the response body could not be inspected";
5565

5666
const nlohmann::json parsed = nlohmann::json::parse(body, nullptr, false);
@@ -67,33 +77,45 @@ inline bool IsBulkResponseSuccessful(int status_code,
6777
return false;
6878
}
6979

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+
7096
if (!errors->get<bool>())
7197
{
7298
return true;
7399
}
74100

75101
// Report the first item error rather than only saying that something failed.
76-
const auto items = parsed.find("items");
77-
if (items != parsed.end() && items->is_array())
102+
for (const auto &item : *items)
78103
{
79-
for (const auto &item : *items)
104+
if (!item.is_object())
105+
{
106+
continue;
107+
}
108+
for (auto operation = item.begin(); operation != item.end(); ++operation)
80109
{
81-
if (!item.is_object())
110+
if (!operation->is_object())
82111
{
83112
continue;
84113
}
85-
for (auto operation = item.begin(); operation != item.end(); ++operation)
114+
const auto error = operation->find("error");
115+
if (error != operation->end())
86116
{
87-
if (!operation->is_object())
88-
{
89-
continue;
90-
}
91-
const auto error = operation->find("error");
92-
if (error != operation->end())
93-
{
94-
failure_reason = "at least one item failed, first error: " + error->dump();
95-
return false;
96-
}
117+
failure_reason = "at least one item failed, first error: " + error->dump();
118+
return false;
97119
}
98120
}
99121
}

‎exporters/elasticsearch/src/es_log_record_exporter.cc‎

Lines changed: 14 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -75,35 +75,22 @@ class ResponseHandler : public http_client::EventHandler
7575
*/
7676
void OnResponse(http_client::Response &response) noexcept override
7777
{
78-
std::string log_message;
79-
8078
// Lock the private members so they can't be read while being modified
8179
{
8280
std::unique_lock<std::mutex> lk(mutex_);
8381

8482
// Store the body of the request
8583
body_ = std::string(response.GetBody().begin(), response.GetBody().end());
8684

87-
if (!(response.GetStatusCode() >= 200 && response.GetStatusCode() <= 299))
88-
{
89-
log_message = BuildResponseLogMessage(response, body_);
90-
91-
OTEL_INTERNAL_LOG_ERROR("[ES Log Exporter] Export failed, " << log_message);
92-
}
93-
9485
if (console_debug_)
9586
{
96-
if (log_message.empty())
97-
{
98-
log_message = BuildResponseLogMessage(response, body_);
99-
}
100-
10187
OTEL_INTERNAL_LOG_DEBUG("[ES Log Exporter] Got response from Elasticsearch, "
102-
<< log_message);
88+
<< BuildResponseLogMessage(response, body_));
10389
}
10490

10591
// Keep the status so Export() can fold it into the ExportResult; storing the body alone
106-
// let a non-2xx response be reported as a success.
92+
// let a non-2xx response be reported as a success. Export() is the single place that logs
93+
// the failure, so a non-2xx response is not logged a second time here with the full body.
10794
status_code_ = response.GetStatusCode();
10895

10996
// Set the response_received_ flag to true and notify any threads waiting on this result
@@ -234,9 +221,11 @@ class AsyncResponseHandler : public http_client::EventHandler
234221
AsyncResponseHandler(
235222
std::shared_ptr<ext::http::client::Session> session,
236223
std::function<bool(opentelemetry::sdk::common::ExportResult)> &&result_callback,
224+
std::size_t expected_item_count,
237225
bool console_debug = false)
238226
: session_{std::move(session)},
239227
result_callback_{std::move(result_callback)},
228+
expected_item_count_{expected_item_count},
240229
console_debug_{console_debug}
241230
{}
242231

@@ -264,7 +253,8 @@ class AsyncResponseHandler : public http_client::EventHandler
264253
"[ES Log Exporter] Got response from Elasticsearch, response body: " << body_);
265254
}
266255
std::string failure_reason;
267-
if (!detail::IsBulkResponseSuccessful(response.GetStatusCode(), body_, failure_reason))
256+
if (!detail::IsBulkResponseSuccessful(response.GetStatusCode(), body_, expected_item_count_,
257+
failure_reason))
268258
{
269259
OTEL_INTERNAL_LOG_ERROR("[ES Log Exporter] Logs were not written to Elasticsearch correctly, "
270260
<< failure_reason << ", response body: " << body_);
@@ -328,6 +318,9 @@ class AsyncResponseHandler : public http_client::EventHandler
328318
// A string to store the response body
329319
std::string body_ = "";
330320

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+
331324
// Whether to print the results from the callback
332325
bool console_debug_ = false;
333326
};
@@ -386,7 +379,7 @@ sdk::common::ExportResult ElasticsearchLogRecordExporter::Export(
386379
auto request = session->CreateRequest();
387380

388381
// Populate the request with headers and methods
389-
request->SetUri(options_.index_ + "/_bulk?pretty");
382+
request->SetUri(options_.index_ + "/_bulk");
390383
request->SetMethod(http_client::Method::Post);
391384
request->AddHeader("Content-Type", "application/json");
392385

@@ -438,7 +431,7 @@ sdk::common::ExportResult ElasticsearchLogRecordExporter::Export(
438431
synchronization_data->force_flush_cv.notify_all();
439432
return true;
440433
},
441-
options_.console_debug_);
434+
span_count, options_.console_debug_);
442435
session->SendRequest(handler);
443436
return sdk::common::ExportResult::kSuccess;
444437
#else
@@ -467,7 +460,8 @@ sdk::common::ExportResult ElasticsearchLogRecordExporter::Export(
467460
// Parse the response output to determine if Elasticsearch consumed it correctly
468461
std::string responseBody = handler->GetResponseBody();
469462
std::string failure_reason;
470-
if (!detail::IsBulkResponseSuccessful(handler->GetStatusCode(), responseBody, failure_reason))
463+
if (!detail::IsBulkResponseSuccessful(handler->GetStatusCode(), responseBody, records.size(),
464+
failure_reason))
471465
{
472466
OTEL_INTERNAL_LOG_ERROR("[ES Log Exporter] Logs were not written to Elasticsearch correctly, "
473467
<< failure_reason << ", response body: " << responseBody);

‎exporters/elasticsearch/test/es_log_record_exporter_test.cc‎

Lines changed: 46 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -164,60 +164,91 @@ constexpr const char *kOneItemRejected =
164164
TEST(ElasticsearchBulkResponseTests, ReportsSuccessWhenErrorsIsFalse)
165165
{
166166
std::string reason;
167-
EXPECT_TRUE(logs_exporter::detail::IsBulkResponseSuccessful(200, kPrettySuccess, reason));
168-
EXPECT_TRUE(logs_exporter::detail::IsBulkResponseSuccessful(200, kCompactSuccess, reason));
167+
EXPECT_TRUE(logs_exporter::detail::IsBulkResponseSuccessful(200, kPrettySuccess, 1, reason));
168+
EXPECT_TRUE(logs_exporter::detail::IsBulkResponseSuccessful(200, kCompactSuccess, 1, reason));
169169
}
170170

171171
TEST(ElasticsearchBulkResponseTests, ReportsFailureWhenAnyItemWasRejected)
172172
{
173173
std::string reason;
174-
EXPECT_FALSE(logs_exporter::detail::IsBulkResponseSuccessful(200, kOneItemRejected, reason));
174+
EXPECT_FALSE(logs_exporter::detail::IsBulkResponseSuccessful(200, kOneItemRejected, 2, reason));
175175
EXPECT_NE(reason.find("mapper_parsing_exception"), std::string::npos);
176176
}
177177

178178
TEST(ElasticsearchBulkResponseTests, ReportsFailureOnUnusableBody)
179179
{
180180
std::string reason;
181-
EXPECT_FALSE(logs_exporter::detail::IsBulkResponseSuccessful(200, "not json at all", reason));
181+
EXPECT_FALSE(logs_exporter::detail::IsBulkResponseSuccessful(200, "not json at all", 1, reason));
182182
EXPECT_FALSE(reason.empty());
183183

184-
EXPECT_FALSE(logs_exporter::detail::IsBulkResponseSuccessful(200, "", reason));
184+
EXPECT_FALSE(logs_exporter::detail::IsBulkResponseSuccessful(200, "", 1, reason));
185185
EXPECT_FALSE(reason.empty());
186186

187187
// An array rather than the expected object.
188-
EXPECT_FALSE(logs_exporter::detail::IsBulkResponseSuccessful(200, "[1,2,3]", reason));
188+
EXPECT_FALSE(logs_exporter::detail::IsBulkResponseSuccessful(200, "[1,2,3]", 1, reason));
189189
EXPECT_FALSE(reason.empty());
190190
}
191191

192192
TEST(ElasticsearchBulkResponseTests, ReportsFailureWhenErrorsFieldIsMissingOrNotBoolean)
193193
{
194194
std::string reason;
195195
EXPECT_FALSE(
196-
logs_exporter::detail::IsBulkResponseSuccessful(200, R"({"took":30,"items":[]})", reason));
196+
logs_exporter::detail::IsBulkResponseSuccessful(200, R"({"took":30,"items":[]})", 0, reason));
197197
EXPECT_FALSE(logs_exporter::detail::IsBulkResponseSuccessful(
198-
200, R"({"errors":"false","items":[]})", reason));
198+
200, R"({"errors":"false","items":[]})", 0, reason));
199199
}
200200

201201
// A non-2xx status is a failure even when the body reports errors:false. This is the invariant
202202
// the substring check and the body-only check both missed, on both the sync and async paths.
203203
TEST(ElasticsearchBulkResponseTests, ReportsFailureOnNon2xxStatus)
204204
{
205205
std::string reason;
206+
// Empty batch, so zero item results are expected; the status is what varies here.
206207
const char *ok_body = R"({"errors":false,"items":[]})";
207-
EXPECT_FALSE(logs_exporter::detail::IsBulkResponseSuccessful(500, ok_body, reason));
208-
EXPECT_FALSE(logs_exporter::detail::IsBulkResponseSuccessful(429, ok_body, reason));
209-
EXPECT_FALSE(logs_exporter::detail::IsBulkResponseSuccessful(400, ok_body, reason));
210-
EXPECT_TRUE(logs_exporter::detail::IsBulkResponseSuccessful(200, ok_body, reason));
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));
211212
}
212213

213214
// errors:true with no extractable item error still fails, with the generic reason.
214215
TEST(ElasticsearchBulkResponseTests, ReportsFailureWhenErrorsTrueWithoutItemError)
215216
{
216217
std::string reason;
217218
EXPECT_FALSE(logs_exporter::detail::IsBulkResponseSuccessful(200, R"({"errors":true,"items":[]})",
218-
reason));
219+
0, reason));
219220
EXPECT_FALSE(logs_exporter::detail::IsBulkResponseSuccessful(
220-
200, R"({"errors":true,"items":[null]})", reason));
221+
200, R"({"errors":true,"items":[null]})", 1, reason));
221222
EXPECT_FALSE(logs_exporter::detail::IsBulkResponseSuccessful(
222-
200, R"({"errors":true,"items":[{"index":42}]})", reason));
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));
223254
}

0 commit comments

Comments
 (0)