Skip to content

Commit fc104af

Browse files
committed
[BUG] Report an Elasticsearch async export's outcome exactly once
AsyncResponseHandler called the result callback directly from OnResponse and from each terminal OnEvent state with no guard, and ReadError, WriteError and Destroyed fell through a default label and called nothing. The HTTP client can deliver both a response and a terminal event for one request, so one export could report twice, and it can end on one of those three states and report nothing at all. The exporter counts one finished session per export. Reporting twice overshoots that count for the life of the exporter. Reporting never leaves a flush waiting on a session that has already ended. Every path goes through one CompleteOnce now, a compare exchange that reports at most once and keeps the first verdict. The switch lists every state with no default, so a state added upstream fails to compile rather than going uncounted, and the destructor reports a failure for a handler torn down without an outcome. The completion line said trace span(s) in the log exporter and says log record(s) now. The cases read that line to count outcomes, and the wording was wrong either way. Nine cases drive a fake HTTP client through the public constructor: each terminal ordering a real session can produce, a response and a teardown event in both orders, and the concurrent version of each. Removing the compare exchange turns six of the nine red. Extracted from #4337, which is 1526 lines and closes two issues. What stays there is the ForceFlush deadline and watermark accounting for #4336, including four completion cases that verify this guard through the flush rather than through the log line. Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
1 parent 58ed80d commit fc104af

3 files changed

Lines changed: 643 additions & 35 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@ Increment the:
1515

1616
## [Unreleased]
1717

18+
* [BUG] Elasticsearch: report an asynchronous export's outcome exactly once
19+
([#4501](https://github.com/open-telemetry/opentelemetry-cpp/pull/4501))
20+
1821
* [CONFIGURATION] Apply general `attribute_limits` per individual limit field.
1922
If a model-specific limit is set it is used, otherwise the matching general
2023
limit, otherwise the model-specific default. Limit fields on

‎exporters/elasticsearch/src/es_log_record_exporter.cc‎

Lines changed: 79 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -267,74 +267,119 @@ class AsyncResponseHandler : public http_client::EventHandler
267267
/**
268268
* Cleans up the session in the destructor.
269269
*/
270-
~AsyncResponseHandler() override { session_->FinishSession(); }
270+
~AsyncResponseHandler() override
271+
{
272+
// An outcome is owed even here, or a waiter is left on a session that cannot finish.
273+
// Reported before FinishSession(), which can block.
274+
CompleteOnce(sdk::common::ExportResult::kFailure);
275+
session_->FinishSession();
276+
}
277+
278+
/**
279+
* Report the outcome of this export, at most once. The HTTP client can deliver both a response
280+
* and a terminal session event for one request, and the exporter counts one finished session
281+
* per export, so only the first outcome is reported.
282+
* @return whether this call is the one that reported.
283+
*/
284+
bool CompleteOnce(sdk::common::ExportResult result) noexcept
285+
{
286+
bool expected = false;
287+
if (!completed_.compare_exchange_strong(expected, true, std::memory_order_acq_rel))
288+
{
289+
return false;
290+
}
291+
result_callback_(result);
292+
return true;
293+
}
271294

272295
/**
273296
* Automatically called when the response is received
274297
*/
275298
void OnResponse(http_client::Response &response) noexcept override
276299
{
277300

278-
// Store the body of the response
279-
body_ = std::string(response.GetBody().begin(), response.GetBody().end());
301+
const std::string body(response.GetBody().begin(), response.GetBody().end());
302+
const bool written = body.find("\"failed\" : 0") != std::string::npos;
303+
304+
// Reported before anything is logged. CompleteOnce() retires the session and wakes
305+
// ForceFlush() before it returns, and the log handler is replaceable, so one that calls
306+
// ForceFlush() would otherwise wait for the session this call has not let go of. A response
307+
// that loses the exchange says nothing either, since the outcome it would describe is not the
308+
// one the caller was given.
309+
if (!CompleteOnce(written ? sdk::common::ExportResult::kSuccess
310+
: sdk::common::ExportResult::kFailure))
311+
{
312+
return;
313+
}
314+
280315
if (console_debug_)
281316
{
282317
OTEL_INTERNAL_LOG_DEBUG(
283-
"[ES Log Exporter] Got response from Elasticsearch, response body: " << body_);
318+
"[ES Log Exporter] Got response from Elasticsearch, response body: " << body);
284319
}
285-
if (body_.find("\"failed\" : 0") == std::string::npos)
320+
if (!written)
286321
{
287322
OTEL_INTERNAL_LOG_ERROR(
288323
"[ES Log Exporter] Logs were not written to Elasticsearch correctly, response body: "
289-
<< body_);
290-
result_callback_(sdk::common::ExportResult::kFailure);
291-
}
292-
else
293-
{
294-
result_callback_(sdk::common::ExportResult::kSuccess);
324+
<< body);
295325
}
296326
}
297327

