Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
57b54d7
feat: Define an entry point for the context thread
awforsythe Mar 5, 2026
d74de51
feat: Update Core to own a context queue and run a context thread
awforsythe Mar 5, 2026
511f69e
feat: Update FeatureScope to allow deferred execution on context thread
awforsythe Mar 5, 2026
c798ea3
feat: Update RUM implementation to use context thread
awforsythe Mar 6, 2026
01af161
fix: Make FeatureScope creation explicit about test-only mode
awforsythe Mar 6, 2026
3ad3c53
fix: Make UpdateContext queue context mutations for async execution
awforsythe Mar 6, 2026
dc4db37
chore: Fix comment to avoid "feature operation" confusion
awforsythe Mar 6, 2026
77d6641
chore: Remove `Core::FlushContextQueue`; it's not actually used
awforsythe Mar 6, 2026
4fcd192
chore: Update comments
awforsythe Mar 6, 2026
f840aef
chore: Remove vital_id references to reconcile
awforsythe Mar 9, 2026
d5ae4ae
fix: Update Logging implementation to use context thread
awforsythe Mar 9, 2026
e1d8b3a
chore: Remove mutex on RUM event encode buffer
awforsythe Mar 9, 2026
3f579fb
chore: Update FeatureScope tests to use a real context thread
awforsythe Mar 10, 2026
e743d19
chore: Refactor repeated mock os/device info into a common header
awforsythe Mar 10, 2026
0005f06
chore: Add context_thread_test.cpp to complement feature_scope_test.cpp
awforsythe Mar 10, 2026
dac8a27
fix: Update RUM context immediately after command processing
awforsythe Mar 10, 2026
b00dd9a
chore: Supplement FeatureScope tests for fixed context-mutation ordering
awforsythe Mar 10, 2026
6c12f14
chore: Add tentative context-queue-drain test
awforsythe Mar 10, 2026
101afbf
chore: Remove old GetContext() and WriteEvent() functions
awforsythe Mar 10, 2026
37d0946
chore: Add tests for context-queue shutdown behavior
awforsythe Mar 11, 2026
1dc05e9
fix: Fix shutdown ordering: stop context thread before stopping features
awforsythe Mar 11, 2026
77dfaa9
fix: Events may no longer be generated on Feature::Stop()
awforsythe Mar 11, 2026
4e679de
chore: Replace `EventGeneratedFunc` with `EventWriter`
awforsythe Mar 11, 2026
e19810f
chore: Remove CoreContextProvider arg from ContextThreadMain
awforsythe Mar 11, 2026
fcbdf93
fix: Reset RumFeatureContext etc. on Core::Start, not on Feature::Stop
awforsythe Mar 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ add_library(dd_native ${DD_LIBRARY_TYPE}
datadog/cpp/version.cpp
datadog/impl/attribute/cow.cpp
datadog/impl/attribute/merge.cpp
datadog/impl/core/context_thread.cpp
datadog/impl/core/context.cpp
datadog/impl/core/core.cpp
datadog/impl/core/feature_read.cpp
Expand Down
2 changes: 2 additions & 0 deletions src/datadog/impl/core/context.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,8 @@ CoreContext::CoreContext(
)
: http(std::make_shared<HttpContext>(config)), os(&os_info), device(&device_info) {}

void CoreContext::Reset() { rum.reset(); }

CoreContextProvider::CoreContextProvider(const CoreContext& context)
: _context(context) {}

Expand Down
7 changes: 7 additions & 0 deletions src/datadog/impl/core/context.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,13 @@ struct CoreContext {
* Additional context provided by the RUM feature, if in use.
*/
std::optional<RumFeatureContext> rum;

/**
* Resets all mutable feature-specific context fields to their default (absent) state.
* Called by Core at the start of each run to ensure features never inherit stale
* context from a previous run.
*/
void Reset();
};

/**
Expand Down
25 changes: 25 additions & 0 deletions src/datadog/impl/core/context_thread.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
// Unless explicitly stated otherwise all files in this repository are licensed
// under the Apache License Version 2.0.
//
// This product includes software developed at Datadog (https://www.datadoghq.com/).
// Copyright 2025-Present Datadog, Inc.

#include "datadog/impl/core/context_thread.hpp"

namespace datadog::impl {

void ContextThreadMain(
const DiagnosticLogger& diagnostic_logger, Queue<std::function<void()>>& queue
) {
diagnostic_logger.Debug("Context thread starting");

// Process functions until the queue is stopped and drained (Pop() returns
// std::nullopt)
while (auto thunk = queue.Pop()) {
(*thunk)();
}

diagnostic_logger.Debug("Context thread finished");
}

} // namespace datadog::impl
37 changes: 37 additions & 0 deletions src/datadog/impl/core/context_thread.hpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
// Unless explicitly stated otherwise all files in this repository are licensed
// under the Apache License Version 2.0.
//
// This product includes software developed at Datadog (https://www.datadoghq.com/).
// Copyright 2025-Present Datadog, Inc.

#pragma once

#include <functional>

#include "datadog/impl/core/context.hpp"
#include "datadog/impl/core/queue.hpp"
#include "datadog/impl/diagnostics.hpp"

namespace datadog::impl {

/**
* Entry point for the context thread.
*
* The context thread continually reads functions from the context queue, execute each
* function serially as it's consumed. This allows features to perform operations
* asynchronously without blocking their callers.
*
* Functions are enqueued via FeatureScope::ExecuteOnContextThread et al. - FeatureScope
* is responsible for wrapping Feature-provided functions in a thunk that will evaluate
* the required parameters (CoreContext and EventWriter) from a CoreContextProvider, so
* the context thread itself is dead-simple: it just executes those thunks.
*
* @param diagnostic_logger Interface for logging status/warning messages.
* @param queue Non-owning reference to the thread-safe queue that we read from;
* guaranteed to outlive the thread.
*/
void ContextThreadMain(
const DiagnosticLogger& diagnostic_logger, Queue<std::function<void()>>& queue
);

} // namespace datadog::impl
59 changes: 50 additions & 9 deletions src/datadog/impl/core/core.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
#include <sstream>

