Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
33 changes: 30 additions & 3 deletions packages/datadog_common_test/lib/src/decoders/rum_decoder.dart
Original file line number Diff line number Diff line change
Expand Up @@ -82,9 +82,14 @@ class RumSessionDecoder {
visit.longTaskEvents.add(longTaskEvent);
break;
case 'vital':
final operationStepEvent =
RumVitalOperationStepEventDecoder(e.rumEvent);
visit.vitalStepEvents.add(operationStepEvent);
if (RumVitalAppLaunchEventDecoder.isAppLaunchVital(e.rumEvent)) {
final appLaunchEvent = RumVitalAppLaunchEventDecoder(e.rumEvent);
visit.appLaunchVitalEvents.add(appLaunchEvent);
} else {
final operationStepEvent =
RumVitalOperationStepEventDecoder(e.rumEvent);
visit.vitalStepEvents.add(operationStepEvent);
}
break;
}
}
Expand All @@ -109,6 +114,7 @@ class RumViewVisit {
final List<RumErrorEventDecoder> errorEvents = [];
final List<RumLongTaskEventDecoder> longTaskEvents = [];
final List<RumVitalOperationStepEventDecoder> vitalStepEvents = [];
final List<RumVitalAppLaunchEventDecoder> appLaunchVitalEvents = [];

RumViewVisit(this.id, this.name, this.path);
}
Expand Down Expand Up @@ -361,3 +367,24 @@ class RumVitalOperationStepEventDecoder extends RumEventDecoder {
rumEvent['vital']['failure_reason'] as String?;
String get stepType => rumEvent['vital']['step_type'] as String;
}

/// Decodes app launch vitals, which carry the app launch metrics (`ttid` and
/// `ttfd`) rather than the operation step properties.
class RumVitalAppLaunchEventDecoder extends RumEventDecoder {
RumVitalAppLaunchEventDecoder(super.rumEvent);

static bool isAppLaunchVital(Map<String, dynamic> rumEvent) {
final vital = rumEvent['vital'];
return vital is Map && vital['type'] == 'app_launch';
}

RumViewInfoDecoder get view => RumViewInfoDecoder(rumEvent['view']);

String get appLaunchMetric =>
rumEvent['vital']['app_launch_metric'] as String;

/// Duration of the app launch metric, in nanoseconds.
int get duration => (rumEvent['vital']['duration'] as num).toInt();

String? get startupType => rumEvent['vital']['startup_type'] as String?;
}
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ class DatadogRumPlugin : MethodChannel.MethodCallHandler {
"stopView" -> stopView(call, result)
"addTiming" -> addTiming(call, result)
"addViewLoadingTime" -> addViewLoadingTime(call, result)
"reportAppFullyDisplayed" -> reportAppFullyDisplayed(call, result)
"startResource" -> startResource(call, result)
"stopResource" -> stopResource(call, result)
"stopResourceWithError" -> stopResourceWithError(call, result)
Expand Down Expand Up @@ -291,6 +292,11 @@ class DatadogRumPlugin : MethodChannel.MethodCallHandler {
}
}

private fun reportAppFullyDisplayed(call: MethodCall, result: Result) {
rum?.reportAppFullyDisplayed()
result.success(null)
}

private fun startResource(call: MethodCall, result: Result) {
val key = call.argument<String>(PARAM_KEY)
val url = call.argument<String>(PARAM_URL)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -593,6 +593,21 @@ class DatadogRumPluginTest {
verify { mockResult.success(null) }
}

@Test
fun `M call monitor reportAppFullyDisplayed W reportAppFullyDisplayed is called`() {
// GIVEN
val call = MethodCall("reportAppFullyDisplayed", mapOf<String, Any?>())
val mockResult = mockk<MethodChannel.Result>()
every { mockResult.success(any()) } returns Unit

// WHEN
plugin.onMethodCall(call, mockResult)

// THEN
verify { monitorProxy.mockMonitor.reportAppFullyDisplayed() }
verify { mockResult.success(null) }
}

@Test
fun `M call monitor startResource W startResource is called`(
@StringForgery resourceKey: String,
Expand Down Expand Up @@ -1066,7 +1081,8 @@ class DatadogRumPluginTest {
"failureReason" to ContractParameter.Type(SupportedContractType.STRING),
"attributes" to ContractParameter.Type(SupportedContractType.MAP)
)),
Contract("stopSession", mapOf())
Contract("stopSession", mapOf()),
Contract("reportAppFullyDisplayed", mapOf())
)

@Test
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -196,7 +196,8 @@ class DatadogRumPluginTests: XCTestCase {
"failureReason": .string,
"attributes": .map
]),
Contract(methodName: "stopSession", requiredParameters: [:])
Contract(methodName: "stopSession", requiredParameters: [:]),
Contract(methodName: "reportAppFullyDisplayed", requiredParameters: [:])
]

