diff --git a/.github/workflows/publish-pub-dev.yml b/.github/workflows/publish-pub-dev.yml index e97a7232c..236dd8ece 100644 --- a/.github/workflows/publish-pub-dev.yml +++ b/.github/workflows/publish-pub-dev.yml @@ -7,6 +7,13 @@ name: Publish to pub.dev # flashcat_flutter_plugin -> flashcat_flutter_plugin-v{{version}} # flashcat_webview_tracking -> flashcat_webview_tracking-v{{version}} # flashcat_tracking_http_client -> flashcat_tracking_http_client-v{{version}} +# +# Release order matters. flashcat_webview_tracking and +# flashcat_tracking_http_client depend on flashcat_flutter_plugin from pub.dev +# (there are no dependency_overrides), so whenever a release bumps that +# constraint, push flashcat_flutter_plugin-v{{version}} first and wait for it to +# publish before tagging the other two. The "Ensure the flashcat_flutter_plugin +# dependency is published" step below enforces this. on: push: tags: @@ -78,6 +85,28 @@ jobs: exit 1 fi + # `flutter pub get` below resolves flashcat_flutter_plugin from pub.dev, so + # a dependent package tagged before it fails with an opaque version-solving + # error. Check the constraint's lower bound up front instead. + - name: Ensure the flashcat_flutter_plugin dependency is published + if: steps.package.outputs.name != 'flashcat_flutter_plugin' + working-directory: ${{ steps.package.outputs.dir }} + env: + PACKAGE_NAME: ${{ steps.package.outputs.name }} + run: | + required=$(grep -E "^[[:space:]]+flashcat_flutter_plugin:[[:space:]]*\^?[0-9]" pubspec.yaml \ + | head -1 | sed -E 's/.*[^0-9]([0-9]+\.[0-9]+\.[0-9]+).*/\1/') + if [ -z "$required" ]; then + echo "No pinned flashcat_flutter_plugin dependency found; nothing to check." + exit 0 + fi + if ! curl -sfo /dev/null "https://pub.dev/api/packages/flashcat_flutter_plugin/versions/${required}"; then + echo "flashcat_flutter_plugin ${required} is not on pub.dev yet, so ${PACKAGE_NAME} cannot resolve it." >&2 + echo "Push the flashcat_flutter_plugin-v${required} tag and let it publish first, then re-run this release." >&2 + exit 1 + fi + echo "flashcat_flutter_plugin ${required} is published." + # setup-dart performs the OIDC temporary-token exchange with pub.dev - uses: dart-lang/setup-dart@v1 diff --git a/packages/datadog_flutter_plugin/CHANGELOG.md b/packages/datadog_flutter_plugin/CHANGELOG.md index cd06e8a33..24823c509 100644 --- a/packages/datadog_flutter_plugin/CHANGELOG.md +++ b/packages/datadog_flutter_plugin/CHANGELOG.md @@ -1,5 +1,20 @@ # Changelog +## 0.2.0 + +* Add opt-in RUM remote configuration through + `remoteConfigurationEnabled`, which defaults to `false`. +* Add `beforeSampling` so applications can synchronously override the sample + rate for each new session. The callback also runs when remote configuration + is disabled; in that case it receives the local sample rate and no custom + values. +* Add `DatadogRum.setForcedSession()` and + `DatadogRum.getRemoteConfig()`. Forced collection lasts for the current + process and does not imply Session Replay support in Flutter. +* Upgrade the native FlashCat SDKs to Android 0.7.0 and iOS 0.6.0. The Android + upgrade includes the default NTP endpoint change introduced in Android + 0.6.0. + ## 0.1.3 * Ship the OkHttp TLS-provider `-dontwarn` rules in the plugin's consumer diff --git a/packages/datadog_flutter_plugin/NATIVE_SDK_VERSIONS.md b/packages/datadog_flutter_plugin/NATIVE_SDK_VERSIONS.md index 3496581ec..ece1acc3b 100644 --- a/packages/datadog_flutter_plugin/NATIVE_SDK_VERSIONS.md +++ b/packages/datadog_flutter_plugin/NATIVE_SDK_VERSIONS.md @@ -1,5 +1,6 @@ | Flutter | iOS SDK | Android SDK | |---------|---------|-------------| +| 0.2.0 | 0.6.0 | 0.7.0 | | 3.0.1 | 3.4.0 | 3.5.0 | | 3.0.0 | 3.4.0 | 3.5.0 | | 2.16.1 | 2.30.1 | 2.26.1 | diff --git a/packages/datadog_flutter_plugin/README.md b/packages/datadog_flutter_plugin/README.md index 1e7d1d7ee..604a7f90b 100644 --- a/packages/datadog_flutter_plugin/README.md +++ b/packages/datadog_flutter_plugin/README.md @@ -13,7 +13,7 @@ This SDK is forked from the [Datadog Flutter SDK](https://github.com/DataDog/dd- - **Package name**: Published as `flashcat_flutter_plugin` and `flashcat_webview_tracking`; imports use `package:flashcat_flutter_plugin/…`. Only the published package name changes — internal Dart/Kotlin/Swift namespaces remain `datadog*`. - **Native dependencies**: Uses the FlashCat forks — iOS `Flashcat*` pods / `fc-sdk-ios` (SPM), Android `cloud.flashcat:*`. - **v1 scope**: iOS and Android only; the Flutter Web target is dropped. -- **Not yet available**: `Logs` (the API is a no-op — FlashCat ingest does not accept Logs yet), Session Replay, automatic HTTP/resource tracking (`datadog_tracking_http_client`), the dio/gql/grpc integrations, and Feature Flags. +- **Not yet available**: `Logs` (the API is a no-op — FlashCat ingest does not accept Logs yet), Session Replay, the dio/gql/grpc integrations, and Feature Flags. HTTP/resource tracking is available through the companion `flashcat_tracking_http_client` package. --- @@ -27,7 +27,7 @@ This release requires Flutter 3.27+ and supports iOS and Android only. | iOS SDK | Android SDK | | :-----: | :---------: | -| 0.5.0 | 0.4.1 | +| 0.6.0 | 0.7.0 | ### iOS @@ -62,6 +62,57 @@ final configuration = DatadogConfiguration( For more information on available configuration options, see the [DatadogConfiguration object][8] documentation. +### Remote RUM configuration + +Remote configuration is opt-in and is disabled by default. Enable it when you +want the native Android or iOS SDK to retrieve the RUM session sampling rate +and custom values published for the application: + +```dart +rumConfiguration: DatadogRumConfiguration( + applicationId: '', + remoteConfigurationEnabled: true, + beforeSampling: (context) { + final debugUsers = context.custom?['debugUsers']; + if (debugUsers is List && debugUsers.contains(currentUserId)) { + return 100; + } + return null; // Keep the native SDK's sampling rate. + }, +) +``` + +`beforeSampling` runs for every new session even when remote configuration is +disabled. In that case, `sessionSampleRate` is the local value and `custom` is +`null`. Returning `null`, throwing, timing out, or returning a value outside +`0...100` keeps the native SDK's sampling decision. + +The current process can be switched permanently to forced collection, and the +latest custom values can be read at runtime: + +```dart +DatadogSdk.instance.rum?.setForcedSession(); +final custom = await DatadogSdk.instance.rum?.getRemoteConfig(); +``` + +Forced collection cannot be reverted until the process restarts. It reports a +session sample rate of 100 without a remote-configuration version and does not +enable Session Replay in Flutter. `getRemoteConfig()` returns only the +console's `custom` object. Treat it as public information and never store +secrets in it. + +The `custom` values from `getRemoteConfig()` and +`RumBeforeSamplingContext.custom` cross the platform channel with the type +fidelity each native SDK provides, so a number published in the console can +arrive as either an `int` or a `double` depending on the platform. Read numbers +as `num` (`(context.custom?['threshold'] as num?)?.toDouble()`) rather than +testing for `int` or `double`. + +When attaching Flutter to an already initialized native SDK, +`remoteConfigurationEnabled` and `beforeSampling` must have been configured by +the native application during RUM initialization. The two runtime methods can +still operate on the existing RUM monitor. + ### Initialize the library You can initialize RUM using one of two methods in the `main.dart` file. diff --git a/packages/datadog_flutter_plugin/analysis_options.yaml b/packages/datadog_flutter_plugin/analysis_options.yaml index fa898be9d..ed36d0fa9 100644 --- a/packages/datadog_flutter_plugin/analysis_options.yaml +++ b/packages/datadog_flutter_plugin/analysis_options.yaml @@ -9,6 +9,13 @@ analyzer: exclude: - "**/*.mocks.dart" - "**/*.g.dart" + - build/** + - android/** + - ios/** + - web/** + - windows/** + - macos/** + - linux/** language: strict-raw-types: true diff --git a/packages/datadog_flutter_plugin/android/build.gradle b/packages/datadog_flutter_plugin/android/build.gradle index 2674b87c2..50b9e241d 100644 --- a/packages/datadog_flutter_plugin/android/build.gradle +++ b/packages/datadog_flutter_plugin/android/build.gradle @@ -10,7 +10,7 @@ version "1.0-SNAPSHOT" buildscript { ext.kotlin_version = "2.1.0" - ext.datadog_version = "0.5.0" + ext.datadog_version = "0.7.0" repositories { google() diff --git a/packages/datadog_flutter_plugin/android/src/main/kotlin/com/datadoghq/flutter/DatadogRumEventMapper.kt b/packages/datadog_flutter_plugin/android/src/main/kotlin/com/datadoghq/flutter/DatadogRumEventMapper.kt index 48a42a8e9..7c22cd1b5 100644 --- a/packages/datadog_flutter_plugin/android/src/main/kotlin/com/datadoghq/flutter/DatadogRumEventMapper.kt +++ b/packages/datadog_flutter_plugin/android/src/main/kotlin/com/datadoghq/flutter/DatadogRumEventMapper.kt @@ -1,5 +1,7 @@ package com.datadoghq.flutter +import android.util.Log +import com.datadog.android.rum.BeforeSamplingContext import com.datadog.android.rum.ExperimentalRumApi import com.datadog.android.rum.RumConfiguration import com.datadog.android.rum.model.ActionEvent @@ -8,6 +10,7 @@ import com.datadog.android.rum.model.LongTaskEvent import com.datadog.android.rum.model.ResourceEvent import com.datadog.android.rum.model.RumVitalOperationStepEvent import com.datadog.android.rum.model.ViewEvent +import com.google.gson.Gson import com.google.gson.JsonParser /** @@ -29,6 +32,7 @@ class DatadogRumEventMapper { fun mapErrorEvent(encodedEvent: String): String? fun mapLongTaskEvent(encodedEvent: String): String? fun mapVitalOperationStepEvent(encodedEvent: String): String? + fun beforeSampling(encodedContext: String): String? } var eventMapper: EventMapper? = null @@ -64,10 +68,30 @@ class DatadogRumEventMapper { } ) } + if (optionIsSet("attachBeforeSampling")) { + configBuilder.setBeforeSampling { context -> beforeSampling(context) } + } return configBuilder } + internal fun beforeSampling(context: BeforeSamplingContext): Float? { + val encodedContext = try { + Gson().toJson( + mapOf( + "sessionSampleRate" to context.sessionSampleRate, + "custom" to context.custom?.sanitizeForFlutter() + ) + ) + } catch (error: RuntimeException) { + Log.w(DATADOG_FLUTTER_TAG, "Unable to encode beforeSampling context.", error) + return null + } + val encodedResult = eventMapper?.beforeSampling(encodedContext) ?: return null + val result = encodedResult.toFloatOrNull() ?: return null + return result.takeIf { it.isFinite() && it in 0f..100f } + } + internal fun mapViewEvent(event: ViewEvent): ViewEvent { val result: ViewEvent = event diff --git a/packages/datadog_flutter_plugin/android/src/main/kotlin/com/datadoghq/flutter/DatadogRumPlugin.kt b/packages/datadog_flutter_plugin/android/src/main/kotlin/com/datadoghq/flutter/DatadogRumPlugin.kt index 390edbb4b..f99f2e759 100644 --- a/packages/datadog_flutter_plugin/android/src/main/kotlin/com/datadoghq/flutter/DatadogRumPlugin.kt +++ b/packages/datadog_flutter_plugin/android/src/main/kotlin/com/datadoghq/flutter/DatadogRumPlugin.kt @@ -136,6 +136,8 @@ class DatadogRumPlugin : MethodChannel.MethodCallHandler { when (call.method) { "deinitialize" -> deinitialize(call, result) "getCurrentSessionId" -> getCurrentSessionId(call, result) + "setForcedSession" -> setForcedSession(call, result) + "getRemoteConfig" -> getRemoteConfig(call, result) "startView" -> startView(call, result) "stopView" -> stopView(call, result) "addTiming" -> addTiming(call, result) @@ -245,6 +247,15 @@ class DatadogRumPlugin : MethodChannel.MethodCallHandler { } } + private fun setForcedSession(call: MethodCall, result: Result) { + rum?.setForcedSession() + result.success(null) + } + + private fun getRemoteConfig(call: MethodCall, result: Result) { + result.success(rum?.getRemoteConfig()?.sanitizeForFlutter()) + } + private fun mapTelemetryConfiguration( event: TelemetryConfigurationEvent ): TelemetryConfigurationEvent { @@ -628,6 +639,9 @@ fun RumConfiguration.Builder.withEncoded(encoded: Map): RumConfigu (encoded["sessionSampleRate"] as? Number)?.let { builder = builder.setSessionSampleRate(it.toFloat()) } + (encoded["remoteConfigurationEnabled"] as? Boolean)?.let { + builder = builder.setRemoteConfigurationEnabled(it) + } (encoded["longTaskThreshold"] as? Number)?.let { builder = builder.trackLongTasks((it.toFloat() * 1000).toLong()) } @@ -666,6 +680,60 @@ fun RumConfiguration.Builder.withEncoded(encoded: Map): RumConfigu return builder } +internal fun Map.sanitizeForFlutter(): Map { + return buildMap { + this@sanitizeForFlutter.forEach { (key, value) -> + val sanitized = sanitizeValueForFlutter(value) + if (sanitized !== UnsupportedFlutterValue) { + put(key, sanitized) + } + } + } +} + +private object UnsupportedFlutterValue + +private fun sanitizeValueForFlutter(value: Any?): Any? { + return when (value) { + null, + is Boolean, + is String, + is Byte, + is Short, + is Int, + is Long, + is Float, + is Double -> value + is Map<*, *> -> buildMap { + value.forEach { (key, item) -> + if (key is String) { + val sanitized = sanitizeValueForFlutter(item) + if (sanitized !== UnsupportedFlutterValue) { + put(key, sanitized) + } + } else { + Log.w(DATADOG_FLUTTER_TAG, "Dropping a remote config entry with a non-string key.") + } + } + } + is Iterable<*> -> buildList { + value.forEach { item -> + val sanitized = sanitizeValueForFlutter(item) + if (sanitized !== UnsupportedFlutterValue) { + add(sanitized) + } + } + } + else -> { + Log.w( + DATADOG_FLUTTER_TAG, + "Dropping unsupported remote config value of type ${value.javaClass.name}." + ) + UnsupportedFlutterValue + } + } +} + fun parseRumHttpMethod(value: String): RumResourceMethod { return when (value) { "RumHttpMethod.get" -> RumResourceMethod.GET diff --git a/packages/datadog_flutter_plugin/android/src/test/kotlin/com/datadoghq/flutter/DatadogRumPluginTest.kt b/packages/datadog_flutter_plugin/android/src/test/kotlin/com/datadoghq/flutter/DatadogRumPluginTest.kt index 5da9e2668..e3ae0fc0b 100644 --- a/packages/datadog_flutter_plugin/android/src/test/kotlin/com/datadoghq/flutter/DatadogRumPluginTest.kt +++ b/packages/datadog_flutter_plugin/android/src/test/kotlin/com/datadoghq/flutter/DatadogRumPluginTest.kt @@ -10,6 +10,7 @@ import assertk.assertThat import assertk.assertions.isEqualTo import assertk.assertions.isNotNull import com.datadog.android.Datadog +import com.datadog.android.rum.BeforeSamplingContext import com.datadog.android.rum.GlobalRumMonitor import com.datadog.android.rum.Rum import com.datadog.android.rum.RumActionType @@ -189,6 +190,7 @@ class DatadogRumPluginTest { val trackNonFatalAnrs = forge.aNullable { forge.aBool() } val trackAnonymousUser = forge.aBool() val trackBackgroundEvents = forge.aBool() + val remoteConfigurationEnabled = forge.aBool() val attributes = forge.exhaustiveAttributes() val configArg = mapOf( "sessionSampleRate" to sessionSampleRate, @@ -197,6 +199,7 @@ class DatadogRumPluginTest { "trackNonFatalAnrs" to trackNonFatalAnrs, "trackAnonymousUser" to trackAnonymousUser, "trackBackgroundEvents" to trackBackgroundEvents, + "remoteConfigurationEnabled" to remoteConfigurationEnabled, "initialResourceThreshold" to initialResourceThreshold, "customEndpoint" to endpoint, "vitalsUpdateFrequency" to "VitalsFrequency.frequent", @@ -221,6 +224,8 @@ class DatadogRumPluginTest { } assertThat(featureConfiguration.getPrivate("trackAnonymousUser")).isEqualTo(trackAnonymousUser) assertThat(featureConfiguration.getPrivate("backgroundEventTracking")).isEqualTo(trackBackgroundEvents) + assertThat(featureConfiguration.getPrivate("remoteConfigurationEnabled")) + .isEqualTo(remoteConfigurationEnabled) val initialResourceIdentifier = featureConfiguration.getPrivate("initialResourceIdentifier") as? TimeBasedInitialResourceIdentifier assertThat(initialResourceIdentifier).isNotNull() // The threshold is converted to configured in milliseconds, but held in nanoseconds. @@ -233,6 +238,75 @@ class DatadogRumPluginTest { assertThat(featureConfiguration.getPrivate("additionalConfig")).isEqualTo(attributes) } + @Test + fun `M keep remote configuration disabled W encoded field is missing`( + forge: Forge + ) { + val config = RumConfiguration.Builder(forge.aString()) + .withEncoded(emptyMap()) + .build() + + val featureConfiguration: Any = config.getFieldValue("featureConfiguration") + assertThat(featureConfiguration.getPrivate("remoteConfigurationEnabled")).isEqualTo(false) + } + + @Test + fun `M call Flutter beforeSampling W native callback is invoked`() { + val mapper = DatadogRumEventMapper() + val callback = mockk() + every { callback.beforeSampling(any()) } returns "42.5" + mapper.eventMapper = callback + + val result = mapper.beforeSampling( + BeforeSamplingContext( + sessionSampleRate = 25f, + custom = mapOf( + "debugUsers" to listOf("user-a"), + "list" to listOf("one", Any(), 2), + "unsupported" to Any() + ) + ) + ) + + assertThat(result).isEqualTo(42.5f) + verify { + callback.beforeSampling( + match { + it.contains("\"sessionSampleRate\":25.0") && + it.contains("\"debugUsers\":[\"user-a\"]") && + it.contains("\"list\":[\"one\",2]") && + !it.contains("unsupported") + } + ) + } + } + + @Test + fun `M attach beforeSampling W attachBeforeSampling is true`(forge: Forge) { + val config = DatadogRumEventMapper() + .attachMappers( + mapOf("attachBeforeSampling" to true), + RumConfiguration.Builder(forge.aString()) + ) + .build() + + val featureConfiguration: Any = config.getFieldValue("featureConfiguration") + assertThat(featureConfiguration.getPrivate("beforeSampling")).isNotNull() + } + + @Test + fun `M ignore invalid Flutter beforeSampling result`() { + val mapper = DatadogRumEventMapper() + val callback = mockk() + mapper.eventMapper = callback + val context = BeforeSamplingContext(sessionSampleRate = 25f, custom = null) + + listOf("not-a-rate", "NaN", "-1", "101").forEach { encodedResult -> + every { callback.beforeSampling(any()) } returns encodedResult + assertThat(mapper.beforeSampling(context)).isEqualTo(null) + } + } + @Test fun `M return invalidOperation W method called { !enabled }`() { //GIVEN @@ -1035,6 +1109,45 @@ class DatadogRumPluginTest { } } + @Test + fun `M call monitor setForcedSession W setForcedSession is called`() { + val call = MethodCall("setForcedSession", emptyMap()) + val mockResult = mockk() + every { mockResult.success(any()) } returns Unit + + plugin.onMethodCall(call, mockResult) + + verify { monitorProxy.mockMonitor.setForcedSession() } + verify { mockResult.success(null) } + } + + @Test + fun `M return sanitized custom values W getRemoteConfig is called`() { + val remoteConfig = mapOf( + "enabled" to true, + "nested" to mapOf("sampleRate" to 25.0), + "list" to listOf("one", null, Any(), 2), + "unsupported" to Any() + ) + every { monitorProxy.mockMonitor.getRemoteConfig() } returns remoteConfig + val call = MethodCall("getRemoteConfig", emptyMap()) + val mockResult = mockk() + every { mockResult.success(any()) } returns Unit + + plugin.onMethodCall(call, mockResult) + + verify { monitorProxy.mockMonitor.getRemoteConfig() } + verify { + mockResult.success( + mapOf( + "enabled" to true, + "nested" to mapOf("sampleRate" to 25.0), + "list" to listOf("one", null, 2) + ) + ) + } + } + private val contracts = listOf( Contract("startView", mapOf( "key" to ContractParameter.Type(SupportedContractType.STRING), @@ -1138,7 +1251,9 @@ class DatadogRumPluginTest { "failureReason" to ContractParameter.Type(SupportedContractType.STRING), "attributes" to ContractParameter.Type(SupportedContractType.MAP) )), - Contract("stopSession", mapOf()) + Contract("stopSession", mapOf()), + Contract("setForcedSession", mapOf()), + Contract("getRemoteConfig", mapOf()) ) @Test diff --git a/packages/datadog_flutter_plugin/android/src/test/kotlin/com/datadoghq/flutter/MockRumMonitor.kt b/packages/datadog_flutter_plugin/android/src/test/kotlin/com/datadoghq/flutter/MockRumMonitor.kt index 68a1b6573..527671884 100644 --- a/packages/datadog_flutter_plugin/android/src/test/kotlin/com/datadoghq/flutter/MockRumMonitor.kt +++ b/packages/datadog_flutter_plugin/android/src/test/kotlin/com/datadoghq/flutter/MockRumMonitor.kt @@ -171,6 +171,14 @@ class MockRumMonitor : RumMonitor { mockMonitor.stopSession() } + override fun setForcedSession() { + mockMonitor.setForcedSession() + } + + override fun getRemoteConfig(): Map? { + return mockMonitor.getRemoteConfig() + } + override fun stopView(key: Any, attributes: Map) { mockMonitor.stopView(key, attributes) } @@ -183,4 +191,4 @@ class MockRumMonitor : RumMonitor { ) { mockMonitor.succeedFeatureOperation(name, operationKey, attributes) } -} \ No newline at end of file +} diff --git a/packages/datadog_flutter_plugin/example/android/app/build.gradle b/packages/datadog_flutter_plugin/example/android/app/build.gradle index adfe1ed42..14680955a 100644 --- a/packages/datadog_flutter_plugin/example/android/app/build.gradle +++ b/packages/datadog_flutter_plugin/example/android/app/build.gradle @@ -4,7 +4,7 @@ * Copyright 2016-Present Datadog, Inc. */ buildscript { - ext.datadog_version = "0.4.1" + ext.datadog_version = "0.7.0" } plugins { diff --git a/packages/datadog_flutter_plugin/example/ios/Tests/DatadogRumPluginTests.swift b/packages/datadog_flutter_plugin/example/ios/Tests/DatadogRumPluginTests.swift index cac7c9c93..3b36490ec 100644 --- a/packages/datadog_flutter_plugin/example/ios/Tests/DatadogRumPluginTests.swift +++ b/packages/datadog_flutter_plugin/example/ios/Tests/DatadogRumPluginTests.swift @@ -11,7 +11,7 @@ import Flutter import DatadogInternal @testable import DatadogCore @testable import DatadogRUM -import flashcat_flutter_plugin +@testable import flashcat_flutter_plugin enum ResultStatus: EquatableInTests { case notCalled @@ -119,6 +119,14 @@ class DatadogRumPluginTests: XCTestCase { mock = MockRUMMonitor() plugin = DatadogRumPlugin.instance plugin.inject(rum: mock) + plugin.beforeSamplingMethodInvoker = nil + plugin.beforeSamplingTimeout = 0.5 + } + + override func tearDown() { + plugin.beforeSamplingMethodInvoker = nil + plugin.beforeSamplingTimeout = 0.5 + super.tearDown() } let contracts = [ @@ -196,7 +204,9 @@ class DatadogRumPluginTests: XCTestCase { "failureReason": .string, "attributes": .map ]), - Contract(methodName: "stopSession", requiredParameters: [:]) + Contract(methodName: "stopSession", requiredParameters: [:]), + Contract(methodName: "setForcedSession", requiredParameters: [:]), + Contract(methodName: "getRemoteConfig", requiredParameters: [:]) ] func testRumPlugin_ContractViolationsThrowErrors() { @@ -248,6 +258,23 @@ class DatadogRumPluginTests: XCTestCase { XCTAssertEqual(config?.trackBackgroundEvents, trackBackgroundEvents) } + func testRumConfiguration_RemoteConfigurationDefaultsToDisabled() { + let config = RUM.Configuration.init(fromEncoded: [ + "applicationId": "fake-application-id" + ]) + + XCTAssertEqual(config?.remoteConfigurationEnabled, false) + } + + func testRumConfiguration_WithRemoteConfigurationEnabled_IsSetCorrectly() { + let config = RUM.Configuration.init(fromEncoded: [ + "applicationId": "fake-application-id", + "remoteConfigurationEnabled": true + ]) + + XCTAssertEqual(config?.remoteConfigurationEnabled, true) + } + func testRepeatEnable_FromMethodChannelSameOptions_DoesNothing() { // Uninitialize plugin plugin?.inject(rum: nil) @@ -779,6 +806,149 @@ class DatadogRumPluginTests: XCTestCase { } XCTAssertEqual(resultStatus, .called(value: nil)) } + + func testSetForcedSession_CallsRumMonitor() { + let call = FlutterMethodCall(methodName: "setForcedSession", arguments: [:] as [String: Any]) + + var resultStatus = ResultStatus.notCalled + plugin.handle(call) { result in + resultStatus = .called(value: result) + } + + XCTAssertEqual(mock.callLog, [.setForcedSession]) + XCTAssertEqual(resultStatus, .called(value: nil)) + } + + func testGetRemoteConfig_ReturnsSanitizedCustomValues() { + mock.remoteConfig = [ + "enabled": true, + "nested": ["sampleRate": 25.0], + "list": ["one", 2], + "unsupported": Date() + ] + let call = FlutterMethodCall(methodName: "getRemoteConfig", arguments: [:] as [String: Any]) + + var returnedConfig: [String: Any]? + plugin.handle(call) { result in + returnedConfig = result as? [String: Any] + } + + XCTAssertEqual(mock.callLog, [.getRemoteConfig]) + XCTAssertEqual(returnedConfig?["enabled"] as? Bool, true) + XCTAssertEqual((returnedConfig?["nested"] as? [String: Double])?["sampleRate"], 25.0) + XCTAssertEqual(returnedConfig?["list"] as? [AnyHashable], ["one", 2]) + XCTAssertNil(returnedConfig?["unsupported"]) + } + + func testBeforeSampling_WhenDisabled_DoesNotAttachCallback() { + var config = RUM.Configuration(applicationID: "fake-application-id") + + plugin.attachBeforeSampling(configArg: [:], config: &config) + + XCTAssertNil(config.beforeSampling) + } + + func testBeforeSampling_OnMainThread_ReturnsValidRateAndSanitizesContext() throws { + XCTAssertTrue(Thread.isMainThread) + var config = RUM.Configuration(applicationID: "fake-application-id") + var receivedArguments: [String: Any?]? + plugin.beforeSamplingMethodInvoker = { arguments, completion in + receivedArguments = arguments + DispatchQueue.main.async { + completion(42.5) + } + } + plugin.attachBeforeSampling( + configArg: ["attachBeforeSampling": true], + config: &config + ) + + let callback = try XCTUnwrap(config.beforeSampling) + let result = callback( + BeforeSamplingContext( + sessionSampleRate: 25, + custom: [ + "enabled": true, + "nested": ["sampleRate": 25.0], + "list": ["one", 2], + "unsupported": Date() + ] + ) + ) + + XCTAssertEqual(result, 42.5) + let context = receivedArguments?["context"] as? [String: Any?] + XCTAssertEqual(context?["sessionSampleRate"] as? Float, 25) + let custom = context?["custom"] as? [String: Any?] + XCTAssertEqual(custom?["enabled"] as? Bool, true) + XCTAssertEqual((custom?["nested"] as? [String: Double])?["sampleRate"], 25.0) + XCTAssertEqual(custom?["list"] as? [AnyHashable], ["one", 2]) + XCTAssertNil(custom?["unsupported"]) + } + + func testBeforeSampling_OnBackgroundThread_ReturnsValidRate() { + let completed = expectation(description: "background beforeSampling completed") + plugin.beforeSamplingMethodInvoker = { _, completion in + XCTAssertTrue(Thread.isMainThread) + completion(60.0) + } + let plugin = self.plugin! + + DispatchQueue.global().async { + let result = plugin.callBeforeSampling( + BeforeSamplingContext(sessionSampleRate: 25, custom: nil) + ) + XCTAssertEqual(result, 60.0) + completed.fulfill() + } + + waitForExpectations(timeout: 1) + } + + func testBeforeSampling_InvalidResultsKeepNativeRate() { + let invalidResults: [Any] = [ + true, + -1.0, + 101.0, + FlutterError(code: "callback-error", message: "failed", details: nil) + ] + let context = BeforeSamplingContext(sessionSampleRate: 25, custom: nil) + + invalidResults.forEach { invalidResult in + plugin.beforeSamplingMethodInvoker = { _, completion in + completion(invalidResult) + } + XCTAssertNil(plugin.callBeforeSampling(context)) + } + } + + func testBeforeSampling_WhenCallbackTimesOut_KeepsNativeRate() { + plugin.beforeSamplingTimeout = 0.01 + plugin.beforeSamplingMethodInvoker = { _, _ in } + + let result = plugin.callBeforeSampling( + BeforeSamplingContext(sessionSampleRate: 25, custom: nil) + ) + + XCTAssertNil(result) + } + + func testBeforeSampling_WhenCallbackRepliesAfterTimeout_ReplyIsIgnored() { + plugin.beforeSamplingTimeout = 0.01 + var lateCompletion: FlutterResult? + plugin.beforeSamplingMethodInvoker = { _, completion in + lateCompletion = completion + } + + let result = plugin.callBeforeSampling( + BeforeSamplingContext(sessionSampleRate: 25, custom: nil) + ) + XCTAssertNil(result) + + // Replying after we stopped waiting must not crash or leak the rate into + // the sampling decision we already returned. + lateCompletion?(60.0) + } } // MARK: - MockRUMMonitor @@ -816,6 +986,8 @@ class MockRUMMonitor: RUMMonitorProtocol, RUMCommandSubscriber { case removeViewAttributes(keys: [DatadogInternal.AttributeKey]) case addFeatureFlagEvaluation(name: String, value: Encodable) case stopSession + case setForcedSession + case getRemoteConfig case startFeatureOperation(name: String, operationKey: String?, attributes: [AttributeKey: AttributeValue]) case succeedFeatureOperation(name: String, operationKey: String?, attributes: [AttributeKey: AttributeValue]) case failFeatureOperation(name: String, operationKey: String?, failureReason: RUMFeatureOperationFailureReason, @@ -824,6 +996,7 @@ class MockRUMMonitor: RUMMonitorProtocol, RUMCommandSubscriber { var callLog: [MethodCall] = [] var commands: [RUMCommand] = [] + var remoteConfig: [String: Any]? init() { debug = true @@ -930,6 +1103,19 @@ class MockRUMMonitor: RUMMonitorProtocol, RUMCommandSubscriber { callLog.append(.stopSession) } + func setForcedSession() { + callLog.append(.setForcedSession) + } + + func getRemoteConfig() -> [String: Any]? { + callLog.append(.getRemoteConfig) + return remoteConfig + } + + func reportAppFullyDisplayed() { + // No-op: required by FlashcatRUM 0.6.0. + } + func addFeatureFlagEvaluation(name: String, value: Encodable) { callLog.append(.addFeatureFlagEvaluation(name: name, value: value)) } diff --git a/packages/datadog_flutter_plugin/ios/flashcat_flutter_plugin.podspec b/packages/datadog_flutter_plugin/ios/flashcat_flutter_plugin.podspec index 2a9433a5e..175ad3b8a 100644 --- a/packages/datadog_flutter_plugin/ios/flashcat_flutter_plugin.podspec +++ b/packages/datadog_flutter_plugin/ios/flashcat_flutter_plugin.podspec @@ -4,7 +4,7 @@ # Pod::Spec.new do |s| s.name = 'flashcat_flutter_plugin' - s.version = '0.1.3' + s.version = '0.2.0' s.summary = 'Instrument your application with Datadog.' s.description = <<-DESC Instrument your application with Datadog. @@ -16,14 +16,14 @@ Instrument your application with Datadog. s.source_files = 'flashcat_flutter_plugin/Sources/**/*' s.static_framework = true s.dependency 'Flutter' - s.dependency 'FlashcatCore', '~> 0.5' + s.dependency 'FlashcatCore', '~> 0.6.0' # Logs are not supported in v1 (FlashCat ingest does not accept Logs yet). # Use the no-op Logs variant so the API compiles but sends nothing. Matches # the SPM Package.swift, which links the FlashcatLogs-NoOp product. - s.dependency 'FlashcatLogs-NoOp', '~> 0.5' - s.dependency 'FlashcatRUM', '~> 0.5' - s.dependency 'FlashcatInternal', '~> 0.5' - s.dependency 'FlashcatCrashReporting', '~> 0.5' + s.dependency 'FlashcatLogs-NoOp', '~> 0.6.0' + s.dependency 'FlashcatRUM', '~> 0.6.0' + s.dependency 'FlashcatInternal', '~> 0.6.0' + s.dependency 'FlashcatCrashReporting', '~> 0.6.0' s.dependency 'DictionaryCoder', '1.2.0' s.platform = :ios, '12.0' diff --git a/packages/datadog_flutter_plugin/ios/flashcat_flutter_plugin/Package.swift b/packages/datadog_flutter_plugin/ios/flashcat_flutter_plugin/Package.swift index 6c481f32c..5da7f4e60 100644 --- a/packages/datadog_flutter_plugin/ios/flashcat_flutter_plugin/Package.swift +++ b/packages/datadog_flutter_plugin/ios/flashcat_flutter_plugin/Package.swift @@ -12,7 +12,7 @@ let package = Package( .library(name: "flashcat-flutter-plugin", targets: ["flashcat_flutter_plugin"]) ], dependencies: [ - .package(url: "https://github.com/flashcatcloud/fc-sdk-ios.git", exact: "0.5.0"), + .package(url: "https://github.com/flashcatcloud/fc-sdk-ios.git", exact: "0.6.0"), .package(url: "https://github.com/almazrafi/DictionaryCoder.git", exact: "1.2.0") ], targets: [ diff --git a/packages/datadog_flutter_plugin/ios/flashcat_flutter_plugin/Sources/flashcat_flutter_plugin/DatadogRumPlugin.swift b/packages/datadog_flutter_plugin/ios/flashcat_flutter_plugin/Sources/flashcat_flutter_plugin/DatadogRumPlugin.swift index 0cd908172..3bde669fc 100644 --- a/packages/datadog_flutter_plugin/ios/flashcat_flutter_plugin/Sources/flashcat_flutter_plugin/DatadogRumPlugin.swift +++ b/packages/datadog_flutter_plugin/ios/flashcat_flutter_plugin/Sources/flashcat_flutter_plugin/DatadogRumPlugin.swift @@ -24,6 +24,7 @@ public extension RUM.Configuration { trackFrustrations = (encoded["trackFrustrations"] as? NSNumber)?.boolValue ?? true trackAnonymousUser = (encoded["trackAnonymousUser"] as? NSNumber)?.boolValue ?? true trackBackgroundEvents = (encoded["trackBackgroundEvents"] as? NSNumber)?.boolValue ?? false + remoteConfigurationEnabled = (encoded["remoteConfigurationEnabled"] as? NSNumber)?.boolValue ?? false if let appHangThreshold = (encoded["appHangThreshold"] as? NSNumber)?.doubleValue { self.appHangThreshold = appHangThreshold } @@ -56,6 +57,8 @@ public extension RUM.Configuration { public class DatadogRumPlugin: NSObject, FlutterPlugin { private static var methodChannel: FlutterMethodChannel? + typealias BeforeSamplingMethodInvoker = ([String: Any?], @escaping FlutterResult) -> Void + public static let instance = DatadogRumPlugin() public static func register(with registrar: FlutterPluginRegistrar) { methodChannel = FlutterMethodChannel(name: "datadog_sdk_flutter.rum", binaryMessenger: registrar.messenger()) @@ -67,6 +70,8 @@ public class DatadogRumPlugin: NSObject, FlutterPlugin { internal var mapperPerf = PerformanceTracker() internal var mainThreadMapperPerf = PerformanceTracker() internal var mapperTimeouts = 0 + internal var beforeSamplingMethodInvoker: BeforeSamplingMethodInvoker? + internal var beforeSamplingTimeout: TimeInterval = 0.5 private var currentConfiguration: [AnyHashable: Any]? @@ -115,6 +120,17 @@ public class DatadogRumPlugin: NSObject, FlutterPlugin { case "getCurrentSessionId": getCurrentSessionId(result: result) + case "setForcedSession": + rum?.setForcedSession() + result(nil) + + case "getRemoteConfig": + if let remoteConfig = rum?.getRemoteConfig() { + result(sanitizeRemoteConfig(remoteConfig)) + } else { + result(nil) + } + case "startView": if let key = arguments["key"] as? String, let name = arguments["name"] as? String, @@ -386,6 +402,7 @@ public class DatadogRumPlugin: NSObject, FlutterPlugin { if let configArg = configArg, var config = RUM.Configuration(fromEncoded: configArg) { attachEventMappers(configArg: configArg, config: &config) + attachBeforeSampling(configArg: configArg, config: &config) // Disable INV as the Flutter calculations for it are different config.nextViewActionPredicate = nil config.onSessionStart = { sessionId, discarded in @@ -521,6 +538,166 @@ public class DatadogRumPlugin: NSObject, FlutterPlugin { } } + internal func attachBeforeSampling(configArg: [String: Any?], config: inout RUM.Configuration) { + let isAttached = (configArg["attachBeforeSampling"] as? NSNumber)?.boolValue ?? false + if isAttached { + config.beforeSampling = { [weak self] context in + guard let self = self else { return nil } + return self.callBeforeSampling(context) + } + } + } + + /// Holds the rate Flutter reported for a single `beforeSampling` round trip. + /// + /// The reply can land after we gave up waiting for it, and on a different + /// thread than the one that started the call, so the rate is written under a + /// lock and `resolve()` closes the call to later replies. + private final class BeforeSamplingResult { + private let lock = NSLock() + private var rate: SampleRate? + private var isResolved = false + + func set(_ rate: SampleRate) { + lock.lock() + defer { lock.unlock() } + guard !isResolved else { return } + self.rate = rate + } + + func resolve() -> SampleRate? { + lock.lock() + defer { lock.unlock() } + isResolved = true + return rate + } + } + + internal func callBeforeSampling(_ context: BeforeSamplingContext) -> SampleRate? { + guard DatadogRumPlugin.methodChannel != nil || beforeSamplingMethodInvoker != nil else { + return nil + } + + let pendingResult = BeforeSamplingResult() + let semaphore = DispatchSemaphore(value: 0) + let encodedContext: [String: Any?] = [ + "sessionSampleRate": context.sessionSampleRate, + "custom": context.custom.map { sanitizeRemoteConfig($0) } + ] + + let invoke: () -> Void = { [weak self] in + guard let self = self else { + semaphore.signal() + return + } + + let completion: FlutterResult = { result in + if let number = result as? NSNumber, + CFGetTypeID(number) != CFBooleanGetTypeID() { + let value = number.floatValue + if value.isFinite && (0...100).contains(value) { + pendingResult.set(value) + } + } + semaphore.signal() + } + + let arguments = ["context": encodedContext] + if let invoker = self.beforeSamplingMethodInvoker { + invoker(arguments, completion) + } else if let methodChannel = DatadogRumPlugin.methodChannel { + methodChannel.invokeMethod("beforeSampling", arguments: arguments, result: completion) + } else { + semaphore.signal() + } + } + + // Initial sessions are often drawn during `RUM.enable` on the platform / + // main thread. Using `DispatchQueue.main.async` + `semaphore.wait` there + // deadlocks: the async block never runs, Flutter never sees + // `beforeSampling`, and we time out after 500ms. + // + // Pumping the run loop instead makes this call a re-entrancy point: any + // other main-queue work runs while we wait, including unrelated platform + // channel calls (`startView`, a second `enable`, `deinitialize`), UIKit + // events, and our own `onSessionStart` block. That is unavoidable while + // the native hook is synchronous and the method channel is not, so keep + // `beforeSamplingTimeout` short and assume the plugin can be called + // again before this method returns. + if Thread.isMainThread { + invoke() + let timeoutDate = Date().addingTimeInterval(beforeSamplingTimeout) + while true { + if semaphore.wait(timeout: .now()) == .success { + break + } + let remaining = timeoutDate.timeIntervalSinceNow + if remaining <= 0 { + reportBeforeSamplingTimeout() + return pendingResult.resolve() + } + let slice = min(remaining, 0.01) + // `run(mode:before:)` returns `false` immediately when the run + // loop has no sources attached. Block on the semaphore in that + // case so we pace the loop instead of spinning for the whole + // timeout. + if !RunLoop.current.run(mode: .default, before: Date(timeIntervalSinceNow: slice)) { + if semaphore.wait(timeout: .now() + .milliseconds(1)) == .success { + break + } + } + } + } else { + DispatchQueue.main.async(execute: invoke) + let timeoutMilliseconds = max(0, Int(beforeSamplingTimeout * 1_000)) + if semaphore.wait(timeout: .now() + .milliseconds(timeoutMilliseconds)) == .timedOut { + reportBeforeSamplingTimeout() + return pendingResult.resolve() + } + } + + return pendingResult.resolve() + } + + private func reportBeforeSamplingTimeout() { + Datadog._internal.telemetry.debug( + id: "before_sampling_timeout", + message: "beforeSampling timed out." + ) + } + + private func sanitizeRemoteConfig(_ values: [String: Any]) -> [String: Any?] { + var sanitized: [String: Any?] = [:] + values.forEach { key, value in + if let safeValue = sanitizeRemoteConfigValue(value) { + sanitized[key] = safeValue + } else { + consolePrint( + "Dropping unsupported remote config value for key \(key).", + .warn + ) + } + } + return sanitized + } + + private func sanitizeRemoteConfigValue(_ value: Any) -> Any? { + switch value { + case is NSNull, is NSNumber, is String, + is Bool, + is Int, is Int8, is Int16, is Int32, is Int64, + is UInt, is UInt8, is UInt16, is UInt32, is UInt64, + is Float, is Double: + return value + case let array as [Any]: + return array.compactMap { sanitizeRemoteConfigValue($0) } + case let dictionary as [String: Any]: + return sanitizeRemoteConfig(dictionary) + default: + return nil + } + } + func callEventMapper( mapperName: String, event: T, diff --git a/packages/datadog_flutter_plugin/lib/src/android/android_rum_event_mapper.dart b/packages/datadog_flutter_plugin/lib/src/android/android_rum_event_mapper.dart index 722277c4b..afac0ee56 100644 --- a/packages/datadog_flutter_plugin/lib/src/android/android_rum_event_mapper.dart +++ b/packages/datadog_flutter_plugin/lib/src/android/android_rum_event_mapper.dart @@ -24,6 +24,7 @@ class AndroidRumEventMapper extends RumMapperProxy { errorEventMapper: config.errorEventMapper, longTaskEventMapper: config.longTaskEventMapper, vitalOperationEventMapper: config.vitalOperationStepEventMapper, + beforeSampling: config.beforeSampling, ) { final listener = DatadogRumEventMapper$EventMapper.implement( $DatadogRumEventMapper$EventMapper( @@ -46,6 +47,7 @@ class AndroidRumEventMapper extends RumMapperProxy { mapLongTaskEvent: (encoded) => _callMapper(encoded, mapLongTaskEvent), mapVitalOperationStepEvent: (encoded) => _callMapper(encoded, mapVitalOperationEvent), + beforeSampling: _callBeforeSampling, ), ); @@ -59,4 +61,24 @@ class AndroidRumEventMapper extends RumMapperProxy { final mapped = mapper(decoded); return safeEncodeJavaJson(mapped, _internalLogger, fallback: encoded); } + + JString? _callBeforeSampling(JString encoded) { + try { + final decoded = safeDecodeJavaJson(encoded, _internalLogger); + if (decoded == null) return null; + + final result = beforeSampling(decoded); + return result == null ? null : JString.fromString(result.toString()); + } catch (e, st) { + _internalLogger.sendToDatadog( + 'beforeSampling threw an exception: ${e.toString()}.', + st, + e.runtimeType.toString(), + ); + _internalLogger.error( + 'beforeSampling threw an exception: ${e.toString()}. Keeping the native sampling rate.', + ); + return null; + } + } } diff --git a/packages/datadog_flutter_plugin/lib/src/android/datadog_android_bridge.dart b/packages/datadog_flutter_plugin/lib/src/android/datadog_android_bridge.dart index 17b81c724..55ef5af9d 100644 --- a/packages/datadog_flutter_plugin/lib/src/android/datadog_android_bridge.dart +++ b/packages/datadog_flutter_plugin/lib/src/android/datadog_android_bridge.dart @@ -223,6 +223,33 @@ class DatadogRumEventMapper$EventMapper extends jni$_.JObject { .object(const jni$_.JStringNullableType()); } + static final _id_beforeSampling = _class.instanceMethodId( + r'beforeSampling', + r'(Ljava/lang/String;)Ljava/lang/String;', + ); + + static final _beforeSampling = jni$_.ProtectedJniExtensions.lookup< + jni$_.NativeFunction< + jni$_.JniResult Function( + jni$_.Pointer, + jni$_.JMethodIDPtr, + jni$_.VarArgs<(jni$_.Pointer,)>)>>( + 'globalEnv_CallObjectMethod') + .asFunction< + jni$_.JniResult Function(jni$_.Pointer, + jni$_.JMethodIDPtr, jni$_.Pointer)>(); + + /// from: `public abstract java.lang.String beforeSampling(java.lang.String string)` + /// The returned object must be released after use, by calling the [release] method. + jni$_.JString? beforeSampling( + jni$_.JString string, + ) { + final _$string = string.reference; + return _beforeSampling(reference.pointer, + _id_beforeSampling as jni$_.JMethodIDPtr, _$string.pointer) + .object(const jni$_.JStringNullableType()); + } + /// Maps a specific port to the implemented interface. static final core$_.Map _$impls = {}; static jni$_.JObjectPtr _$invoke( @@ -314,6 +341,16 @@ class DatadogRumEventMapper$EventMapper extends jni$_.JObject { .toPointer() ?? jni$_.nullptr; } + if ($d == r'beforeSampling(Ljava/lang/String;)Ljava/lang/String;') { + final $r = _$impls[$p]!.beforeSampling( + $a![0]!.as(const jni$_.JStringType(), releaseOriginal: true), + ); + return ($r as jni$_.JObject?) + ?.as(const jni$_.JObjectType()) + .reference + .toPointer() ?? + jni$_.nullptr; + } } catch (e) { return jni$_.ProtectedJniExtensions.newDartException(e); } @@ -365,6 +402,7 @@ abstract base mixin class $DatadogRumEventMapper$EventMapper { required jni$_.JString? Function(jni$_.JString string) mapLongTaskEvent, required jni$_.JString? Function(jni$_.JString string) mapVitalOperationStepEvent, + required jni$_.JString? Function(jni$_.JString string) beforeSampling, }) = _$DatadogRumEventMapper$EventMapper; jni$_.JString? mapViewEvent(jni$_.JString string); @@ -373,6 +411,7 @@ abstract base mixin class $DatadogRumEventMapper$EventMapper { jni$_.JString? mapErrorEvent(jni$_.JString string); jni$_.JString? mapLongTaskEvent(jni$_.JString string); jni$_.JString? mapVitalOperationStepEvent(jni$_.JString string); + jni$_.JString? beforeSampling(jni$_.JString string); } final class _$DatadogRumEventMapper$EventMapper @@ -385,12 +424,14 @@ final class _$DatadogRumEventMapper$EventMapper required jni$_.JString? Function(jni$_.JString string) mapLongTaskEvent, required jni$_.JString? Function(jni$_.JString string) mapVitalOperationStepEvent, + required jni$_.JString? Function(jni$_.JString string) beforeSampling, }) : _mapViewEvent = mapViewEvent, _mapActionEvent = mapActionEvent, _mapResourceEvent = mapResourceEvent, _mapErrorEvent = mapErrorEvent, _mapLongTaskEvent = mapLongTaskEvent, - _mapVitalOperationStepEvent = mapVitalOperationStepEvent; + _mapVitalOperationStepEvent = mapVitalOperationStepEvent, + _beforeSampling = beforeSampling; final jni$_.JString? Function(jni$_.JString string) _mapViewEvent; final jni$_.JString? Function(jni$_.JString string) _mapActionEvent; @@ -399,6 +440,7 @@ final class _$DatadogRumEventMapper$EventMapper final jni$_.JString? Function(jni$_.JString string) _mapLongTaskEvent; final jni$_.JString? Function(jni$_.JString string) _mapVitalOperationStepEvent; + final jni$_.JString? Function(jni$_.JString string) _beforeSampling; jni$_.JString? mapViewEvent(jni$_.JString string) { return _mapViewEvent(string); @@ -423,6 +465,10 @@ final class _$DatadogRumEventMapper$EventMapper jni$_.JString? mapVitalOperationStepEvent(jni$_.JString string) { return _mapVitalOperationStepEvent(string); } + + jni$_.JString? beforeSampling(jni$_.JString string) { + return _beforeSampling(string); + } } final class $DatadogRumEventMapper$EventMapper$NullableType diff --git a/packages/datadog_flutter_plugin/lib/src/ios/ios_rum_event_mapper.dart b/packages/datadog_flutter_plugin/lib/src/ios/ios_rum_event_mapper.dart index 575c0dc04..a8a71f5d8 100644 --- a/packages/datadog_flutter_plugin/lib/src/ios/ios_rum_event_mapper.dart +++ b/packages/datadog_flutter_plugin/lib/src/ios/ios_rum_event_mapper.dart @@ -22,14 +22,16 @@ class IosRumEventMapper extends RumMethodChannelMapperProxy { final InternalLogger _internalLogger; IosRumEventMapper(DatadogRumConfiguration config, InternalLogger logger) - : _internalLogger = logger, - super( - viewEventMapper: config.viewEventMapper, - actionEventMapper: config.actionEventMapper, - resourceEventMapper: config.resourceEventMapper, - errorEventMapper: config.errorEventMapper, - longTaskEventMapper: config.longTaskEventMapper, - ); + : _internalLogger = logger, + super( + viewEventMapper: config.viewEventMapper, + actionEventMapper: config.actionEventMapper, + resourceEventMapper: config.resourceEventMapper, + errorEventMapper: config.errorEventMapper, + longTaskEventMapper: config.longTaskEventMapper, + vitalOperationEventMapper: config.vitalOperationStepEventMapper, + beforeSampling: config.beforeSampling, + ); @override Future handleMethodCall(MethodCall call) async { @@ -45,6 +47,8 @@ class IosRumEventMapper extends RumMethodChannelMapperProxy { return _mapErrorEvent(call); case 'mapLongTaskEvent': return _mapLongTaskEvent(call); + case 'beforeSampling': + return _beforeSampling(call); } throw MissingPluginException( 'Could not find a method to call for ${call.method}', @@ -55,6 +59,12 @@ class IosRumEventMapper extends RumMethodChannelMapperProxy { st, e.runtimeType.toString(), ); + if (call.method == 'beforeSampling') { + _internalLogger.error( + '${call.method} threw an exception: ${e.toString()}.\nKeeping the native sampling rate.', + ); + return null; + } _internalLogger.error( '${call.method} threw an exception: ${e.toString()}.\nReturning mapper error.', ); @@ -86,6 +96,14 @@ class IosRumEventMapper extends RumMethodChannelMapperProxy { final eventJson = (call.arguments['event'] as Map).toJsonMap(); return mapLongTaskEvent(eventJson); } + + double? _beforeSampling(MethodCall call) { + final arguments = call.arguments; + if (arguments is! Map) return null; + final encodedContext = arguments['context']; + if (encodedContext is! Map) return null; + return beforeSampling(encodedContext.toJsonMap()); + } } // ignore: strict_raw_type diff --git a/packages/datadog_flutter_plugin/lib/src/rum/ddrum.dart b/packages/datadog_flutter_plugin/lib/src/rum/ddrum.dart index 2c25a3024..d28d95284 100644 --- a/packages/datadog_flutter_plugin/lib/src/rum/ddrum.dart +++ b/packages/datadog_flutter_plugin/lib/src/rum/ddrum.dart @@ -286,6 +286,31 @@ class DatadogRum { ); } + /// Forces RUM sessions to be collected for the lifetime of this process. + /// + /// If the current session was sampled out, the native SDK ends it and starts + /// a collected session. A session already being collected continues. This + /// setting cannot be reverted without restarting the process. + void setForcedSession() { + wrap('rum.setForcedSession', logger, null, () { + return _platform.setForcedSession(); + }); + } + + /// Returns custom values supplied by RUM remote configuration. + /// + /// Returns `null` when remote configuration is disabled or unavailable. An + /// empty custom object is returned as an empty map. These values are public + /// to anyone with the client token and must not contain secrets. + Future?> getRemoteConfig() { + return wrapAsync( + 'rum.getRemoteConfig', + logger, + null, + () => _platform.getRemoteConfig(), + ); + } + /// Notifies that the View identified by [key] starts being presented to the /// user. This view will show as [name] in the RUM explorer, and defaults to /// [key] if it is not provided. You can also attach custom [attributes], diff --git a/packages/datadog_flutter_plugin/lib/src/rum/ddrum_method_channel.dart b/packages/datadog_flutter_plugin/lib/src/rum/ddrum_method_channel.dart index c86567167..98b821065 100644 --- a/packages/datadog_flutter_plugin/lib/src/rum/ddrum_method_channel.dart +++ b/packages/datadog_flutter_plugin/lib/src/rum/ddrum_method_channel.dart @@ -59,6 +59,19 @@ class DdRumMethodChannel extends DdRumPlatform { return sessionId; } + @override + Future setForcedSession() { + return methodChannel.invokeMethod('setForcedSession', {}); + } + + @override + Future?> getRemoteConfig() { + return methodChannel.invokeMapMethod( + 'getRemoteConfig', + {}, + ); + } + @override Future addTiming(DateTime timestamp, String name) { return methodChannel.invokeMethod('addTiming', {'name': name}); @@ -434,7 +447,7 @@ class DdRumMethodChannel extends DdRumPlatform { @visibleForTesting Future handleMethodCall(MethodCall call) async { - if (call.method.startsWith('map')) { + if (call.method.startsWith('map') || call.method == 'beforeSampling') { if (_mapperProxy case final RumMethodChannelMapperProxy mapper) { return mapper.handleMethodCall(call); } diff --git a/packages/datadog_flutter_plugin/lib/src/rum/ddrum_noop_platform.dart b/packages/datadog_flutter_plugin/lib/src/rum/ddrum_noop_platform.dart index 2808267d9..909d6875a 100644 --- a/packages/datadog_flutter_plugin/lib/src/rum/ddrum_noop_platform.dart +++ b/packages/datadog_flutter_plugin/lib/src/rum/ddrum_noop_platform.dart @@ -12,6 +12,12 @@ class DdNoOpRumPlatform extends DdRumPlatform { @override Future getCurrentSessionId() => Future.value(null); + @override + Future setForcedSession() => Future.value(); + + @override + Future?> getRemoteConfig() => Future.value(null); + @override Future addAttribute(String key, Object value) => Future.value(); diff --git a/packages/datadog_flutter_plugin/lib/src/rum/ddrum_platform_interface.dart b/packages/datadog_flutter_plugin/lib/src/rum/ddrum_platform_interface.dart index f3d2b08e0..63b282dd7 100644 --- a/packages/datadog_flutter_plugin/lib/src/rum/ddrum_platform_interface.dart +++ b/packages/datadog_flutter_plugin/lib/src/rum/ddrum_platform_interface.dart @@ -27,6 +27,8 @@ abstract class DdRumPlatform extends PlatformInterface { Future deinitialize(); Future getCurrentSessionId(); + Future setForcedSession(); + Future?> getRemoteConfig(); Future startView( DateTime timestamp, diff --git a/packages/datadog_flutter_plugin/lib/src/rum/rum_configuration.dart b/packages/datadog_flutter_plugin/lib/src/rum/rum_configuration.dart index 7e947f6f8..950f95b44 100644 --- a/packages/datadog_flutter_plugin/lib/src/rum/rum_configuration.dart +++ b/packages/datadog_flutter_plugin/lib/src/rum/rum_configuration.dart @@ -64,6 +64,30 @@ typedef RumLongTaskEventMapper = RumLongTaskEvent? Function( typedef RumVitalOperationEventMapper = RumVitalOperationStepEvent? Function( RumVitalOperationStepEvent event); +/// The context provided to [RumBeforeSamplingCallback] before a new RUM +/// session is sampled. +class RumBeforeSamplingContext { + /// The sampling rate that would otherwise be used for the new session. + final double sessionSampleRate; + + /// Custom values supplied by remote configuration, when available. + final Map? custom; + + const RumBeforeSamplingContext({ + required this.sessionSampleRate, + this.custom, + }); +} + +/// Called synchronously before a new RUM session is sampled. +/// +/// Return a value between `0.0` and `100.0` to override the sampling rate, or +/// `null` to keep [RumBeforeSamplingContext.sessionSampleRate]. Invalid values +/// and exceptions are ignored by the SDK. +typedef RumBeforeSamplingCallback = double? Function( + RumBeforeSamplingContext context, +); + /// Configuration options for the Datadog Real User Monitoring (RUM) feature. class DatadogRumConfiguration { // Either a RUM Application Id. Obtained on the Datadog website. @@ -194,6 +218,20 @@ class DatadogRumConfiguration { /// Use a custom endpoint for sending RUM data. String? customEndpoint; + /// Whether the native SDK may retrieve RUM sampling configuration remotely. + /// + /// This is disabled by default. Enabling it may cause the native SDK to make + /// configuration requests. Remote configuration affects sessions created + /// after the configuration is received. + bool remoteConfigurationEnabled; + + /// A callback that can override the sampling rate for each new session. + /// + /// This callback is independent of [remoteConfigurationEnabled]. When remote + /// configuration is disabled, it still receives the locally configured + /// [sessionSamplingRate] and `custom` is `null`. + RumBeforeSamplingCallback? beforeSampling; + // double telemetrySampleRate; @@ -239,6 +277,8 @@ class DatadogRumConfiguration { this.trackBackgroundEvents = false, this.initialResourceThreshold = 0.1, this.customEndpoint, + this.remoteConfigurationEnabled = false, + this.beforeSampling, this.telemetrySampleRate = 20.0, this.viewEventMapper, this.actionEventMapper, @@ -266,6 +306,8 @@ class DatadogRumConfiguration { 'trackBackgroundEvents': trackBackgroundEvents, 'initialResourceThreshold': initialResourceThreshold, 'customEndpoint': customEndpoint, + 'remoteConfigurationEnabled': remoteConfigurationEnabled, + 'attachBeforeSampling': beforeSampling != null, 'telemetrySampleRate': telemetrySampleRate, 'attachViewEventMapper': viewEventMapper != null, 'attachActionEventMapper': actionEventMapper != null, diff --git a/packages/datadog_flutter_plugin/lib/src/rum/rum_mapper_proxy.dart b/packages/datadog_flutter_plugin/lib/src/rum/rum_mapper_proxy.dart index b5268ccfb..630d13d6f 100644 --- a/packages/datadog_flutter_plugin/lib/src/rum/rum_mapper_proxy.dart +++ b/packages/datadog_flutter_plugin/lib/src/rum/rum_mapper_proxy.dart @@ -19,6 +19,7 @@ abstract class RumMapperProxy { final RumErrorEventMapper? _errorEventMapper; final RumLongTaskEventMapper? _longTaskEventMapper; final RumVitalOperationEventMapper? _vitalOperationEventMapper; + final RumBeforeSamplingCallback? _beforeSampling; RumMapperProxy({ required RumViewEventMapper? viewEventMapper, @@ -27,12 +28,41 @@ abstract class RumMapperProxy { required RumErrorEventMapper? errorEventMapper, required RumLongTaskEventMapper? longTaskEventMapper, required RumVitalOperationEventMapper? vitalOperationEventMapper, + required RumBeforeSamplingCallback? beforeSampling, }) : _viewEventMapper = viewEventMapper, _actionEventMapper = actionEventMapper, _resourceEventMapper = resourceEventMapper, _errorEventMapper = errorEventMapper, _longTaskEventMapper = longTaskEventMapper, - _vitalOperationEventMapper = vitalOperationEventMapper; + _vitalOperationEventMapper = vitalOperationEventMapper, + _beforeSampling = beforeSampling; + + double? beforeSampling(Map encodedContext) { + final callback = _beforeSampling; + if (callback == null) return null; + + final sessionSampleRate = encodedContext['sessionSampleRate']; + if (sessionSampleRate is! num) return null; + + Map? custom; + final encodedCustom = encodedContext['custom']; + if (encodedCustom is Map) { + custom = encodedCustom.map( + (key, value) => MapEntry(key.toString(), value), + ); + } + + final result = callback( + RumBeforeSamplingContext( + sessionSampleRate: sessionSampleRate.toDouble(), + custom: custom, + ), + ); + if (result == null || !result.isFinite || result < 0 || result > 100) { + return null; + } + return result; + } Map mapViewEvent(Map viewEventJson) { if (_viewEventMapper case final mapper?) { @@ -129,6 +159,7 @@ abstract class RumMethodChannelMapperProxy extends RumMapperProxy { super.errorEventMapper, super.longTaskEventMapper, super.vitalOperationEventMapper, + super.beforeSampling, }) : super(); Future handleMethodCall(MethodCall methodCall); diff --git a/packages/datadog_flutter_plugin/lib/src/rum/web/ddrum_web.dart b/packages/datadog_flutter_plugin/lib/src/rum/web/ddrum_web.dart index 663e7120d..8798fb564 100644 --- a/packages/datadog_flutter_plugin/lib/src/rum/web/ddrum_web.dart +++ b/packages/datadog_flutter_plugin/lib/src/rum/web/ddrum_web.dart @@ -116,6 +116,17 @@ class DdRumWeb extends DdRumPlatform { return DD_RUM?.getInternalContext()?.session_id; } + @override + Future setForcedSession() async { + // NOOP - Not supported by the Browser SDK. + } + + @override + Future?> getRemoteConfig() async { + // Not supported by the Browser SDK. + return null; + } + @override Future addAttribute(String key, dynamic value) async { DD_RUM?.setGlobalContextProperty(key, valueToJs(value, 'context')); diff --git a/packages/datadog_flutter_plugin/lib/src/version.dart b/packages/datadog_flutter_plugin/lib/src/version.dart index 200f2d616..ac3861523 100644 --- a/packages/datadog_flutter_plugin/lib/src/version.dart +++ b/packages/datadog_flutter_plugin/lib/src/version.dart @@ -2,4 +2,4 @@ // This product includes software developed at Datadog (https://www.datadoghq.com/). // Copyright 2016-Present Datadog, Inc. -const ddPackageVersion = '0.1.3'; +const ddPackageVersion = '0.2.0'; diff --git a/packages/datadog_flutter_plugin/pubspec.yaml b/packages/datadog_flutter_plugin/pubspec.yaml index b27ec0baf..77556b616 100644 --- a/packages/datadog_flutter_plugin/pubspec.yaml +++ b/packages/datadog_flutter_plugin/pubspec.yaml @@ -1,6 +1,6 @@ name: flashcat_flutter_plugin description: Flutter bindings and tools for utilizing the FlashCat Mobile SDKs -version: 0.1.3 +version: 0.2.0 repository: https://github.com/flashcatcloud/fc-sdk-flutter environment: diff --git a/packages/datadog_flutter_plugin/test/rum/ddrum_method_channel_test.dart b/packages/datadog_flutter_plugin/test/rum/ddrum_method_channel_test.dart index 49662ce4c..498aed855 100644 --- a/packages/datadog_flutter_plugin/test/rum/ddrum_method_channel_test.dart +++ b/packages/datadog_flutter_plugin/test/rum/ddrum_method_channel_test.dart @@ -46,6 +46,12 @@ void main() { if (message.method == 'getCurrentSessionId') { return Future.value('fake-session-id'); } + if (message.method == 'getRemoteConfig') { + return Future.value({ + 'debugUsers': ['user-a'], + 'enabled': true, + }); + } return null; }); }); @@ -61,6 +67,22 @@ void main() { expect(log, [isMethodCall('getCurrentSessionId', arguments: {})]); }); + test('setForcedSession calls to platform', () async { + await ddRumPlatform.setForcedSession(); + + expect(log, [isMethodCall('setForcedSession', arguments: {})]); + }); + + test('getRemoteConfig calls to platform', () async { + final config = await ddRumPlatform.getRemoteConfig(); + + expect(config, { + 'debugUsers': ['user-a'], + 'enabled': true, + }); + expect(log, [isMethodCall('getRemoteConfig', arguments: {})]); + }); + test('cachedSessionId starts null', () async { var cachedSessionId = ddRumPlatform.cachedSessionId; diff --git a/packages/datadog_flutter_plugin/test/rum/ddrum_test.dart b/packages/datadog_flutter_plugin/test/rum/ddrum_test.dart index 0e419df81..ac87c1946 100644 --- a/packages/datadog_flutter_plugin/test/rum/ddrum_test.dart +++ b/packages/datadog_flutter_plugin/test/rum/ddrum_test.dart @@ -9,8 +9,10 @@ import 'package:datadog_common_test/datadog_common_test.dart' hide DurationHelpers; import 'package:flashcat_flutter_plugin/flashcat_flutter_plugin.dart'; import 'package:flashcat_flutter_plugin/datadog_internal.dart'; +import 'package:flashcat_flutter_plugin/src/ios/ios_rum_event_mapper.dart'; import 'package:flashcat_flutter_plugin/src/rum/ddrum_noop_platform.dart'; import 'package:flashcat_flutter_plugin/src/rum/ddrum_platform_interface.dart'; +import 'package:flashcat_flutter_plugin/src/rum/rum_mapper_proxy.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; @@ -29,6 +31,19 @@ class MockRumPlatform extends Mock class MockTimeProvider extends Mock implements DatadogTimeProvider {} +class TestRumMapperProxy extends RumMapperProxy { + TestRumMapperProxy(RumBeforeSamplingCallback? beforeSampling) + : super( + viewEventMapper: null, + actionEventMapper: null, + resourceEventMapper: null, + errorEventMapper: null, + longTaskEventMapper: null, + vitalOperationEventMapper: null, + beforeSampling: beforeSampling, + ); +} + void main() { const numSamples = 500; late MockInternalLogger mockInternalLogger; @@ -98,6 +113,9 @@ void main() { expect(configuration.appHangThreshold, isNull); expect(configuration.trackAnonymousUser, true); expect(configuration.initialResourceThreshold, 0.1); + expect(configuration.remoteConfigurationEnabled, false); + expect(configuration.beforeSampling, isNull); + expect(configuration.encode()['remoteConfigurationEnabled'], false); }); test('configuration is encoded correctly', () { @@ -120,6 +138,8 @@ void main() { trackBackgroundEvents: true, initialResourceThreshold: 1.23, customEndpoint: customEndpoint, + remoteConfigurationEnabled: true, + beforeSampling: (_) => 100, ); final encoded = configuration.encode(); @@ -135,6 +155,127 @@ void main() { expect(encoded['appHangThreshold'], 0.332); expect(encoded['customEndpoint'], customEndpoint); expect(encoded['initialResourceThreshold'], 1.23); + expect(encoded['remoteConfigurationEnabled'], true); + expect(encoded['attachBeforeSampling'], true); + }); + + test('configuration without beforeSampling does not attach callback', () { + final configuration = DatadogRumConfiguration(applicationId: 'app-id'); + + expect(configuration.encode()['attachBeforeSampling'], false); + }); + + test('beforeSampling receives native context and returns override', () { + RumBeforeSamplingContext? receivedContext; + final proxy = TestRumMapperProxy((context) { + receivedContext = context; + return 42.5; + }); + + final result = proxy.beforeSampling({ + 'sessionSampleRate': 12, + 'custom': { + 'debugUsers': ['user-a'], + }, + }); + + expect(result, 42.5); + expect(receivedContext?.sessionSampleRate, 12.0); + expect(receivedContext?.custom, { + 'debugUsers': ['user-a'], + }); + }); + + test('beforeSampling ignores null, non-finite, and out-of-range values', () { + final encodedContext = { + 'sessionSampleRate': 12.0, + 'custom': null, + }; + + expect( + TestRumMapperProxy((_) => null).beforeSampling(encodedContext), null); + expect( + TestRumMapperProxy((_) => double.nan).beforeSampling(encodedContext), + null, + ); + expect(TestRumMapperProxy((_) => -1).beforeSampling(encodedContext), null); + expect(TestRumMapperProxy((_) => 101).beforeSampling(encodedContext), null); + }); + + test('iOS beforeSampling returns valid callback result', () async { + RumBeforeSamplingContext? receivedContext; + final mapper = IosRumEventMapper( + DatadogRumConfiguration( + applicationId: 'app-id', + beforeSampling: (context) { + receivedContext = context; + return 80; + }, + ), + mockInternalLogger, + ); + + final result = await mapper.handleMethodCall( + const MethodCall('beforeSampling', { + 'context': { + 'sessionSampleRate': 25.0, + 'custom': {'debug': true}, + }, + }), + ); + + expect(result, 80.0); + expect(receivedContext?.sessionSampleRate, 25.0); + expect(receivedContext?.custom, {'debug': true}); + }); + + test('iOS beforeSampling exception keeps native result', () async { + final mapper = IosRumEventMapper( + DatadogRumConfiguration( + applicationId: 'app-id', + beforeSampling: (_) => throw StateError('failed'), + ), + mockInternalLogger, + ); + + final result = await mapper.handleMethodCall( + const MethodCall('beforeSampling', { + 'context': {'sessionSampleRate': 25.0, 'custom': null}, + }), + ); + + expect(result, isNull); + verify( + () => mockInternalLogger.sendToDatadog(any(), any(), any()), + ).called(1); + verify(() => mockInternalLogger.error(any())).called(1); + }); + + test('iOS beforeSampling invalid return keeps native result', () async { + final mapper = IosRumEventMapper( + DatadogRumConfiguration( + applicationId: 'app-id', + beforeSampling: (_) => 101, + ), + mockInternalLogger, + ); + + final result = await mapper.handleMethodCall( + const MethodCall('beforeSampling', { + 'context': {'sessionSampleRate': 25.0, 'custom': null}, + }), + ); + + expect(result, isNull); + }); + + test('no-op platform ignores forced sessions and has no remote config', + () async { + final platform = DdNoOpRumPlatform(); + + await platform.setForcedSession(); + + expect(await platform.getRemoteConfig(), isNull); }); test('configuration with mapper sets attach*Mapper', () { @@ -396,6 +537,51 @@ void main() { expect(sessionId, fakeSessionId); }); + test('setForcedSession forwards to platform', () async { + DdRumPlatform.instance = mockRumPlatform; + when( + () => mockRumPlatform.enable(any(), any()), + ).thenAnswer((_) => Future.value()); + when( + () => mockRumPlatform.setForcedSession(), + ).thenAnswer((_) => Future.value()); + final rum = await DatadogRum.enable( + mockDatadogSdk, + DatadogRumConfiguration( + applicationId: 'applicationId', + detectLongTasks: false, + ), + ); + + rum!.setForcedSession(); + + verify(() => mockRumPlatform.setForcedSession()).called(1); + }); + + test('getRemoteConfig returns custom values from platform', () async { + final remoteConfig = { + 'debugUsers': ['user-a'], + }; + DdRumPlatform.instance = mockRumPlatform; + when( + () => mockRumPlatform.enable(any(), any()), + ).thenAnswer((_) => Future.value()); + when( + () => mockRumPlatform.getRemoteConfig(), + ).thenAnswer((_) => Future.value(remoteConfig)); + final rum = await DatadogRum.enable( + mockDatadogSdk, + DatadogRumConfiguration( + applicationId: 'applicationId', + detectLongTasks: false, + ), + ); + + final result = await rum!.getRemoteConfig(); + + expect(result, remoteConfig); + }); + test('addAttribute with null calls remove attribute instead', () async { // Given DdRumPlatform.instance = mockRumPlatform; diff --git a/packages/datadog_tracking_http_client/CHANGELOG.md b/packages/datadog_tracking_http_client/CHANGELOG.md index e87dc0cc6..29a0a1e70 100644 --- a/packages/datadog_tracking_http_client/CHANGELOG.md +++ b/packages/datadog_tracking_http_client/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## 0.1.2 + +* Support `flashcat_flutter_plugin` 0.2.0. + ## 0.1.1 * Fix RUM resources never being reported when the response body is consumed diff --git a/packages/datadog_tracking_http_client/pubspec.yaml b/packages/datadog_tracking_http_client/pubspec.yaml index 02a8c210c..2f483798c 100644 --- a/packages/datadog_tracking_http_client/pubspec.yaml +++ b/packages/datadog_tracking_http_client/pubspec.yaml @@ -1,6 +1,6 @@ name: flashcat_tracking_http_client description: A wrapping implementation of HttpClient for tracking resources with FlashCat -version: 0.1.1 +version: 0.1.2 homepage: https://flashcat.cloud repository: https://github.com/flashcatcloud/fc-sdk-flutter @@ -11,7 +11,7 @@ environment: dependencies: flutter: sdk: flutter - flashcat_flutter_plugin: ^0.1.0 + flashcat_flutter_plugin: ^0.2.0 uuid: ^4.0.0 http: ^1.0.0 diff --git a/packages/datadog_webview_tracking/CHANGELOG.md b/packages/datadog_webview_tracking/CHANGELOG.md index d0bf59f0b..87f7639be 100644 --- a/packages/datadog_webview_tracking/CHANGELOG.md +++ b/packages/datadog_webview_tracking/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 0.1.1 + +* Support `flashcat_flutter_plugin` 0.2.0. +* Upgrade the native FlashCat SDKs to Android 0.7.0 and iOS 0.6.0. +* Align the CocoaPods package version with the Dart package version. + ## 0.1.0 * First FlashCat release (forked from `datadog_webview_tracking`). diff --git a/packages/datadog_webview_tracking/README.md b/packages/datadog_webview_tracking/README.md index 8f865af60..3efee7f90 100644 --- a/packages/datadog_webview_tracking/README.md +++ b/packages/datadog_webview_tracking/README.md @@ -16,8 +16,8 @@ Add both the `flashcat_webview_tracking` package and the `webview_flutter` packa ```yaml dependencies: webview_flutter: ^4.0.4 - flashcat_flutter_plugin: ^0.1.0 - flashcat_webview_tracking: ^0.1.0 + flashcat_flutter_plugin: ^0.2.0 + flashcat_webview_tracking: ^0.1.1 ``` To add Web View Tracking, call the `trackDatadogEvents` extension method on `WebViewController`, providing the list of allowed hosts. diff --git a/packages/datadog_webview_tracking/android/build.gradle b/packages/datadog_webview_tracking/android/build.gradle index 3ccbf68a5..6d69b5f1c 100644 --- a/packages/datadog_webview_tracking/android/build.gradle +++ b/packages/datadog_webview_tracking/android/build.gradle @@ -3,7 +3,7 @@ version '1.0-SNAPSHOT' buildscript { ext.kotlin_version = '2.1.0' - ext.datadog_version = "0.4.1" + ext.datadog_version = "0.7.0" repositories { google() diff --git a/packages/datadog_webview_tracking/example/pubspec.yaml b/packages/datadog_webview_tracking/example/pubspec.yaml index bddb23919..520761350 100644 --- a/packages/datadog_webview_tracking/example/pubspec.yaml +++ b/packages/datadog_webview_tracking/example/pubspec.yaml @@ -9,7 +9,7 @@ dependencies: flutter: sdk: flutter - flashcat_flutter_plugin: ^0.1.0 + flashcat_flutter_plugin: ^0.2.0 flashcat_webview_tracking: path: ../ webview_flutter: ^4.0.4 @@ -33,4 +33,4 @@ flutter: dependency_overrides: flashcat_flutter_plugin: - path: ../../datadog_flutter_plugin \ No newline at end of file + path: ../../datadog_flutter_plugin diff --git a/packages/datadog_webview_tracking/ios/flashcat_webview_tracking.podspec b/packages/datadog_webview_tracking/ios/flashcat_webview_tracking.podspec index 57d58efae..16c7ad762 100644 --- a/packages/datadog_webview_tracking/ios/flashcat_webview_tracking.podspec +++ b/packages/datadog_webview_tracking/ios/flashcat_webview_tracking.podspec @@ -4,7 +4,7 @@ # Pod::Spec.new do |s| s.name = 'flashcat_webview_tracking' - s.version = '0.0.1' + s.version = '0.1.1' s.summary = 'A Flutter plugin for Datadog webview tracking.' s.description = <<-DESC A Flutter plugin for use with the Datadog Flutter Plugin to track webviews as part of a user's mobile session. @@ -15,8 +15,8 @@ A Flutter plugin for use with the Datadog Flutter Plugin to track webviews as pa s.source = { :path => '.' } s.source_files = 'flashcat_webview_tracking/Sources/**/*' s.dependency 'Flutter' - s.dependency 'FlashcatCore', '~> 0.5' - s.dependency 'FlashcatWebViewTracking', '~> 0.5' + s.dependency 'FlashcatCore', '~> 0.6.0' + s.dependency 'FlashcatWebViewTracking', '~> 0.6.0' s.dependency 'webview_flutter_wkwebview' s.platform = :ios, '13.0' diff --git a/packages/datadog_webview_tracking/ios/flashcat_webview_tracking/Package.swift b/packages/datadog_webview_tracking/ios/flashcat_webview_tracking/Package.swift index adb2f132b..24599691f 100644 --- a/packages/datadog_webview_tracking/ios/flashcat_webview_tracking/Package.swift +++ b/packages/datadog_webview_tracking/ios/flashcat_webview_tracking/Package.swift @@ -12,7 +12,7 @@ let package = Package( .library(name: "flashcat-webview-tracking", targets: ["flashcat_webview_tracking"]) ], dependencies: [ - .package(url: "https://github.com/flashcatcloud/fc-sdk-ios.git", exact: "0.5.0") + .package(url: "https://github.com/flashcatcloud/fc-sdk-ios.git", exact: "0.6.0") ], targets: [ .target( diff --git a/packages/datadog_webview_tracking/pubspec.yaml b/packages/datadog_webview_tracking/pubspec.yaml index 61332d136..0266ae11c 100644 --- a/packages/datadog_webview_tracking/pubspec.yaml +++ b/packages/datadog_webview_tracking/pubspec.yaml @@ -1,6 +1,6 @@ name: flashcat_webview_tracking description: A package for tracking FlashCat sessions in a webview -version: 0.1.0 +version: 0.1.1 homepage: https://flashcat.cloud repository: https://github.com/flashcatcloud/fc-sdk-flutter @@ -11,7 +11,7 @@ environment: dependencies: flutter: sdk: flutter - flashcat_flutter_plugin: ^0.1.0 + flashcat_flutter_plugin: ^0.2.0 webview_flutter: ^4.0.4 webview_flutter_android: ^3.8.2 webview_flutter_wkwebview: ^3.18.0