Skip to content

Commit 7a381fb

Browse files
committed
[BUG] Decide Elasticsearch bulk export success from HTTP status and the 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>
1 parent dfca162 commit 7a381fb

6 files changed

Lines changed: 737 additions & 40 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,10 @@ Increment the:
6565
* [BUG] Wait on a completion state in the Elasticsearch exporter
6666
[#4298](https://github.com/open-telemetry/opentelemetry-cpp/pull/4298)
6767

68+
* [BUG] Decide Elasticsearch bulk export success from the HTTP status, the
69+
errors flag, and one acknowledgement per record
70+
[#4297](https://github.com/open-telemetry/opentelemetry-cpp/pull/4297)
71+
6872
* [BUG] Make SocketAddr string parsing safe and reject malformed addresses
6973
[#4292](https://github.com/open-telemetry/opentelemetry-cpp/pull/4292)
7074

‎exporters/elasticsearch/BUILD‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,24 @@ load("@rules_cc//cc:cc_test.bzl", "cc_test")
66

77
package(default_visibility = ["//visibility:public"])
88

9+
# The bulk response parser is an implementation detail that the CMake package
10+
# also leaves out. hdrs is a target's public interface, so it is kept in its own
11+
# target that only this package can depend on, and reached through
12+
# implementation_deps so the exporter does not re-export it.
13+
cc_library(
14+
name = "es_bulk_response",
15+
hdrs = [
16+
"include/opentelemetry/exporters/elasticsearch/detail/es_bulk_response.h",
17+
],
18+
strip_include_prefix = "include",
19+
tags = ["es"],
20+
visibility = ["//exporters/elasticsearch:__pkg__"],
21+
deps = [
22+
"//api",
23+
"@github_nlohmann_json//:json",
24+
],
25+
)
26+
927
cc_library(
1028
name = "es_log_record_exporter",
1129
srcs = [
@@ -16,6 +34,7 @@ cc_library(
1634
"include/opentelemetry/exporters/elasticsearch/es_log_record_exporter.h",
1735
"include/opentelemetry/exporters/elasticsearch/es_log_recordable.h",
1836
],
37+
implementation_deps = [":es_bulk_response"],
1938
linkopts = select({
2039
"//bazel:windows": [
2140
"-DEFAULTLIB:advapi32.lib",
@@ -43,6 +62,7 @@ cc_test(
4362
"test",
4463
],
4564
deps = [
65+
":es_bulk_response",
4666
":es_log_record_exporter",
4767
"@com_google_googletest//:gtest_main",
4868
"@curl",

‎exporters/elasticsearch/CMakeLists.txt‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,12 @@ otel_add_component(
3434
"*.h"
3535
PATTERN
3636
"es_log_recordable.h"
37+
EXCLUDE
38+
PATTERN
39+
"es_bulk_response.h"
40+
EXCLUDE
41+
PATTERN
42+
"detail"
3743
EXCLUDE)
3844

3945
if(OPENTELEMETRY_INSTALL)
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
// Copyright The OpenTelemetry Authors
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
#pragma once
5+
6+
#include <cstddef>
7+
#include <nlohmann/json.hpp>
8+
#include <string>
9+
10+
#include "opentelemetry/version.h"
11+
12+
OPENTELEMETRY_BEGIN_NAMESPACE
13+
namespace exporter
14+
{
15+
namespace logs
16+
{
17+
namespace detail
18+
{
19+
20+
/**
21+
* Whether an acknowledged operation status is one that applied the operation. Elasticsearch
22+
* answers an index operation with 200 or 201, so 2xx is the whole band.
23+
*
24+
* Compared in the type the number was parsed as. Narrowing to int first is not safe here: the
25+
* value comes from the server, is_number_integer() is true for unsigned as well, and 2^32 + 200
26+
* narrows back into the band on a 32 bit int.
27+
*/
28+
inline bool IsAcknowledgedStatus(const nlohmann::json &status) noexcept
29+
{
30+
if (status.is_number_unsigned())
31+
{
32+
const auto value = status.get<nlohmann::json::number_unsigned_t>();
33+
return value >= 200U && value <= 299U;
34+
}
35+
36+
const auto value = status.get<nlohmann::json::number_integer_t>();
37+
return value >= 200 && value <= 299;
38+
}
39+
40+
/**
41+
* Decide whether an Elasticsearch bulk response reports the whole batch as written.
42+
*
43+
* Callers include noexcept response handlers, so nothing may escape. Anything that stops the body
44+
* being inspected counts as a failed export.
45+
*
46+
* @param status_code the response status, which the caller passes rather than this reading the
47+
* body alone: "errors" describes item outcomes and cannot override a transport error
48+
* @param body the raw response body
49+
* @param expected_items the number of index operations the request submitted
50+
* @param failure_reason a best-effort explanation when this returns false
51+
* @return true only when the status is 2xx, "errors" is false, and "items" holds exactly
52+
* expected_items index results that each acknowledge a 2xx status
53+
*/
54+
inline bool IsBulkResponseSuccessful(int status_code,
55+
const std::string &body,
56+
std::size_t expected_items,
57+
std::string &failure_reason) noexcept
58+
{
59+
failure_reason.clear();
60+
#if OPENTELEMETRY_HAVE_EXCEPTIONS
61+
try
62+
{
63+
#endif
64+
// Inside the try: building a reason allocates.
65+
if (status_code < 200 || status_code > 299)
66+
{
67+
failure_reason = "unexpected HTTP status " + std::to_string(status_code);
68+
return false;
69+
}
70+
71+
const nlohmann::json parsed = nlohmann::json::parse(body, nullptr, false);
72+
if (parsed.is_discarded() || !parsed.is_object())
73+
{
74+
failure_reason = "the response body is not a JSON object";
75+
return false;
76+
}
77+
78+
const auto errors = parsed.find("errors");
79+
if (errors == parsed.end() || !errors->is_boolean())
80+
{
81+
failure_reason = "the response body has no boolean \"errors\" field";
82+
return false;
83+
}
84+
85+
// The request is an unfiltered /_bulk, so "items" is always there and holds one entry per
86+
// operation. The next three checks decide whether this body answers the request that was sent.
87+
const auto items = parsed.find("items");
88+
if (items == parsed.end() || !items->is_array())
89+
{
90+
failure_reason = "the response body has no \"items\" array";
91+
return false;
92+
}
93+
94+
if (items->size() != expected_items)
95+
{
96+
failure_reason = "the response acknowledges " + std::to_string(items->size()) + " of " +
97+
std::to_string(expected_items) + " submitted operations";
98+
return false;
99+
}
100+
101+
// Each entry is keyed by the action it answers, and every record goes out as an index
102+
// operation. Read before "errors", which a body that is not this answer does not get to decide.
103+
const nlohmann::json *rejected = nullptr;
104+
for (const auto &item : *items)
105+
{
106+
if (!item.is_object() || item.size() != 1)
107+
{
108+
failure_reason = "the response has an \"items\" entry that is not one operation result";
109+
return false;
110+
}
111+
112+
const auto operation = item.find("index");
113+
if (operation == item.end() || !operation->is_object())
114+
{
115+
failure_reason = "the response does not acknowledge the submitted index operation";
116+
return false;
117+
}
118+
119+
const auto status = operation->find("status");
120+
if (status == operation->end() || !status->is_number_integer())
121+
{
122+
failure_reason = "the response acknowledges an index operation with no status";
123+
return false;
124+
}
125+
126+
if (rejected == nullptr && !IsAcknowledgedStatus(*status))
127+
{
128+
rejected = &(*status);
129+
}
130+
}
131+
132+
if (!errors->get<bool>())
133+
{
134+
// A false flag claims every operation was applied; hold the items to that claim.
135+
if (rejected != nullptr)
136+
{
137+
failure_reason =
138+
"the response reports no errors but acknowledges operation status " + rejected->dump();
139+
return false;
140+
}
141+
return true;
142+
}
143+
144+
// Name the first item error rather than only saying that something failed. Every entry is one
145+
// index result by now, so its single member is the result to look in.
146+
for (const auto &item : *items)
147+
{
148+
const auto &result = *item.begin();
149+
const auto error = result.find("error");
150+
if (error != result.end())
151+
{
152+
failure_reason = "at least one item failed, first error: " + error->dump();
153+
return false;
154+
}
155+
}
156+
157+
failure_reason = "the response reports errors";
158+
return false;
159+
#if OPENTELEMETRY_HAVE_EXCEPTIONS
160+
}
161+
catch (...)
162+
{
163+
return false;
164+
}
165+
#endif
166+
}
167+
168+
} // namespace detail
169+
} // namespace logs
170+
} // namespace exporter
171+
OPENTELEMETRY_END_NAMESPACE

0 commit comments

Comments
 (0)