#include "datadog/impl/assert.hpp"
#include "datadog/impl/core/context_thread.hpp"
#include "datadog/impl/core/storage_thread.hpp"
#include "datadog/impl/core/types.hpp"
#include "datadog/impl/core/version.hpp"
Expand Down Expand Up @@ -298,6 +299,11 @@ bool Core::Start() {
return false;
}

// Reset any feature-specific context left over from a previous run, so that features
// start clean rather than inheriting stale state from the prior session
DATADOG_ASSERT(_context_provider, "_context_provider is null on Start()");
_context_provider->Update([](CoreContext& ctx) { ctx.Reset(); });

// Initialize a thread-safe queue that features can write to whenever they produce
// events that need to be written to disk
DATADOG_ASSERT(!_storage_queue, "_storage_queue already exists on Start()");
Expand All @@ -314,6 +320,19 @@ bool Core::Start() {
std::ref(_features)
);

// Initialize a thread-safe queue for functions that will execute on the context
// thread
DATADOG_ASSERT(!_context_queue, "_context_queue already exists on Start()");
_context_queue = std::make_unique<Queue<std::function<void()>>>();

// Start the context thread that will execute functions submitted by features
DATADOG_ASSERT(!_context_thread, "_context_thread already exists on Start()");
_context_thread = std::thread(
ContextThreadMain, std::ref(_diagnostic_logger), std::ref(*_context_queue)
);

// Initialize an upload scheduler to manage the timing of upload cycles for each
// feature
DATADOG_ASSERT(!_upload_scheduler, "_upload_scheduler already exists on Start()");
_upload_scheduler = std::make_unique<UploadScheduler>(*_subsystems.clock);