298328
// Callback method when an http event occurs
299329
void OnEvent(http_client::SessionState state, nostd::string_view /* reason */) noexcept override
300330
{
301-
bool need_stop = false;
331+
// No default label, so -Wswitch reports a state added upstream rather than leaving it
332+
// uncounted.
333+
const char *failure = nullptr;
302334
switch (state)
303335
{
336+
// On the way to an outcome, so nothing to report and, in particular, nothing to log: the
337+
// session is still registered, and a replaceable log handler that flushed from here would
338+
// wait on the export whose call stack it is standing in.
339+
case http_client::SessionState::Created:
340+
case http_client::SessionState::Connecting:
341+
case http_client::SessionState::Connected:
342+
case http_client::SessionState::Sending:
343+
// The body arrives through OnResponse(), which is what reports the outcome.
344+
case http_client::SessionState::Response:
345+
break;
304346
case http_client::SessionState::CreateFailed:
305-
OTEL_INTERNAL_LOG_ERROR("[ES Log Exporter] Create request to elasticsearch failed");
306-
need_stop = true;
347+
failure = "[ES Log Exporter] Create request to elasticsearch failed";
348+
break;
349+
case http_client::SessionState::Destroyed:
350+
failure = "[ES Log Exporter] Session to elasticsearch destroyed before a response";
307351
break;
308352
case http_client::SessionState::ConnectFailed:
309-
OTEL_INTERNAL_LOG_ERROR("[ES Log Exporter] Connection to elasticsearch failed");
310-
need_stop = true;
353+
failure = "[ES Log Exporter] Connection to elasticsearch failed";
311354
break;
312355
case http_client::SessionState::SendFailed:
313-
OTEL_INTERNAL_LOG_ERROR("[ES Log Exporter] Request failed to be sent to elasticsearch");
314-
need_stop = true;
356+
failure = "[ES Log Exporter] Request failed to be sent to elasticsearch";
315357
break;
316358
case http_client::SessionState::SSLHandshakeFailed:
317-
OTEL_INTERNAL_LOG_ERROR("[ES Log Exporter] SSL handshake to elasticsearch failed");
318-
need_stop = true;
359+
failure = "[ES Log Exporter] SSL handshake to elasticsearch failed";
319360
break;
320361
case http_client::SessionState::TimedOut:
321-
OTEL_INTERNAL_LOG_ERROR("[ES Log Exporter] Request to elasticsearch timed out");
322-
need_stop = true;
362+
failure = "[ES Log Exporter] Request to elasticsearch timed out";
323363
break;
324364
case http_client::SessionState::NetworkError:
325-
OTEL_INTERNAL_LOG_ERROR("[ES Log Exporter] Network error to elasticsearch");
326-
need_stop = true;
365+
failure = "[ES Log Exporter] Network error to elasticsearch";
327366
break;
328-
case http_client::SessionState::Cancelled:
329-
OTEL_INTERNAL_LOG_ERROR("[ES Log Exporter] Request to elasticsearch cancelled");
330-
need_stop = true;
367+
case http_client::SessionState::ReadError:
368+
failure = "[ES Log Exporter] Read error";
369+
break;
370+
case http_client::SessionState::WriteError:
371+
failure = "[ES Log Exporter] Write error";
331372
break;
332-
default:
373+
case http_client::SessionState::Cancelled:
374+
failure = "[ES Log Exporter] Request to elasticsearch cancelled";
333375
break;
334376
}
335-
if (need_stop)
377+
378+
// Logged only when this event is the outcome. These can arrive after a response, and an
379+
// error line there would describe a failure the caller was never told about.
380+
if (failure != nullptr && CompleteOnce(sdk::common::ExportResult::kFailure))
336381
{
337-
result_callback_(sdk::common::ExportResult::kFailure);
382+
OTEL_INTERNAL_LOG_ERROR(failure);
338383
}
339384
}
340385

@@ -344,8 +389,8 @@ class AsyncResponseHandler : public http_client::EventHandler
344389
// Callback to call to on receiving events
345390
std::function<bool(opentelemetry::sdk::common::ExportResult)> result_callback_;
346391

347-
// A string to store the response body
348-
std::string body_ = "";
392+
// Whether the outcome has already been reported
393+
std::atomic<bool> completed_{false};
349394

350395
// Whether to print the results from the callback
351396
bool console_debug_ = false;
@@ -446,12 +491,12 @@ sdk::common::ExportResult ElasticsearchLogRecordExporter::Export(
446491
{
447492
OTEL_INTERNAL_LOG_ERROR("[ES Log Exporter] ERROR: Export "
448493
<< span_count
449-
<< " trace span(s) error: " << static_cast<int>(result));
494+
<< " log record(s) error: " << static_cast<int>(result));
450495
}
451496
else
452497
{
453498
OTEL_INTERNAL_LOG_DEBUG("[ES Log Exporter] Export " << span_count
454-
<< " trace span(s) success");
499+
<< " log record(s) success");
455500
}
456501

457502
synchronization_data->finished_session_counter_.fetch_add(1, std::memory_order_release);

0 commit comments

Comments
 (0)