From a8927195e9ef658c62014cd80de64655f7e6e386 Mon Sep 17 00:00:00 2001 From: Jeff Ward Date: Thu, 9 Jul 2026 12:21:36 -0400 Subject: [PATCH 1/2] feat: Add manual Long Task reporting Long tasks are periods in the application that stall the main thread for a specific length of time. While we can't automatically detect these in the C++ SDK, we need to provide a way for clients (including other SDKs) to manually report them. The C++ SDK takes a long task duration in Nanoseconds (following the Android SDK as an example) and sends a Long Task event to RUM. Additionally, View and Action scopes keep track of the number of Long Tasks that occur in their scope, and forward this information over to RUM when those events are sent. refs: RUM-17248 --- include-c/datadog/rum.h | 14 ++ include-cpp/datadog/rum.hpp | 14 ++ src/datadog/c/rum.cpp | 26 +++ src/datadog/cpp/rum.cpp | 16 ++ src/datadog/impl/core/feature_types/rum.hpp | 217 ++++++++++++++++++++ src/datadog/impl/core/util/validation.cpp | 4 +- src/datadog/impl/rum/command.hpp | 20 ++ src/datadog/impl/rum/rum.cpp | 4 + src/datadog/impl/rum/rum.hpp | 6 + src/datadog/impl/rum/scopes/action.cpp | 9 + src/datadog/impl/rum/scopes/action.hpp | 1 + src/datadog/impl/rum/scopes/view.cpp | 104 ++++++++++ src/datadog/impl/rum/scopes/view.hpp | 18 ++ tests/c/rum_c_api_test.cpp | 153 ++++++++++++++ tests/cpp/rum_cpp_api_test.cpp | 152 ++++++++++++++ tests/impl/core/feature_types/rum_test.cpp | 149 ++++++++++++++ tests/impl/rum/scopes/action_test.cpp | 26 +++ 17 files changed, 931 insertions(+), 2 deletions(-) diff --git a/include-c/datadog/rum.h b/include-c/datadog/rum.h index 1ca6eb97..2aaa2e03 100644 --- a/include-c/datadog/rum.h +++ b/include-c/datadog/rum.h @@ -498,6 +498,20 @@ DATADOG_API void dd_rum_add_error( dd_attribute_t* attributes ); +// === RUM long tasks === + +/** + * Records that the application encountered a long task (a period during which the main + * thread was blocked for an extended duration) in the context of the current view. + * + * @param duration_ns - The duration of the long task, in nanoseconds. Must be positive. + * @param attributes - An optional set of custom attributes describing the long task, + * provided as a dd_attribute_t value with DD_VALUE_TYPE_OBJECT. + */ +DATADOG_API void dd_rum_add_long_task( + dd_rum_t* rum, uint64_t duration_ns, dd_attribute_t* attributes +); + #ifdef __cplusplus } #endif diff --git a/include-cpp/datadog/rum.hpp b/include-cpp/datadog/rum.hpp index da9bc7a1..e11a56ee 100644 --- a/include-cpp/datadog/rum.hpp +++ b/include-cpp/datadog/rum.hpp @@ -403,6 +403,20 @@ class Rum { const Attribute& attributes = Attribute() ); + /** + * Records that the application encountered a long task (a period during which the + * main thread was blocked for an extended duration) in the context of the current + * view. + * + * @param duration_ns - The duration of the long task, in nanoseconds. Must be + * positive. + * @param attributes - An optional set of custom attributes describing the long task, + * provided as an Attribute with ValueType::Object. + */ + DATADOG_API void AddLongTask( + uint64_t duration_ns, const Attribute& attributes = Attribute() + ); + /** * Records the start of a operation (e.g. login, checkout, upload). * diff --git a/src/datadog/c/rum.cpp b/src/datadog/c/rum.cpp index 90ab2896..141719f8 100644 --- a/src/datadog/c/rum.cpp +++ b/src/datadog/c/rum.cpp @@ -690,6 +690,32 @@ void dd_rum_add_error( }; rum->impl->AddError(datadog::RumErrorSource_FromC(source), error, cpp_attributes); } + +void dd_rum_add_long_task( + dd_rum_t* rum, uint64_t duration_ns, dd_attribute_t* attributes +) { + // If the underlying feature is NULL, this call is a no-op + if (!rum || !rum->impl) { + return; + } + + // The schema for RUM long_task events requires a positive duration; reject it, + // logging a warning + if (duration_ns == 0) { + rum->diagnostic_logger.Warning( + "dd_rum_add_long_task call ignored: application must supply a positive duration" + ); + return; + } + + // If we've been given a valid object attribute, convert it to the equivalent C++ type + datadog::Attribute cpp_attributes; // Default-initialized to Attribute::Null() + if (attributes && attributes->type == DD_VALUE_TYPE_OBJECT) { + cpp_attributes = datadog::impl::AttributeConversion::CopyFromC(*attributes); + } + + rum->impl->AddLongTask(datadog::Duration(duration_ns), cpp_attributes); +} } // NOLINTEND(cppcoreguidelines-owning-memory) diff --git a/src/datadog/cpp/rum.cpp b/src/datadog/cpp/rum.cpp index 46fe9dff..ac0f111a 100644 --- a/src/datadog/cpp/rum.cpp +++ b/src/datadog/cpp/rum.cpp @@ -7,6 +7,7 @@ #include "datadog/rum.hpp" #include +#include #include "datadog/core.hpp" @@ -324,6 +325,21 @@ void Rum::AddError( } } +void Rum::AddLongTask(uint64_t duration_ns, const Attribute& attributes) { + if (_impl) { + // The schema for RUM long_task events requires a positive duration; reject it, + // logging a warning + if (duration_ns == 0) { + impl::DiagnosticLogger{_diagnostic_handler, _diagnostic_threshold}.Warning( + "Rum::AddLongTask call ignored: application must supply a positive duration" + ); + return; + } + + _impl->AddLongTask(Duration(duration_ns), attributes); + } +} + void Rum::StartOperation( std::string_view name, std::string_view operation_key, const Attribute& attributes ) { diff --git a/src/datadog/impl/core/feature_types/rum.hpp b/src/datadog/impl/core/feature_types/rum.hpp index 29157167..c389c2dd 100644 --- a/src/datadog/impl/core/feature_types/rum.hpp +++ b/src/datadog/impl/core/feature_types/rum.hpp @@ -2047,6 +2047,223 @@ DATADOG_JSON_STRUCT( // NYI: feature_flags ) +// === Long Task Event === + +struct RumLongTaskEvent { + struct Application { + // From _common-schema.json + UUID id; + OmitIfEmpty current_locale; + + explicit Application(const UUID& in_id) : id(in_id) {} + }; + struct Session { + // From _common-schema.json + UUID id; + StringRumSessionType type; + OmitIfFalse has_replay{false}; + + explicit Session(const UUID& in_id, RumSessionType in_type) + : id(in_id), type(in_type) {} + }; + struct View { + // From _common-schema.json + UUID id; + OmitIfEmpty referrer; + std::string url; + OmitIfEmpty name; + + explicit View(UUID in_id, std::string_view in_url) : id(in_id), url(in_url) {} + }; + struct Display { + // From _common-schema.json + OmitIfNoValue viewport; + + Display() {}; + }; + struct Action { + // From _action-child-schema.json + UUID id; // Schema permits array of UUIDs; we only support a single value + + explicit Action(UUID in_id) : id(in_id) {} + }; + struct LongTask { + // From long_task-schema.json + UUID id; + int64_t duration; + OmitIfNoValue is_frozen_frame; + // NYI: entry_type, render_start, style_and_layout_start, first_ui_event_timestamp, + // blocking_duration, scripts (all long-animation-frame/browser-only properties) + + explicit LongTask(const UUID& in_id, int64_t in_duration) + : id(in_id), duration(in_duration) {} + }; + struct Internal { + struct Session { + // From _common-schema.json + OmitIfZero plan{}; + OmitIfNoValue session_precondition; + + Session() {}; + }; + struct Configuration { + // From _common-schema.json + float session_sample_rate; + OmitIfNoValue session_replay_sample_rate; + OmitIfNoValue profiling_sample_rate; + + explicit Configuration(float in_session_sample_rate) + : session_sample_rate(in_session_sample_rate) {} + }; + // From _common-schema.json + uint8_t format_version{2}; + OmitIfNoValue session; + OmitIfNoValue configuration; + OmitIfEmpty browser_sdk_version; + // NYI: discarded (long_task-schema.json; backend-computed) + + Internal() {} + }; + // From _common-schema.json + MilliTimestamp date; + Application application; + OmitIfEmpty service; + OmitIfEmpty version; + OmitIfEmpty build_version; + OmitIfEmpty build_id; + OmitIfEmpty ddtags; + Session session; + OmitIfNoValue source; + View view; + OmitIfNoValue usr; + OmitIfNoValue account; + OmitIfNoValue connectivity; + OmitIfNoValue display; + OmitIfNoValue synthetics; + OmitIfNoValue ci_test; + OmitIfNoValue os; + OmitIfNoValue device; + Internal _dd; + OmitIfZero context; + // NYI: stream + + // From _action-child-schema.json + OmitIfNoValue action; + + // From long_task-schema.json + std::string_view type{"long_task"}; + LongTask long_task; + + explicit RumLongTaskEvent( + Timestamp in_date, + const UUID& in_application_id, + const UUID& in_session_id, + RumSessionType in_session_type, + const UUID& in_view_id, + std::string_view in_view_url, + const UUID& in_long_task_id, + int64_t in_long_task_duration + ) + : date(in_date), + application(Application{in_application_id}), + session(Session{in_session_id, in_session_type}), + view(View{in_view_id, in_view_url}), + _dd(Internal{}), + long_task(in_long_task_id, in_long_task_duration) {} +}; +DATADOG_JSON_STRUCT( + RumLongTaskEvent::Application, + // From _common-schema.json + DATADOG_JSON_FIELD(id), + DATADOG_JSON_FIELD(current_locale) +) +DATADOG_JSON_STRUCT( + RumLongTaskEvent::Session, + // From _common-schema.json + DATADOG_JSON_FIELD(id), + DATADOG_JSON_FIELD(type), + DATADOG_JSON_FIELD(has_replay) +) +DATADOG_JSON_STRUCT( + RumLongTaskEvent::View, + // From _common-schema.json + DATADOG_JSON_FIELD(id), + DATADOG_JSON_FIELD(referrer), + DATADOG_JSON_FIELD(url), + DATADOG_JSON_FIELD(name) +) +DATADOG_JSON_STRUCT( + RumLongTaskEvent::Display, + // From _common-schema.json + DATADOG_JSON_FIELD(viewport) +) +DATADOG_JSON_STRUCT( + RumLongTaskEvent::Action, + // From _action-child-schema.json + DATADOG_JSON_FIELD(id) +) +DATADOG_JSON_STRUCT( + RumLongTaskEvent::LongTask, + // From long_task-schema.json + DATADOG_JSON_FIELD(id), + DATADOG_JSON_FIELD(duration), + DATADOG_JSON_FIELD(is_frozen_frame) + // NYI: entry_type, render_start, style_and_layout_start, first_ui_event_timestamp, + // blocking_duration, scripts +) +DATADOG_JSON_STRUCT( + RumLongTaskEvent::Internal::Session, + // From _common-schema.json + DATADOG_JSON_FIELD(plan), + DATADOG_JSON_FIELD(session_precondition) +) +DATADOG_JSON_STRUCT( + RumLongTaskEvent::Internal::Configuration, + // From _common-schema.json + DATADOG_JSON_FIELD(session_sample_rate), + DATADOG_JSON_FIELD(session_replay_sample_rate), + DATADOG_JSON_FIELD(profiling_sample_rate) +) +DATADOG_JSON_STRUCT( + RumLongTaskEvent::Internal, + // From _common-schema.json + DATADOG_JSON_FIELD(format_version), + DATADOG_JSON_FIELD(session), + DATADOG_JSON_FIELD(configuration), + DATADOG_JSON_FIELD(browser_sdk_version) + // NYI: discarded +) +DATADOG_JSON_STRUCT( + RumLongTaskEvent, + // From _common-schema.json + DATADOG_JSON_FIELD(date), + DATADOG_JSON_FIELD(application), + DATADOG_JSON_FIELD(service), + DATADOG_JSON_FIELD(version), + DATADOG_JSON_FIELD(build_version), + DATADOG_JSON_FIELD(build_id), + DATADOG_JSON_FIELD(ddtags), + DATADOG_JSON_FIELD(session), + DATADOG_JSON_FIELD(source), + DATADOG_JSON_FIELD(view), + DATADOG_JSON_FIELD(usr), + DATADOG_JSON_FIELD(account), + DATADOG_JSON_FIELD(connectivity), + DATADOG_JSON_FIELD(display), + DATADOG_JSON_FIELD(synthetics), + DATADOG_JSON_FIELD(ci_test), + DATADOG_JSON_FIELD(os), + DATADOG_JSON_FIELD(device), + DATADOG_JSON_FIELD(_dd), + DATADOG_JSON_FIELD(context), + // NYI: stream + // From _action-child-schema.json + DATADOG_JSON_FIELD(action), + // From long_task-schema.json + DATADOG_JSON_FIELD(type), + DATADOG_JSON_FIELD(long_task) +) + // === Vital Operation Step Event === enum class RumVitalType : uint8_t { OperationStep }; diff --git a/src/datadog/impl/core/util/validation.cpp b/src/datadog/impl/core/util/validation.cpp index 9165f830..9e1a63a9 100644 --- a/src/datadog/impl/core/util/validation.cpp +++ b/src/datadog/impl/core/util/validation.cpp @@ -4,9 +4,9 @@ // This product includes software developed at Datadog (https://www.datadoghq.com/). // Copyright 2025-Present Datadog, Inc. -#include +#include "datadog/impl/core/util/validation.hpp" -#include "datadog/impl/core/util/diagnostics.hpp" +#include namespace datadog::impl { diff --git a/src/datadog/impl/rum/command.hpp b/src/datadog/impl/rum/command.hpp index d3465447..7ec2381c 100644 --- a/src/datadog/impl/rum/command.hpp +++ b/src/datadog/impl/rum/command.hpp @@ -267,6 +267,20 @@ struct RumAddErrorPayload { : source(in_source), error(in_error) {} }; +/** + * On AddLongTask, the application has called the AddLongTask API function, recording + * that the application encountered a long task that should be reported in the context + * of the current view. + */ +struct RumAddLongTaskPayload { + static constexpr const char* COMMAND_NAME = "AddLongTask"; + static constexpr RumCommandFlags FLAGS = RumCommandFlags::RequiresActiveView; + + Duration duration; + + explicit RumAddLongTaskPayload(Duration in_duration) : duration(in_duration) {} +}; + /** * On StartOperation, the application has called the StartOperation API * function, recording the start of a user-facing operation (e.g. login, checkout). @@ -322,6 +336,7 @@ struct RumCommand { RumStartActionPayload, RumStopActionPayload, RumAddErrorPayload, + RumAddLongTaskPayload, RumStartOperationPayload, RumStopOperationPayload>; @@ -412,6 +427,11 @@ struct RumCommand { return RumCommand(std::move(base), RumAddErrorPayload(source, error)); } + /** Creates a new 'AddLongTask' command. */ + static RumCommand AddLongTask(RumCommandParams&& base, Duration duration) { + return RumCommand(std::move(base), RumAddLongTaskPayload(duration)); + } + /** Creates a new 'StartOperation' command. */ static RumCommand StartOperation( RumCommandParams&& base, diff --git a/src/datadog/impl/rum/rum.cpp b/src/datadog/impl/rum/rum.cpp index c79a48c0..9e7198b2 100644 --- a/src/datadog/impl/rum/rum.cpp +++ b/src/datadog/impl/rum/rum.cpp @@ -270,6 +270,10 @@ void Rum::AddError( DispatchAsync(RumCommand::AddError(GetBaseCommandParams(attributes), source, error)); } +void Rum::AddLongTask(Duration duration, const Attribute& attributes) { + DispatchAsync(RumCommand::AddLongTask(GetBaseCommandParams(attributes), duration)); +} + void Rum::StartOperation( std::string_view name, std::optional operation_key, diff --git a/src/datadog/impl/rum/rum.hpp b/src/datadog/impl/rum/rum.hpp index 4bfe3843..d7754d0a 100644 --- a/src/datadog/impl/rum/rum.hpp +++ b/src/datadog/impl/rum/rum.hpp @@ -136,6 +136,12 @@ class Rum final : public Feature { const Attribute& attributes = Attribute() ); + /** + * Handles an AddLongTask API call, causing a long_task to be reported in the context + * of the current view. + */ + void AddLongTask(Duration duration, const Attribute& attributes = Attribute()); + /** * Handles a StartOperation API call, recording the start of a user-facing * operation. diff --git a/src/datadog/impl/rum/scopes/action.cpp b/src/datadog/impl/rum/scopes/action.cpp index 4ddc2c77..8b29df8c 100644 --- a/src/datadog/impl/rum/scopes/action.cpp +++ b/src/datadog/impl/rum/scopes/action.cpp @@ -112,6 +112,12 @@ RumScopeResult RumActionScope::Process( _num_errors_recorded++; } + // On AddLongTask: increment the count of long tasks recorded while this action was + // active + if (command.Is()) { + _num_long_tasks_recorded++; + } + // The action remains active return RumScopeResult::RemainOpen; } @@ -166,6 +172,9 @@ void RumActionScope::SendActionEvent( if (_num_errors_recorded > 0) { ev.action.error.value.emplace(_num_errors_recorded); } + if (_num_long_tasks_recorded > 0) { + ev.action.long_task.value.emplace(_num_long_tasks_recorded); + } // Prepare to merge the final set of custom attributes for our action event, in this // order: diff --git a/src/datadog/impl/rum/scopes/action.hpp b/src/datadog/impl/rum/scopes/action.hpp index 2e1447fa..f88d3d76 100644 --- a/src/datadog/impl/rum/scopes/action.hpp +++ b/src/datadog/impl/rum/scopes/action.hpp @@ -98,6 +98,7 @@ class RumActionScope { int32_t _num_active_resources{0}; size_t _num_resources_recorded{0}; size_t _num_errors_recorded{0}; + size_t _num_long_tasks_recorded{0}; bool _has_sent_action_event{false}; diff --git a/src/datadog/impl/rum/scopes/view.cpp b/src/datadog/impl/rum/scopes/view.cpp index 34c7ec19..ccbe4c4d 100644 --- a/src/datadog/impl/rum/scopes/view.cpp +++ b/src/datadog/impl/rum/scopes/view.cpp @@ -16,6 +16,10 @@ namespace datadog::impl { +// Matches the frozen-frame threshold used by other Datadog SDKs: a long task is +// considered a "frozen frame" if its duration exceeds 700ms. +static constexpr Duration FROZEN_FRAME_THRESHOLD = std::chrono::milliseconds(700); + RumViewScope::RumViewScope( const RumScopeDependencies& deps, RumSessionScope& parent, @@ -191,6 +195,14 @@ RumViewScope::ViewEventType RumViewScope::HandleCommand( ); } + // On `AddLongTask`, we should immediately send a long_task event in the context of + // this view, updating our view state accordingly + if (command.Is()) { + return HandleAddLongTask( + command.base, command.As(), context, writer + ); + } + return ViewEventType::None; } @@ -384,6 +396,24 @@ RumViewScope::ViewEventType RumViewScope::HandleAddError( return ViewEventType::Full; } +RumViewScope::ViewEventType RumViewScope::HandleAddLongTask( + const RumCommandParams& base, + const RumAddLongTaskPayload& payload, + const CoreContext& context, + const EventWriter& writer +) { + // If the view is no longer active, it should report no long tasks + if (!_is_active) { + return ViewEventType::None; + } + + // Immediately generate a RUM 'long_task' event describing the long task + SendLongTaskEvent(base, payload, context, writer); + + // Our long task count has been incremented we must update the state of the view + return ViewEventType::Full; +} + void RumViewScope::BecomeInactive( const RumCommandParams& base, bool accept_command_attributes ) { @@ -545,6 +575,13 @@ void RumViewScope::SendViewEvent( ev.view.name.value = _name; } + if (_num_long_tasks_reported > 0) { + ev.view.long_task.value.emplace(_num_long_tasks_reported); + } + if (_num_frozen_frames_reported > 0) { + ev.view.frozen_frame.value.emplace(_num_frozen_frames_reported); + } + // Set 'context' to the full set of user-specified attributes that should be included // in this event. If the view is still active, we resolve the current set of global // attribute values carried with the command; if the view is inactive, we ignore those @@ -645,6 +682,73 @@ void RumViewScope::SendErrorEvent( _num_errors_reported++; } +void RumViewScope::SendLongTaskEvent( + const RumCommandParams& base, + const RumAddLongTaskPayload& payload, + const CoreContext& context, + const EventWriter& writer +) { + DATADOG_ASSERT(_is_active, "SendLongTaskEvent called while view scope is inactive"); + + // Resolve references needed to populate required event data + const RumScopeDependencies& deps = _deps; + const RumSessionScope& session = _parent; + + // The 'date' timestamp on a RUM 'long_task' event indicates the time at which the + // long task started, i.e. when it finished minus its duration; by the time we're + // notified of a long task, it has already completed + const Timestamp event_timestamp = base.issued_at - payload.duration; + + const bool is_frozen_frame = payload.duration > FROZEN_FRAME_THRESHOLD; + const int64_t duration_ns = payload.duration.count(); + + // Construct an event value on the stack with the minimal set of required properties + RumLongTaskEvent ev( + event_timestamp, + deps.application_id, + session.GetSessionID(), + RumSessionType::User, + _view_id, + _key, + UUID::Random(), + duration_ns + ); + + // Set essential view properties + if (!_name.empty()) { + ev.view.name.value = _name; + } + + // Correlate this event with the active action, if we have one + if (_active_action_scope) { + ev.action.value.emplace(_active_action_scope->GetActionID()); + } + + ev.long_task.is_frozen_frame.value = is_frozen_frame; + + // Set 'context' to the full set of user-specified attributes that should be included + // in this event, merging: global <- view <- long task + Attribute merged_attributes = Attribute::Object(); + AttributeMerge::AssembleObject( + merged_attributes, + {base.global_attributes, _view_attributes.attribute, base.attributes} + ); + if (merged_attributes.GetObjectPropertyCount() > 0) { + ev.context.value = merged_attributes; + } + + // Enrich event with OS and device properties from CoreContext + RumEventEnrichment::PopulateCommonProperties(context, ev); + + std::string_view json = deps.EncodeEvent(ev); + const bool bypass_tracking_consent = false; + writer(Block{json.data(), json.size()}, Block{}, bypass_tracking_consent); + _num_long_tasks_reported++; + if (is_frozen_frame) { + _num_frozen_frames_reported++; + } +} + ScopeRef RumViewScope::GetActiveAction() const { return _active_action_scope; } diff --git a/src/datadog/impl/rum/scopes/view.hpp b/src/datadog/impl/rum/scopes/view.hpp index 124907fe..5b486c9b 100644 --- a/src/datadog/impl/rum/scopes/view.hpp +++ b/src/datadog/impl/rum/scopes/view.hpp @@ -198,6 +198,12 @@ class RumViewScope { const CoreContext& context, const EventWriter& writer ); + ViewEventType HandleAddLongTask( + const RumCommandParams& base, + const RumAddLongTaskPayload& payload, + const CoreContext& context, + const EventWriter& writer + ); /** * Renders the view inactive, updating all necessary state to finalize the scope. This @@ -246,6 +252,16 @@ class RumViewScope { const EventWriter& writer ); + /** + * Generates and sends a RUM long_task event in response to the given command. + */ + void SendLongTaskEvent( + const RumCommandParams& base, + const RumAddLongTaskPayload& payload, + const CoreContext& context, + const EventWriter& writer + ); + private: std::reference_wrapper _deps; std::reference_wrapper _parent; @@ -292,6 +308,8 @@ class RumViewScope { uint64_t _num_actions_completed{0}; uint64_t _num_errors_reported{0}; uint64_t _num_resources_completed{0}; + uint64_t _num_long_tasks_reported{0}; + uint64_t _num_frozen_frames_reported{0}; std::optional _active_action_scope; diff --git a/tests/c/rum_c_api_test.cpp b/tests/c/rum_c_api_test.cpp index 31de649d..b4c3f0d3 100644 --- a/tests/c/rum_c_api_test.cpp +++ b/tests/c/rum_c_api_test.cpp @@ -657,6 +657,19 @@ TEST_CASE("dd_rum argument validation", "[unit][rum][c-api]") { "supply a non-empty error message"}, {}}, + // === dd_rum_add_long_task() === + + {"M print warning W dd_rum_add_long_task is called with non-positive duration", + [&](dd_rum_config_t* config, dd_core_t* core) { + with_rum(config, core, [](dd_rum_t* rum) { + dd_rum_start_view(rum, "my-view", "My View", nullptr); + dd_rum_add_long_task(rum, 0, NULL); + }); + }, + {"dd_rum_add_long_task call ignored: application must supply a positive " + "duration"}, + {}}, + // === dd_rum_start/succeed/fail_operation() === {"M print error W dd_rum_start_operation is called with NULL name", @@ -2844,6 +2857,146 @@ TEST_CASE("dd_rum events", "[unit][rum][c-api]") { })")); }}, + // === dd_rum_add_long_task() === + + {"M send long_task event W dd_rum_add_long_task is called", + [](dd_rum_config_t*) { + // Given an ordinary RUM config + }, + [](dd_rum_t* rum, MockClock& clock) { + // When we create a RUM view and then record a long task at T+5ms + dd_rum_start_view(rum, "my-view", "My View", nullptr); + clock.TickMilliseconds(5); + dd_rum_add_long_task(rum, 5000000, NULL); + }, + [](const nlohmann::json& events) { + // Then we get a long_task event with the task's duration and, since it did not + // exceed the frozen-frame threshold, is_frozen_frame is false; its date is + // computed as the report time minus the task's duration + auto long_tasks = filter_events("long_task", events); + REQUIRE(long_tasks.size() == 1); + RequireEventMatch(long_tasks[0], DATADOG_RUM_EVENT_LITERAL(R"({ + "type": "long_task", + "date": 1700000000000, + "ddtags": "service:mock-service,version:mock-application-version,env:mock-env,sdk_version:1.2.3", + "os": { + "name": "MockOS", + "version": "1.0.0", + "build": "12345", + "version_major": "1" + }, + "device": { + "type": "desktop", + "name": "MockDevice", + "model": "MockModel", + "brand": "MockBrand", + "architecture": "x86_64", + "locale": "en-US", + "time_zone": "UTC" + }, + "application": { + "id": "a991ca10-4004-4004-4004-beefbeefbeef" + }, + "session": { + "id": "${__NONZERO_UUID__}", + "type": "user" + }, + "view": { + "id": "${__NONZERO_UUID__}", + "url": "my-view", + "name": "My View" + }, + "long_task": { + "id": "${__NONZERO_UUID__}", + "duration": 5000000, + "is_frozen_frame": false + }, + "_dd": { + "format_version": 2 + } + })")); + + // And we also get a view event with an incremented long_task count and no + // frozen_frame count + auto views = filter_events("view", events); + REQUIRE(views.size() == 2); + REQUIRE(views[0]["view"]["id"] == long_tasks[0]["view"]["id"]); + REQUIRE(!views[0]["view"].contains("long_task")); + REQUIRE(!views[0]["view"].contains("frozen_frame")); + REQUIRE(views[1]["view"]["id"] == long_tasks[0]["view"]["id"]); + REQUIRE(views[1]["view"]["long_task"]["count"] == 1); + REQUIRE(!views[1]["view"].contains("frozen_frame")); + }}, + + {"M send frozen_frame long_task event with action.id W dd_rum_add_long_task " + "exceeds the frozen-frame threshold with an active action", + [](dd_rum_config_t*) { + // Given an ordinary RUM config + }, + [](dd_rum_t* rum, MockClock& clock) { + // When we create a RUM view and a RUM action, and then record a long task that + // exceeds the 700ms frozen-frame threshold at T+5ms + dd_rum_start_view(rum, "my-view", "My View", nullptr); + dd_rum_add_action(rum, DD_RUM_ACTION_TYPE_CLICK, "button1", NULL); + clock.TickMilliseconds(5); + dd_rum_add_long_task(rum, 800000000, NULL); + }, + [](const nlohmann::json& events) { + // Then we get a long_task event marked as a frozen frame, correlated with the + // active action + auto long_tasks = filter_events("long_task", events); + REQUIRE(long_tasks.size() == 1); + RequireEventMatch(long_tasks[0], DATADOG_RUM_EVENT_LITERAL(R"({ + "type": "long_task", + "date": 1699999999205, + "ddtags": "service:mock-service,version:mock-application-version,env:mock-env,sdk_version:1.2.3", + "os": { + "name": "MockOS", + "version": "1.0.0", + "build": "12345", + "version_major": "1" + }, + "device": { + "type": "desktop", + "name": "MockDevice", + "model": "MockModel", + "brand": "MockBrand", + "architecture": "x86_64", + "locale": "en-US", + "time_zone": "UTC" + }, + "application": { + "id": "a991ca10-4004-4004-4004-beefbeefbeef" + }, + "session": { + "id": "${__NONZERO_UUID__}", + "type": "user" + }, + "view": { + "id": "${__NONZERO_UUID__}", + "url": "my-view", + "name": "My View" + }, + "action": { + "id": "${__NONZERO_UUID__}" + }, + "long_task": { + "id": "${__NONZERO_UUID__}", + "duration": 800000000, + "is_frozen_frame": true + }, + "_dd": { + "format_version": 2 + } + })")); + + // And we get a view event with both long_task and frozen_frame counts of 1 + auto views = filter_events("view", events); + REQUIRE(views.back()["view"]["id"] == long_tasks[0]["view"]["id"]); + REQUIRE(views.back()["view"]["long_task"]["count"] == 1); + REQUIRE(views.back()["view"]["frozen_frame"]["count"] == 1); + }}, + // === Action lifetime vis-a-vis resources === {"M extend discrete action lifetime W concurrent resource remains active", diff --git a/tests/cpp/rum_cpp_api_test.cpp b/tests/cpp/rum_cpp_api_test.cpp index 20124372..3827d2b9 100644 --- a/tests/cpp/rum_cpp_api_test.cpp +++ b/tests/cpp/rum_cpp_api_test.cpp @@ -375,6 +375,18 @@ TEST_CASE("Rum argument validation", "[unit][rum][cpp-api]") { "non-empty error message"}, {}}, + // === AddLongTask() === + + {"M print warning W AddLongTask is called with non-positive duration", + [&](RumConfig& config, std::shared_ptr& core) { + with_rum(config, core, [](std::shared_ptr rum) { + rum->StartView("my-view", "My View"); + rum->AddLongTask(0); + }); + }, + {"Rum::AddLongTask call ignored: application must supply a positive duration"}, + {}}, + // === StartOperation() / SucceedOperation() / // FailOperation() // === @@ -2389,6 +2401,146 @@ TEST_CASE("Rum events", "[unit][rum][cpp-api]") { })")); }}, + // === AddLongTask() === + + {"M send long_task event W AddLongTask is called", + [](RumConfig&) { + // Given an ordinary RUM config + }, + [](std::shared_ptr& rum, MockClock& clock) { + // When we create a RUM view and then record a long task at T+5ms + rum->StartView("my-view", "My View"); + clock.TickMilliseconds(5); + rum->AddLongTask(5000000); + }, + [](const nlohmann::json& events) { + // Then we get a long_task event with the task's duration and, since it did not + // exceed the frozen-frame threshold, is_frozen_frame is false; its date is + // computed as the report time minus the task's duration + auto long_tasks = filter_events("long_task", events); + REQUIRE(long_tasks.size() == 1); + RequireEventMatch(long_tasks[0], DATADOG_RUM_EVENT_LITERAL(R"({ + "type": "long_task", + "date": 1700000000000, + "ddtags": "service:mock-service,version:mock-application-version,env:mock-env,sdk_version:1.2.3", + "os": { + "name": "MockOS", + "version": "1.0.0", + "build": "12345", + "version_major": "1" + }, + "device": { + "type": "desktop", + "name": "MockDevice", + "model": "MockModel", + "brand": "MockBrand", + "architecture": "x86_64", + "locale": "en-US", + "time_zone": "UTC" + }, + "application": { + "id": "a991ca10-4004-4004-4004-beefbeefbeef" + }, + "session": { + "id": "${__NONZERO_UUID__}", + "type": "user" + }, + "view": { + "id": "${__NONZERO_UUID__}", + "url": "my-view", + "name": "My View" + }, + "long_task": { + "id": "${__NONZERO_UUID__}", + "duration": 5000000, + "is_frozen_frame": false + }, + "_dd": { + "format_version": 2 + } + })")); + + // And we also get a view event with an incremented long_task count and no + // frozen_frame count + auto views = filter_events("view", events); + REQUIRE(views.size() == 2); + REQUIRE(views[0]["view"]["id"] == long_tasks[0]["view"]["id"]); + REQUIRE(!views[0]["view"].contains("long_task")); + REQUIRE(!views[0]["view"].contains("frozen_frame")); + REQUIRE(views[1]["view"]["id"] == long_tasks[0]["view"]["id"]); + REQUIRE(views[1]["view"]["long_task"]["count"] == 1); + REQUIRE(!views[1]["view"].contains("frozen_frame")); + }}, + + {"M send frozen_frame long_task event with action.id W AddLongTask exceeds the " + "frozen-frame threshold with an active action", + [](RumConfig&) { + // Given an ordinary RUM config + }, + [](std::shared_ptr& rum, MockClock& clock) { + // When we create a RUM view and a RUM action, and then record a long task that + // exceeds the 700ms frozen-frame threshold at T+5ms + rum->StartView("my-view", "My View"); + rum->AddAction(RumActionType::Click, "button1"); + clock.TickMilliseconds(5); + rum->AddLongTask(800000000); + }, + [](const nlohmann::json& events) { + // Then we get a long_task event marked as a frozen frame, correlated with the + // active action + auto long_tasks = filter_events("long_task", events); + REQUIRE(long_tasks.size() == 1); + RequireEventMatch(long_tasks[0], DATADOG_RUM_EVENT_LITERAL(R"({ + "type": "long_task", + "date": 1699999999205, + "ddtags": "service:mock-service,version:mock-application-version,env:mock-env,sdk_version:1.2.3", + "os": { + "name": "MockOS", + "version": "1.0.0", + "build": "12345", + "version_major": "1" + }, + "device": { + "type": "desktop", + "name": "MockDevice", + "model": "MockModel", + "brand": "MockBrand", + "architecture": "x86_64", + "locale": "en-US", + "time_zone": "UTC" + }, + "application": { + "id": "a991ca10-4004-4004-4004-beefbeefbeef" + }, + "session": { + "id": "${__NONZERO_UUID__}", + "type": "user" + }, + "view": { + "id": "${__NONZERO_UUID__}", + "url": "my-view", + "name": "My View" + }, + "action": { + "id": "${__NONZERO_UUID__}" + }, + "long_task": { + "id": "${__NONZERO_UUID__}", + "duration": 800000000, + "is_frozen_frame": true + }, + "_dd": { + "format_version": 2 + } + })")); + + // And we get a view event with both long_task and frozen_frame counts of 1 + auto views = filter_events("view", events); + REQUIRE(views.back()["view"]["id"] == long_tasks[0]["view"]["id"]); + REQUIRE(views.back()["view"]["long_task"]["count"] == 1); + REQUIRE(views.back()["view"]["frozen_frame"]["count"] == 1); + }}, + // === Action lifetime vis-a-vis resources === {"M extend discrete action lifetime W concurrent resource remains active", diff --git a/tests/impl/core/feature_types/rum_test.cpp b/tests/impl/core/feature_types/rum_test.cpp index 5ec6a15e..f576843b 100644 --- a/tests/impl/core/feature_types/rum_test.cpp +++ b/tests/impl/core/feature_types/rum_test.cpp @@ -1379,6 +1379,155 @@ Last 8 instructions at CS:EIP: } } +TEST_CASE("RumLongTaskEvent", "[unit][feature_types][rum]") { + // Given a RumLongTaskEvent initialized with the minimum set of required properties + const Timestamp date{std::chrono::nanoseconds(946684799999999999)}; + const UUID application_id = *UUID::Parse("a991ca10-4004-4004-4004-beefbeefbeef"); + const UUID session_id = *UUID::Parse("5e551017-4114-4114-4114-beeeefbeeeef"); + const RumSessionType session_type = RumSessionType::User; + const UUID view_id = *UUID::Parse("141ee144-4224-4224-4224-beeeeeeeeeef"); + const std::string_view view_url = "my-view"; + const UUID long_task_id = *UUID::Parse("dec0dec0-4004-4004-4004-beefbeefbeef"); + const int64_t long_task_duration = 123456789; + RumLongTaskEvent ev{ + date, + application_id, + session_id, + session_type, + view_id, + view_url, + long_task_id, + long_task_duration + }; + + SECTION("M produce a minimal long task event W only required values are set") { + RequireJsonObject(ev, DATADOG_RUM_EVENT_LITERAL(R"({ + "date": 946684799999, + "application": { + "id": "a991ca10-4004-4004-4004-beefbeefbeef" + }, + "session": { + "id": "5e551017-4114-4114-4114-beeeefbeeeef", + "type": "user" + }, + "view": { + "id": "141ee144-4224-4224-4224-beeeeeeeeeef", + "url": "my-view" + }, + "long_task": { + "id": "dec0dec0-4004-4004-4004-beefbeefbeef", + "duration": 123456789 + }, + "_dd": { + "format_version": 2 + }, + "type": "long_task" + })")); + } + + SECTION("M generate valid event W all supported values are set") { + // RumLongTaskEvent + ev.date = Timestamp{std::chrono::nanoseconds(1761829845132015612)}; + ev.service = "my-service"; + ev.version = "my-version"; + ev.build_version = "my-build-version"; + ev.build_id = "d2008244-7344-4313-a7df-b1c283c995c1"; + ev.ddtags = "service:my-service,env:test,foo:bar"; + ev.source = RumSource::RumCpp; + + // RumLongTaskEvent::Application + ev.application.id = *UUID::Parse("a4b9f39a-e5de-45b5-bb70-a6e616bfec6c"); + ev.application.current_locale = "en-US"; + + // RumLongTaskEvent::Session + ev.session.id = *UUID::Parse("f1f719db-ed81-4e63-9fe9-cf434c2af8e6"); + ev.session.type = RumSessionType::Synthetics; + ev.session.has_replay = true; + + // RumLongTaskEvent::View + ev.view.id = *UUID::Parse("18136cf5-e4a8-4e5c-9d65-7cab1703f17f"); + ev.view.referrer = "https://referer.referrer"; + ev.view.url = "https://example.com/yes"; + ev.view.name = "Yes!!!🙌"; + + // RumLongTaskEvent::Internal::Session + ev._dd.session.value.emplace(); + ev._dd.session.value->plan = 2; + ev._dd.session.value->session_precondition = + RumSessionPrecondition::InactivityTimeout; + + // RumLongTaskEvent::Internal::Configuration + ev._dd.configuration.value.emplace(10.0f); + ev._dd.configuration.value->session_replay_sample_rate = 5.0f; + ev._dd.configuration.value->profiling_sample_rate = 20.0f; + + // RumLongTaskEvent::Internal + ev._dd.browser_sdk_version = "3.1.2"; + + // RumLongTaskEvent::Action + ev.action.value.emplace(*UUID::Parse("4aa1315e-4cb3-4d32-90cf-a92bfd02c38c")); + + // RumLongTaskEvent::LongTask + ev.long_task.id = *UUID::Parse("e1313013-4554-4554-4554-bbbbbb00eeff"); + ev.long_task.duration = 987654321; + ev.long_task.is_frozen_frame = true; + + // Custom user attributes (RumLongTaskEvent::context) + ev.context.value.InitObject(1); + ev.context.value.SetObjectProperty("foo", Attribute::Int(100)); + + RequireJsonObject(ev, DATADOG_RUM_EVENT_LITERAL(R"({ + "type": "long_task", + "date": 1761829845132, + "service": "my-service", + "version": "my-version", + "build_version": "my-build-version", + "build_id": "d2008244-7344-4313-a7df-b1c283c995c1", + "ddtags": "service:my-service,env:test,foo:bar", + "source": "rum-cpp", + "application": { + "id": "a4b9f39a-e5de-45b5-bb70-a6e616bfec6c", + "current_locale": "en-US" + }, + "session": { + "id": "f1f719db-ed81-4e63-9fe9-cf434c2af8e6", + "type": "synthetics", + "has_replay": true + }, + "view": { + "id": "18136cf5-e4a8-4e5c-9d65-7cab1703f17f", + "referrer": "https://referer.referrer", + "url": "https://example.com/yes", + "name": "Yes!!!🙌" + }, + "action": { + "id": "4aa1315e-4cb3-4d32-90cf-a92bfd02c38c" + }, + "long_task": { + "id": "e1313013-4554-4554-4554-bbbbbb00eeff", + "duration": 987654321, + "is_frozen_frame": true + }, + "context": { + "foo": 100 + }, + "_dd": { + "format_version": 2, + "session": { + "plan": 2, + "session_precondition": "inactivity_timeout" + }, + "configuration": { + "session_sample_rate": 10, + "session_replay_sample_rate": 5, + "profiling_sample_rate": 20 + }, + "browser_sdk_version": "3.1.2" + } + })")); + } +} + TEST_CASE("RumVitalEvent", "[unit][feature_types][rum]") { // Given a RumVitalEvent initialized with the minimum set of required properties const Timestamp date{std::chrono::nanoseconds(946684799999999999)}; diff --git a/tests/impl/rum/scopes/action_test.cpp b/tests/impl/rum/scopes/action_test.cpp index 74b25728..4db442af 100644 --- a/tests/impl/rum/scopes/action_test.cpp +++ b/tests/impl/rum/scopes/action_test.cpp @@ -370,6 +370,32 @@ TEST_CASE_METHOD(ActionFixture, "RumActionScope::Process", "[unit][rum]") { REQUIRE(event["action"]["error"]["count"] == 1); REQUIRE(event["action"]["loading_time"] == 10000000); } + + SECTION("M increment long_task count W long task is reported") { + // Given an active RumActionScope + + // When we process AddLongTask + auto result = scope.Process( + RumCommand::AddLongTask(GetBaseParams(), std::chrono::milliseconds(50)), + GetTestContext(), + GetTestWriter() + ); + REQUIRE(result == RumScopeResult::RemainOpen); + + // And we then process StopAction to explicitly end the action 10ms later + clock.TickMilliseconds(10); + result = scope.Process( + RumCommand::StopAction(GetBaseParams(), ""), GetTestContext(), GetTestWriter() + ); + REQUIRE(result == RumScopeResult::Close); + auto actions = event_capture.Actions(); + REQUIRE(actions.size() == 1); + + // Then we get an action event that has a long_task count of 1 + const auto& event = actions.front(); + REQUIRE(event["action"]["long_task"]["count"] == 1); + REQUIRE(event["action"]["loading_time"] == 10000000); + } } TEST_CASE("RumActionScope::PopulateContext", "[unit][rum]") { From 67117400a3c1df983ab97f806c0e007a5da4e872 Mon Sep 17 00:00:00 2001 From: Jeff Ward Date: Thu, 9 Jul 2026 16:34:30 -0400 Subject: [PATCH 2/2] feat: Add long task reporting to the REPL. --- examples/repl/commands.hpp | 1 + examples/repl/commands_rum.cpp | 28 ++++++++++++++++++++++++++++ examples/repl/main.cpp | 3 +++ 3 files changed, 32 insertions(+) diff --git a/examples/repl/commands.hpp b/examples/repl/commands.hpp index dc3735a9..b2bc01c3 100644 --- a/examples/repl/commands.hpp +++ b/examples/repl/commands.hpp @@ -53,6 +53,7 @@ CommandResult HandleStartResource(State& state, const CommandInput& args); CommandResult HandleStopResource(State& state, const CommandInput& args); CommandResult HandleStopResourceWithError(State& state, const CommandInput& args); CommandResult HandleAddError(State& state, const CommandInput& args); +CommandResult HandleAddLongTask(State& state, const CommandInput& args); CommandResult HandleStartOperation(State& state, const CommandInput& args); CommandResult HandleSucceedOperation(State& state, const CommandInput& args); CommandResult HandleFailOperation(State& state, const CommandInput& args); diff --git a/examples/repl/commands_rum.cpp b/examples/repl/commands_rum.cpp index b8a1ebb4..bad90d1a 100644 --- a/examples/repl/commands_rum.cpp +++ b/examples/repl/commands_rum.cpp @@ -4,6 +4,7 @@ // This product includes software developed at Datadog (https://www.datadoghq.com/). // Copyright 2025-Present Datadog, Inc. +#include #include #include "datadog.hpp" @@ -467,6 +468,33 @@ CommandResult HandleAddError(State& state, const CommandInput& args) { return CommandResult::OK("Rum::AddError()"); } +CommandResult HandleAddLongTask(State& state, const CommandInput& args) { + // RUM must be registered and SDK must be running + if (!state.rum) { + return CommandResult::Error("RUM is not registered!"); + } + if (!state.started) { + return CommandResult::Error("SDK is not running!"); + } + + // Positional args + auto pos = args.Positional(); + auto duration_str = pos[0]; + if (duration_str.empty()) { + return CommandResult::Error("No duration given!"); + } + uint64_t duration_ns{0}; + auto res = std::from_chars( + duration_str.data(), duration_str.data() + duration_str.size(), duration_ns + ); + if (res.ec != std::errc{}) { + return CommandResult::Error("Duration must be a non-negative integer!"); + } + + state.rum->AddLongTask(duration_ns); + return CommandResult::OK("Rum::AddLongTask()"); +} + CommandResult HandleStartOperation(State& state, const CommandInput& args) { // RUM must be registered and SDK must be running if (!state.rum) { diff --git a/examples/repl/main.cpp b/examples/repl/main.cpp index 50fc8f66..a9fa15c4 100644 --- a/examples/repl/main.cpp +++ b/examples/repl/main.cpp @@ -134,6 +134,9 @@ CommandResult Handle(State& state, const CommandInput& input) { if (input.Peek() == "add-error") { return HandleAddError(state, input.Shift()); } + if (input.Peek() == "add-long-task") { + return HandleAddLongTask(state, input.Shift()); + } if (input.Peek() == "start-operation") { return HandleStartOperation(state, input.Shift()); }