func testRumPlugin_ContractViolationsThrowErrors() {
Expand Down Expand Up @@ -382,6 +383,18 @@ class DatadogRumPluginTests: XCTestCase {
XCTAssertEqual(resultStatus, .called(value: nil))
}

func testReportAppFullyDisplayed_CallsRumMonitor() {
let call = FlutterMethodCall(methodName: "reportAppFullyDisplayed", arguments: [:] as [String: Any?])

var resultStatus = ResultStatus.notCalled
plugin.handle(call) { result in
resultStatus = .called(value: result)
}

XCTAssertEqual(mock.callLog, [ .reportAppFullyDisplayed ])
XCTAssertEqual(resultStatus, .called(value: nil))
}

func testStartResource_CallsRumMonitor() {
let call = FlutterMethodCall(methodName: "startResource", arguments: [
"key": "resource_key",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,33 @@ void main() {
expect(view1.vitalStepEvents[2].vitalOperationKey, isNull);
expect(view1.vitalStepEvents[2].vitalFailureReason, 'error');

// `reportAppFullyDisplayed` reports TTFD as an app launch vital rather than
// as a property of the view it was called from, so look for it across the
// whole session.
//
// This is only checked on iOS. The Browser SDK has no equivalent API, and
// the Android SDK only sends TTFD once it has computed TTID for the startup
// scenario, which does not happen in this app -- it sends no app launch
// vitals at all, so there is nothing to assert on there yet.
if (!kIsWeb && Platform.isIOS) {
Comment on lines +194 to +198

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.

If that's the case, we may want to hold off on this change. I know we're in the process of looking into TTID for Android. Let me look into the state of it and I'll get back to you.

final ttfdVitals = rumLog
.where((e) =>
e.eventType == 'vital' &&
RumVitalAppLaunchEventDecoder.isAppLaunchVital(e.rumEvent))
.map((e) => RumVitalAppLaunchEventDecoder(e.rumEvent))
.where((e) => e.appLaunchMetric == 'ttfd')
.toList();
Comment on lines +199 to +205

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'll likely refactor this to have all vitals accessible on the session but for now this is fine.


// Only the first call to `reportAppFullyDisplayed` is reported.
expect(ttfdVitals.length, 1);
// TTFD is measured from the launch of the app, so it should be at least
// as long as the fake loading the scenario performs before reporting it.
expect(ttfdVitals[0].duration,
greaterThanOrEqualTo(const Duration(milliseconds: 50).inNanoseconds));
expect(ttfdVitals[0].duration,
lessThan(const Duration(seconds: 60).inNanoseconds));
Comment on lines +213 to +214

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.

We'll see if this is enough for CI or if it flakes 😅.

}

// Verify user in all events, except for the first view event
for (final viewEvent in view1.viewEvents.sublist(1)) {
verifyUser(viewEvent);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ class _RumManualInstrumentationScenarioState
await Future<void>.delayed(const Duration(milliseconds: 50));
DatadogSdk.instance.rum?.addTiming('content-ready');
DatadogSdk.instance.rum?.addViewLoadingTime();
DatadogSdk.instance.rum?.reportAppFullyDisplayed();

Comment thread
brunovsiqueira marked this conversation as resolved.
DatadogSdk.instance.setUserInfo(
id: 'fake-id',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,9 @@ public class DatadogRumPlugin: NSObject, FlutterPlugin {
FlutterError.missingParameter(methodName: call.method)
)
}
case "reportAppFullyDisplayed":
rum?.reportAppFullyDisplayed()
result(nil)
case "startResource":
if let key = arguments["key"] as? String,
let methodString = arguments["httpMethod"] as? String,
Expand Down
14 changes: 14 additions & 0 deletions packages/datadog_flutter_plugin/lib/src/rum/ddrum.dart
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,20 @@ class DatadogRum {
});
}

/// Marks the moment when the UI of the app is considered fully displayed.
/// The duration between the launch of the app and this call is reported as
/// the time to full display (TTFD) of the app launch.
///
/// Only the first call to this method has any effect for a given RUM
/// session.
///
/// *Note*: This API is experimental and may change in the future.
void reportAppFullyDisplayed() {
wrap('rum.reportAppFullyDisplayed', logger, null, () {
return _platform.reportAppFullyDisplayed();
});
}

/// Notifies that the Exception or Error [error] occurred in currently
/// presented View, with an origin of [source]. You can optionally set
/// additional [attributes] for this error, an [errorType] and a
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,14 @@ class DdRumMethodChannel extends DdRumPlatform {
});
}

@override
Future<void> reportAppFullyDisplayed() {
return methodChannel.invokeMethod(
'reportAppFullyDisplayed',
<String, Object?>{},

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.

nit: We tend to add the type annotations on these maps, especially if they're empty.

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.

The map is already <String, Object?>{}, same as stopSession below. Did you mean invokeMethod<void>?

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.

Sorry, I missed a "not" in there. We tend to not add the type annotations on these maps. It slipped in on stopSession, and not a big deal so I'm not going to worry about it.

);
}

@override
Future<void> addErrorInfo(
DateTime timestamp,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,11 @@ class DdNoOpRumPlatform extends DdRumPlatform {
return Future.value();
}

@override
Future<void> reportAppFullyDisplayed() {
return Future.value();
}

@override
Future<void> addAction(
DateTime timestamp,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ abstract class DdRumPlatform extends PlatformInterface {
);
Future<void> addTiming(DateTime timestamp, String name);
Future<void> addViewLoadingTime(bool overwrite);
Future<void> reportAppFullyDisplayed();

Future<void> startResource(
DateTime timestamp,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,11 @@ class DdRumWeb extends DdRumPlatform {
// NOOP - Not supported by the Browser SDK
}

@override
Future<void> reportAppFullyDisplayed() async {
// NOOP - Not supported by the Browser SDK
}

@override
Future<void> addAction(
DateTime timestamp,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,14 @@ void main() {
]);
});

test('reportAppFullyDisplayed calls to platform', () async {
await ddRumPlatform.reportAppFullyDisplayed();

expect(log, [
isMethodCall('reportAppFullyDisplayed', arguments: <String, Object?>{}),
]);
});

test('startResource calls to platform', () async {
final timestamp = randomTimestamp();
await ddRumPlatform.startResource(
Expand Down