Expand Down Expand Up @@ -355,12 +374,13 @@ bool Core::Start() {
// FeatureScope interface that it can use to interoperate with the core
for (const auto& feature : _features) {
const FeatureId id = feature.id;
EventGeneratedFunc event_generated_func =
[this, id](Block event, Block event_metadata) -> bool {
EventWriter event_writer = [this, id](Block event, Block event_metadata) -> bool {
return EnqueueStorageWrite(id, event, event_metadata);
};
feature.impl->OnCoreStarted(
FeatureScope(*_context_provider, event_generated_func, _diagnostic_logger)
FeatureScope::Create(
*_context_provider, event_writer, _diagnostic_logger, *_context_queue
)
);
}
return true;
Expand All @@ -373,12 +393,12 @@ void Core::Stop() {
}
_diagnostic_logger.Debug("Beginning Core shutdown");

// Notify each registered feature that the core has stopped
for (const auto& feature : _features) {
feature.impl->OnCoreStopping();
}

// If we were previously started, the storage and upload threads should be running
// If we were previously started, all background threads should be running
DATADOG_ASSERT(_context_queue, "_context_queue is invalid on Stop");
DATADOG_ASSERT(
_context_thread && _context_thread->joinable(),
"_context_thread is non-joinable on Stop"
);
DATADOG_ASSERT(_storage_queue, "_storage_queue is invalid on Stop");
DATADOG_ASSERT(
_storage_thread && _storage_thread->joinable(),
Expand All @@ -390,6 +410,27 @@ void Core::Stop() {
"_upload_thread is non-joinable on Stop"
);

// Stop the context queue, then block until the context thread drains the queue and
// exits. This ensures all pending feature work completes before we stop the storage
// thread or tear down feature state.
// TODO(RUM-15042): Other SDKs abandon the context thread and shut down in a
// non-blocking fashion, without draining the context queue
_context_queue->Stop();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep stop hooks able to mutate context before queue shutdown

Stopping the context queue before invoking OnCoreStopping() causes stop-time context mutations to be silently dropped, because FeatureScope::UpdateContext() enqueues via Push() and does not handle a false return once the queue is stopped. In this commit, Rum::Stop() still depends on UpdateContext() to clear ctx.rum, so after a stop/start cycle stale RUM IDs can remain in CoreContext until a later RUM command runs, and early log events may be enriched with incorrect session/view/action IDs.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in fcbdf93 - rather than requiring each feature to reset its own subset of CoreContext state on Stop() (which can no longer be done because Feature-induced CoreContext mutations are now asynchronous, and no further async work can be done on Stop()), we now just have Core::Start() preemptively reset CoreContext to a clean starting value.

if (_context_thread) {
_diagnostic_logger.Debug("Joining on context thread");
_context_thread->join();
}
_context_thread.reset();

// Notify each registered feature that the core has stopped, now that no more
// context-thread functions enqueued by those features may be running
for (const auto& feature : _features) {
feature.impl->OnCoreStopping();
}

// Destroy the context queue, now that no more features exist
_context_queue.reset();

// Stop all queue processing, then block until the consumer thread drains the queue
// and exits, at which point it's safe to release the queue
_storage_queue->Stop();
Expand Down
3 changes: 3 additions & 0 deletions src/datadog/impl/core/core.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -386,6 +386,9 @@ class Core {
std::unique_ptr<StorageQueue> _storage_queue;
std::optional<std::thread> _storage_thread;

std::unique_ptr<Queue<std::function<void()>>> _context_queue;
std::optional<std::thread> _context_thread;

std::unique_ptr<UploadScheduler> _upload_scheduler;
std::optional<std::thread> _upload_thread;

Expand Down
12 changes: 1 addition & 11 deletions src/datadog/impl/core/feature.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -20,22 +20,12 @@ void Feature::OnCoreStarted(FeatureScope&& feature_scope) {
}

void Feature::OnCoreStopping() {
// Notify the feature that the core is about to stop, while it's still able to write
// events
// Notify the feature that the core is stopping
Stop();

// Drop our FeatureScope; the Core is stopped so we must stop all work
DATADOG_ASSERT(_scope, "Feature has null _scope in OnCoreStop");
_scope.reset();
}

bool Feature::WriteEvent(Block event, Block event_metadata) const {
if (_scope) {
return _scope->WriteEvent(event, event_metadata);
}
return false;
}

bool Feature::IsRunning() const { return _scope.has_value(); }

} // namespace datadog::impl
34 changes: 12 additions & 22 deletions src/datadog/impl/core/feature.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -74,8 +74,8 @@ struct FeatureStorageConfig {
* - If the feature has unique storage requirements, implement `GetStorageConfig()`
* - If the feature needs initialization/shutdown logic, implement `Start()`/`Stop()`
* - Define main-thread-callable functions for the required feature-specific operations
* - In those functions, call `WriteEvent()` for each event the feature generate, using
* whatever binary format is appropriate for the feature
* - In those functions, call `_scope->ExecuteOnContextThread()` to generate events
* asynchronously on the context thread, using the provided `EventWriter`
* - Implement `UploadThread_PrepareReport()` to read batches of events (in the same
* format) and return a `Report` object describing the resulting HTTP request that
* should be made to upload that batch to the appropriate intake endpoint
Expand Down Expand Up @@ -110,32 +110,22 @@ class Feature : public std::enable_shared_from_this<Feature> {
protected:
/**
* Called from the main thread when the SDK has finished starting up. This is the
* first point at which events may be generated.
* first point at which _scope is valid.
*
* Once Start() is called, the feature may enqueue work on the context thread via
* _scope->ExecuteOnContextThread(), generating events in the process, until Stop() is
* called.
*/
virtual void Start() {}

/**
* Called from the main thread when the SDK is about to shut down. This is the last
* point at which events may be generated.
*/
virtual void Stop() {}

/**
* Callable from the main thread in order to produce a new event. Copies the given
* event payload(s) into the storage queue so that the storage thread can write them
* to persistent storage. Once the batch containing this event is ready for upload,
* the upload thread will pass it to UploadThread_PrepareReport().
* Called from the main thread when the SDK is shutting down. This is the last point
* at which _scope is valid.
*
* @returns whether the event was successfully enqueued for storage. If called before
* Start() or after Stop(), always returns false.
* Once Stop() is called, any work enqueued via _scope->ExecuteOnContextThread() will
* be ignored, and the feature may no longer generate events.
*/
bool WriteEvent(Block event, Block event_metadata = {}) const;

/**
* @returns whether the feature has received a call to Start() and has not yet
* received a call to Stop().
*/
bool IsRunning() const;
virtual void Stop() {}

public:
/**
Expand Down
85 changes: 73 additions & 12 deletions src/datadog/impl/core/feature_scope.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -12,28 +12,89 @@ namespace datadog::impl {

FeatureScope::FeatureScope(
CoreContextProvider& context_provider,
const EventGeneratedFunc& event_generated_func,
const DiagnosticLogger& in_diagnostic_logger
const EventWriter& event_writer,
const DiagnosticLogger& in_diagnostic_logger,
FeatureScope::ExecutionMode mode,
Queue<std::function<void()>>* context_queue

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I really wish C++ had a nice way to enforce single ownership with non-owning observers that didn't require shared_ptr.

)
: _context_provider(&context_provider),
_event_generated_func(event_generated_func),
diagnostic_logger(in_diagnostic_logger) {}
_event_generated_func(event_writer),
_mode(mode),
_context_queue(context_queue),
diagnostic_logger(in_diagnostic_logger) {
// Invariant: context_queue must be non-null when mode is OnContextThread
DATADOG_ASSERT(
mode != FeatureScope::ExecutionMode::OnContextThread || context_queue != nullptr,
"context_queue must be non-null when ExecutionMode is OnContextThread"
);
}

CoreContext FeatureScope::GetContext() const {
DATADOG_ASSERT(_context_provider, "FeatureScope has no _context_provider");
return _context_provider->Get();
FeatureScope FeatureScope::Create(
CoreContextProvider& context_provider,
const EventWriter& event_writer,
const DiagnosticLogger& diagnostic_logger,
Queue<std::function<void()>>& context_queue
) {
return FeatureScope(
context_provider,
event_writer,
diagnostic_logger,
FeatureScope::ExecutionMode::OnContextThread,
&context_queue
);
}

FeatureScope FeatureScope::CreateForTesting(
CoreContextProvider& context_provider,
const EventWriter& event_writer,
const DiagnosticLogger& diagnostic_logger
) {
return FeatureScope(
context_provider,
event_writer,
diagnostic_logger,
FeatureScope::ExecutionMode::Synchronous,
nullptr
);
}

void FeatureScope::UpdateContext(const std::function<void(CoreContext&)>& callback) {
DATADOG_ASSERT(_context_provider, "FeatureScope has no _context_provider");
_context_provider->Update(callback);

if (_mode == FeatureScope::ExecutionMode::Synchronous) {
// Testing mode: execute synchronously on calling thread
_context_provider->Update(callback);
return;
}

// Production mode: queue for async execution on context thread
DATADOG_ASSERT(
_context_queue != nullptr, "context_queue is null in OnContextThread mode"
);
_context_queue->Push([this, callback]() {
DATADOG_ASSERT(_context_provider, "FeatureScope has no _context_provider");
_context_provider->Update(callback);
});
Comment on lines +74 to +77

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Update CoreContext immediately when already on context thread

UpdateContext now always re-enqueues the mutation, even when the caller is already running on the context thread. RUM command processing calls UpdateFeatureContext() from inside ExecuteOnContextThread, so the actual context write is deferred behind already-queued work; a subsequent log callback queued right after a RUM call can therefore run before the RUM context update and be enriched with stale/missing RUM IDs, regressing correlation accuracy.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a legitimate error in the implementation. As originally written, calling a RUM API function would enqueue a single function to run on the context thread. That function would perform its command-processing work (updating internal state and producing RUM events), then enqueue another function that would mutate the CoreContext to add the latest RUM UUIDs.

That second function would be enqueued at the end of the queue, causing a legitimate bug. e.g. if you made these three API calls back-to-back:

  • logger.Info("log1");
  • rum.StartView("my-view");
  • logger.Info("log2");

...then the second log event would be missing the RUM View ID set for my-view.

This is fixed in dac8a27: we now enqueue our two functions back-to-back, serially, in response to a RUM API call, such that in the example above, log2 will consistently see the updated View ID set by StartView.

}

bool FeatureScope::WriteEvent(Block event, Block event_metadata) const {
if (_event_generated_func) {
return _event_generated_func(event, event_metadata);
void FeatureScope::ExecuteOnContextThread(const ContextThreadFunc& func) {
if (_mode == FeatureScope::ExecutionMode::Synchronous) {
// Testing mode: execute synchronously on calling thread
DATADOG_ASSERT(_context_provider, "FeatureScope has no _context_provider");
const CoreContext context = _context_provider->Get();
func(context, _event_generated_func);
return;
}
return false;

// Production mode: queue for async execution on context thread
DATADOG_ASSERT(
_context_queue != nullptr, "context_queue is null in OnContextThread mode"
);
_context_queue->Push([this, func]() {
DATADOG_ASSERT(_context_provider, "FeatureScope has no _context_provider");
const CoreContext context = _context_provider->Get();
func(context, _event_generated_func);
Comment on lines +93 to +96

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Avoid dangling this captures in context queue thunks

ExecuteOnContextThread enqueues a lambda that captures this, then dereferences _context_provider and _event_generated_func later on the context thread. During shutdown, Core::Stop() calls each feature's OnCoreStopping() (which resets/destroys FeatureScope) before it drains the context queue, so any queued thunk that has not run yet can execute with a dangling FeatureScope pointer and trigger use-after-free/crashes.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a fair point - fixed in 1dc05e9 by modifying the shutdown order such that the context queue is drained, then all features are stopped, then the storage and upload threads are stopped.

As a consequence, it's no longer possible for a feature implementation to do last-minute work (or produce last-minute events) in response to Feature::Stop() - see 77dfaa9. This is appropriate, as SDK stop is designed to be an immediate, non-blocking operation - in existing RUM SDKs, features are not traditionally given the opportunity to produce events in response to core.stop().

Note that in its current form, the C++ SDK still drains both the context queue and storage queue on shutdown, making Core::Stop() a blocking operation whose overhead scales with the amount of pending work enqueued by the application's API calls. This is nifty in that we can guarantee that if an API call produces an event, that event will be flushed to disk reliably, but it's inconsistent with the behavior of the iOS SDK, which treats shutdown as immediate and non-blocking, abandoning all pending work unless using a special integration-test-only shutdown method. I've created RUM-15042 to track this design discrepancy.

});
}

} // namespace datadog::impl
Loading