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()); } 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]") {