diff --git a/.github/ISSUE_TEMPLATE/BugReport.yml b/.github/ISSUE_TEMPLATE/BugReport.yml index f9d52f0dbf..9ecf0b0f42 100644 --- a/.github/ISSUE_TEMPLATE/BugReport.yml +++ b/.github/ISSUE_TEMPLATE/BugReport.yml @@ -5,7 +5,7 @@ body: - type: markdown attributes: value: | - Ensure you go through our [troubleshooting](https://docs.datadoghq.com/real_user_monitoring/mobile_and_tv_monitoring/troubleshooting/#debugging) page before creating a new issue. + Ensure you go through our [troubleshooting](https://docs.datadoghq.com/real_user_monitoring/mobile_and_tv_monitoring/troubleshooting/android/#debugging) page before creating a new issue. Before getting started, if the problem is urgent or easier to investigate with access to your organization's data please use our [official support channel](https://www.datadoghq.com/support/). - type: textarea id: description @@ -19,8 +19,8 @@ body: attributes: label: Reproduction steps description: | - Provide a self-contained piece of code demonstrating the bug. - For a more complex setup consider creating a small app that showcases the problem. + Provide a self-contained piece of code demonstrating the bug. + For a more complex setup consider creating a small app that showcases the problem. **Note** - Avoid sharing any business logic, credentials or tokens. validations: required: true @@ -29,7 +29,7 @@ body: attributes: label: Logcat logs description: | - Please provide Logcat logs before, during and after the bug occurs. + Please provide Logcat logs before, during and after the bug occurs. validations: required: false - type: textarea @@ -43,7 +43,7 @@ body: id: affected_sdk_versions attributes: label: Affected SDK versions - description: What are the SDK versions you're seeing this bug in? + description: What are the SDK versions you're seeing this bug in? validations: required: true - type: input @@ -54,7 +54,7 @@ body: validations: required: true - type: dropdown - id: checked_lastest_sdk + id: checked_latest_sdk attributes: label: Did you confirm if the latest SDK version fixes the bug? options: @@ -65,24 +65,24 @@ body: - type: input id: kotlin_java_version attributes: - label: Kotlin / Java version + label: Kotlin / Java version - type: input id: gradle_version attributes: - label: Gradle / AGP version + label: Gradle / AGP version - type: textarea id: dependencies attributes: - label: Other dependencies versions + label: Other dependencies versions description: | - Relevant third party dependency versions. + Relevant third party dependency versions. e.g. okhttp 4.11.0 - type: textarea id: device_info attributes: label: Device Information description: | - What are the common characteristics of devices you're seeing this bug in. + What are the common characteristics of devices you're seeing this bug in. Specific models, OS versions, network state (wifi / cellular / offline), power state (plugged in / battery), etc. validations: required: false @@ -91,5 +91,5 @@ body: attributes: label: Other relevant information description: | - Other relevant information such as additional tooling in place, proxies, etc. - Anything that might be relevant for troubleshooting this bug. + Other relevant information such as additional tooling in place, proxies, etc. + Anything that might be relevant for troubleshooting this bug. diff --git a/.github/ISSUE_TEMPLATE/CrashReport.yml b/.github/ISSUE_TEMPLATE/CrashReport.yml index abe04c9b61..60962462b0 100644 --- a/.github/ISSUE_TEMPLATE/CrashReport.yml +++ b/.github/ISSUE_TEMPLATE/CrashReport.yml @@ -1,11 +1,11 @@ name: Crash Report -description: Report crashes caused by the SDK. +description: Report crashes caused by the SDK. labels: ["crash"] body: - type: markdown attributes: value: | - Report crashes caused by the SDK. Please try to be as detailed as possible. + Report crashes caused by the SDK. Please try to be as detailed as possible. Before getting started, if the problem is urgent please use our [official support channel](https://www.datadoghq.com/support/). - type: textarea id: stacktrace @@ -19,8 +19,8 @@ body: attributes: label: Reproduction steps description: | - Provide a self-contained piece of code demonstrating the crash if you can. - For a more complex setup consider creating a small app that showcases the problem. + Provide a self-contained piece of code demonstrating the crash if you can. + For a more complex setup consider creating a small app that showcases the problem. **Note** - Avoid sharing any business logic, credentials or tokens. validations: required: false @@ -35,7 +35,7 @@ body: id: affected_sdk_versions attributes: label: Affected SDK versions - description: What are the SDK versions you're seeing this crash in? + description: What are the SDK versions you're seeing this crash in? validations: required: true - type: input @@ -46,7 +46,7 @@ body: validations: required: true - type: dropdown - id: checked_lastest_sdk + id: checked_latest_sdk attributes: label: Does the crash manifest in the latest SDK version? options: @@ -57,24 +57,24 @@ body: - type: input id: kotlin_java_version attributes: - label: Kotlin / Java version + label: Kotlin / Java version - type: input id: gradle_version attributes: - label: Gradle / AGP version + label: Gradle / AGP version - type: textarea id: dependencies attributes: - label: Other dependencies versions + label: Other dependencies versions description: | - Relevant third party dependency versions. + Relevant third party dependency versions. e.g. okhttp 4.11.0 - type: textarea id: device_info attributes: label: Device Information description: | - What are the common characteristics of devices you're seeing this crash in? + What are the common characteristics of devices you're seeing this crash in? Specific models, OS versions, etc. validations: required: false @@ -82,4 +82,4 @@ body: id: other_info attributes: label: Other relevant information - description: Anything that might be relevant to pinpoint the source of the crash. + description: Anything that might be relevant to pinpoint the source of the crash. diff --git a/.github/ISSUE_TEMPLATE/FeatureRequest.yml b/.github/ISSUE_TEMPLATE/FeatureRequest.yml index 3a254d22aa..83f7729da8 100644 --- a/.github/ISSUE_TEMPLATE/FeatureRequest.yml +++ b/.github/ISSUE_TEMPLATE/FeatureRequest.yml @@ -5,7 +5,7 @@ body: - type: textarea id: description attributes: - label: Feature description + label: Feature description description: | Provide a description for the feature request. Please include: 1. Use case @@ -19,7 +19,7 @@ body: label: Proposed solution description: | How would you implement this? - Propose an idea, solution or reference implementation. + Propose an idea, solution or reference implementation. validations: required: false - type: textarea @@ -29,4 +29,3 @@ body: description: Any other relevant information you'd like we take into consideration. validations: required: false - diff --git a/.github/ISSUE_TEMPLATE/Question.yml b/.github/ISSUE_TEMPLATE/Question.yml index 0f286a3568..3c04ee56b1 100644 --- a/.github/ISSUE_TEMPLATE/Question.yml +++ b/.github/ISSUE_TEMPLATE/Question.yml @@ -5,6 +5,6 @@ body: - type: textarea id: question attributes: - label: Question + label: Question validations: required: true diff --git a/.github/ISSUE_TEMPLATE/SetupIssue.yml b/.github/ISSUE_TEMPLATE/SetupIssue.yml index e5d072c4b2..074c58f740 100644 --- a/.github/ISSUE_TEMPLATE/SetupIssue.yml +++ b/.github/ISSUE_TEMPLATE/SetupIssue.yml @@ -5,12 +5,12 @@ body: - type: markdown attributes: value: | - Before creating an issue, please ensure you go through the [troubleshooting page](https://docs.datadoghq.com/real_user_monitoring/mobile_and_tv_monitoring/troubleshooting/#debugging). + Before creating an issue, please ensure you go through the [troubleshooting page](https://docs.datadoghq.com/real_user_monitoring/mobile_and_tv_monitoring/troubleshooting/android/#debugging). - type: textarea id: issue attributes: - label: Describe the issue - description: Provide a clear and concise description of the issue. Include compilation logs and SDK debug logs if relevant. + label: Describe the issue + description: Provide a clear and concise description of the issue. Include compilation logs and SDK debug logs if relevant. validations: required: true - type: textarea @@ -19,7 +19,7 @@ body: label: Reproduction steps description: | Provide a self-contained piece of code demonstrating the issue. - For a more complex setup consider creating a small app that showcases the problem. + For a more complex setup consider creating a small app that showcases the problem. **Note** - Avoid sharing any business logic, credentials or tokens. validations: required: true @@ -28,7 +28,7 @@ body: attributes: label: Device Information description: | - What are the common characteristics of devices you're seeing this issue in? + What are the common characteristics of devices you're seeing this issue in? Simulators, specific models, OS versions, network state (wifi / cellular / offline), power state (plugged in / battery), etc. validations: required: false @@ -42,17 +42,17 @@ body: - type: input id: kotlin_java_version attributes: - label: Kotlin / Java version + label: Kotlin / Java version - type: input id: gradle_version attributes: - label: Gradle / AGP version + label: Gradle / AGP version - type: textarea id: dependencies attributes: - label: Other dependencies versions + label: Other dependencies versions description: | - Relevant third party dependency versions. + Relevant third party dependency versions. e.g. okhttp 4.11.0 - type: textarea id: other_info @@ -60,4 +60,4 @@ body: label: Other relevant information description: | Other relevant information such as additional tooling in place, proxies, etc. - Anything that might be relevant for troubleshooting your setup. + Anything that might be relevant for troubleshooting your setup. diff --git a/.gitignore b/.gitignore index 96021db4d2..bb3700d431 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,4 @@ config/* gh_token sdk_classpath +detekt_classpath diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index aca970c394..60e2bf7cb5 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -4,8 +4,8 @@ include: # SETUP variables: - CURRENT_CI_IMAGE: "10" - CI_IMAGE_DOCKER: 486234852809.dkr.ecr.us-east-1.amazonaws.com/ci/dd-sdk-android:$CURRENT_CI_IMAGE + CURRENT_CI_IMAGE: "14" + CI_IMAGE_DOCKER: registry.ddbuild.io/ci/dd-sdk-android:$CURRENT_CI_IMAGE GIT_DEPTH: 5 DD_SERVICE: "dd-sdk-android" @@ -15,11 +15,11 @@ variables: DD_COMMON_AGENT_CONFIG: "dd.env=ci,dd.trace.enabled=false,dd.jmx.fetch.enabled=false" KUBERNETES_MEMORY_REQUEST: "8Gi" - KUBERNETES_MEMORY_LIMIT: "16Gi" + KUBERNETES_MEMORY_LIMIT: "13Gi" EMULATOR_NAME: "android_emulator" ANDROID_ARCH: "arm64-v8a" - ANDROID_API: "34" + ANDROID_API: "35" ANDROID_SDK_VERSION: "commandlinetools-mac-11076708_latest" stages: @@ -32,16 +32,8 @@ stages: - notify .snippets: - install-android-sdk: - - curl -sSL -o commandlinetools.zip https://dl.google.com/android/repository/$ANDROID_SDK_VERSION.zip - - rm -rf ~/android_sdk - - rm -rf ~/cmdline-tools - - unzip -q commandlinetools -d ~/ - - mkdir -p ~/android_sdk/cmdline-tools/latest - - mv ~/cmdline-tools/* ~/android_sdk/cmdline-tools/latest - - rm ./commandlinetools.zip - - export ANDROID_HOME="$HOME/android_sdk/" - - export ANDROID_SDK_ROOT="$HOME/android_sdk/" + # macOS AMI will already have cmdline-tools installed + install-android-api-components: - echo y | ~/android_sdk/cmdline-tools/latest/bin/sdkmanager --install "emulator" - echo y | ~/android_sdk/cmdline-tools/latest/bin/sdkmanager --install "platform-tools" - echo y | ~/android_sdk/cmdline-tools/latest/bin/sdkmanager --install "$ANDROID_PLATFORM" @@ -53,10 +45,7 @@ stages: - set +e - exit_code=0 - $ANDROID_HOME/emulator/emulator -avd "$EMULATOR_NAME" -grpc-use-jwt -no-snapstorage -no-audio -no-window -no-boot-anim -verbose -qemu -machine virt & - - GRADLE_OPTS="-Xmx3072m" ./gradlew :instrumented:integration:assembleDebug :instrumented:integration:assembleDebugAndroidTest --stacktrace --no-daemon $( (( $ANDROID_API <= 23 )) && echo "-Puse-api21-java-backport -Puse-desugaring" ) - - $ANDROID_HOME/platform-tools/adb install -t -d $( (( $ANDROID_API >= 23 )) && echo "-g" ) -r instrumented/integration/build/outputs/apk/androidTest/debug/integration-debug-androidTest.apk - - $ANDROID_HOME/platform-tools/adb install -t -d $( (( $ANDROID_API >= 23 )) && echo "-g" ) -r instrumented/integration/build/outputs/apk/debug/integration-debug.apk - - $ANDROID_HOME/platform-tools/adb shell am instrument -w com.datadog.android.sdk.integration.test/androidx.test.runner.AndroidJUnitRunner || exit_code=$? + - GRADLE_OPTS="-Xmx3072m" ./gradlew :instrumented:integration:connectedDebugAndroidTest --stacktrace --no-daemon $( (( $ANDROID_API <= 23 )) && echo "-Puse-api21-java-backport -Puse-desugaring" ) || exit_code=$? - $ANDROID_HOME/platform-tools/adb emu kill - if [[ "$exit_code" -ne 0 ]]; then exit 1; fi - exit 0 @@ -110,9 +99,6 @@ static-analysis: stage: analysis variables: DETEKT_PUBLIC_API: "true" - DETEKT_CUSTOM_RULES_BUILD_TASK: "assembleLibrariesRelease :tools:detekt:jar" - DETEKT_CUSTOM_RULES_JAR_PATH: "tools/detekt/build/libs/detekt.jar" - DETEKT_CUSTOM_RULES_YML_PATH: "detekt_custom.yml" DETEKT_GENERATE_CLASSPATH_BUILD_TASK: "printSdkDebugRuntimeClasspath" DETEKT_CLASSPATH_FILE_PATH: "sdk_classpath" FLAVORED_ANDROID_LINT: ":tools:lint:lint" @@ -120,6 +106,42 @@ static-analysis: include: "https://gitlab-templates.ddbuild.io/mobile/v34714656-060be019/static-analysis.yml" strategy: depend +analysis:detekt-custom: + tags: + - "arch:amd64" + image: $CI_IMAGE_DOCKER + stage: analysis + timeout: 1h + script: + - ./gradlew assembleLibrariesRelease --stacktrace + - ./gradlew unzipAarForDetekt --stacktrace + - ./gradlew :tools:detekt:jar --stacktrace + - ./gradlew printDetektClasspath --stacktrace + - curl -sSLO https://github.com/detekt/detekt/releases/download/v1.23.4/detekt-cli-1.23.4-all.jar + - ./gradlew :dd-sdk-android-core:customDetektRules + - ./gradlew :dd-sdk-android-internal:customDetektRules + - ./gradlew :features:dd-sdk-android-logs:customDetektRules + - ./gradlew :features:dd-sdk-android-ndk:customDetektRules + - ./gradlew :features:dd-sdk-android-rum:customDetektRules + - ./gradlew :features:dd-sdk-android-session-replay:customDetektRules + - ./gradlew :features:dd-sdk-android-session-replay-material:customDetektRules + - ./gradlew :features:dd-sdk-android-trace:customDetektRules + - ./gradlew :features:dd-sdk-android-trace-otel:customDetektRules + - ./gradlew :features:dd-sdk-android-webview:customDetektRules + - ./gradlew :integrations:dd-sdk-android-coil:customDetektRules + - ./gradlew :integrations:dd-sdk-android-compose:customDetektRules + - ./gradlew :integrations:dd-sdk-android-fresco:customDetektRules + - ./gradlew :integrations:dd-sdk-android-glide:customDetektRules + - ./gradlew :integrations:dd-sdk-android-okhttp:customDetektRules + - ./gradlew :integrations:dd-sdk-android-okhttp-otel:customDetektRules + - ./gradlew :integrations:dd-sdk-android-rum-coroutines:customDetektRules + - ./gradlew :integrations:dd-sdk-android-rx:customDetektRules + - ./gradlew :integrations:dd-sdk-android-sqldelight:customDetektRules + - ./gradlew :integrations:dd-sdk-android-timber:customDetektRules + - ./gradlew :integrations:dd-sdk-android-trace-coroutines:customDetektRules + - ./gradlew :integrations:dd-sdk-android-tv:customDetektRules + + # TODO RUM-1622 cleanup this section # TESTS @@ -137,16 +159,15 @@ test:debug: script: - rm -rf ~/.gradle/daemon/ - export DD_AGENT_HOST="$BUILDENV_HOST_IP" - - GRADLE_OPTS="-Xmx3072m" DD_TAGS="test.configuration.variant:debug" ./gradlew :dd-sdk-android-core:testDebugUnitTest --no-daemon --build-cache --gradle-user-home cache/ -Dorg.gradle.jvmargs=-javaagent:$DD_TRACER_FOLDER/dd-java-agent.jar=$DD_COMMON_AGENT_CONFIG -Dorg.gradle.jvmargs=-XX:+HeapDumpOnOutOfMemoryError - - GRADLE_OPTS="-Xmx3072m" DD_TAGS="test.configuration.variant:debug" ./gradlew :unitTestDebugFeatures --no-daemon --build-cache --gradle-user-home cache/ -Dorg.gradle.jvmargs=-javaagent:$DD_TRACER_FOLDER/dd-java-agent.jar=$DD_COMMON_AGENT_CONFIG -Dorg.gradle.jvmargs=-XX:+HeapDumpOnOutOfMemoryError - - GRADLE_OPTS="-Xmx3072m" DD_TAGS="test.configuration.variant:debug" ./gradlew :unitTestDebugIntegrations --no-daemon --build-cache --gradle-user-home cache/ -Dorg.gradle.jvmargs=-javaagent:$DD_TRACER_FOLDER/dd-java-agent.jar=$DD_COMMON_AGENT_CONFIG -Dorg.gradle.jvmargs=-XX:+HeapDumpOnOutOfMemoryError + - GRADLE_OPTS="-Xmx3072m" DD_TAGS="test.configuration.variant:debug" ./gradlew :dd-sdk-android-core:testDebugUnitTest --no-daemon --build-cache --gradle-user-home cache/ -Dorg.gradle.jvmargs=-javaagent:$DD_TRACER_FOLDER/dd-java-agent.jar=$DD_COMMON_AGENT_CONFIG + - GRADLE_OPTS="-Xmx3072m" DD_TAGS="test.configuration.variant:debug" ./gradlew :dd-sdk-android-internal:testDebugUnitTest --no-daemon --build-cache --gradle-user-home cache/ -Dorg.gradle.jvmargs=-javaagent:$DD_TRACER_FOLDER/dd-java-agent.jar=$DD_COMMON_AGENT_CONFIG + - GRADLE_OPTS="-Xmx3072m" DD_TAGS="test.configuration.variant:debug" ./gradlew :unitTestDebugFeatures --no-daemon --build-cache --gradle-user-home cache/ -Dorg.gradle.jvmargs=-javaagent:$DD_TRACER_FOLDER/dd-java-agent.jar=$DD_COMMON_AGENT_CONFIG + - GRADLE_OPTS="-Xmx3072m" DD_TAGS="test.configuration.variant:debug" ./gradlew :unitTestDebugIntegrations --no-daemon --build-cache --gradle-user-home cache/ -Dorg.gradle.jvmargs=-javaagent:$DD_TRACER_FOLDER/dd-java-agent.jar=$DD_COMMON_AGENT_CONFIG artifacts: when: always expire_in: 1 week reports: junit: "**/build/test-results/testDebugUnitTest/*.xml" - paths: - - "*.hprof" test:tools: tags: [ "arch:amd64" ] @@ -181,23 +202,22 @@ test:kover: - export DD_API_KEY=$(aws ssm get-parameter --region us-east-1 --name ci.dd-sdk-android.api_key --with-decryption --query "Parameter.Value" --out text) - export DD_APP_KEY=$(aws ssm get-parameter --region us-east-1 --name ci.dd-sdk-android.app_key --with-decryption --query "Parameter.Value" --out text) - CODECOV_TOKEN=$(aws ssm get-parameter --region us-east-1 --name ci.dd-sdk-android.codecov-token --with-decryption --query "Parameter.Value" --out text) - - GRADLE_OPTS="-Xmx3072m" DD_TAGS="test.configuration.variant:release" ./gradlew :dd-sdk-android-core:koverXmlReportRelease --no-daemon --build-cache --gradle-user-home cache/ -Dorg.gradle.jvmargs=-javaagent:$DD_TRACER_FOLDER/dd-java-agent.jar=$DD_COMMON_AGENT_CONFIG -Dorg.gradle.jvmargs=-XX:+HeapDumpOnOutOfMemoryError - - GRADLE_OPTS="-Xmx3072m" DD_TAGS="test.configuration.variant:release" ./gradlew :koverReportFeatures --no-daemon --build-cache --gradle-user-home cache/ -Dorg.gradle.jvmargs=-javaagent:$DD_TRACER_FOLDER/dd-java-agent.jar=$DD_COMMON_AGENT_CONFIG -Dorg.gradle.jvmargs=-XX:+HeapDumpOnOutOfMemoryError - - GRADLE_OPTS="-Xmx3072m" DD_TAGS="test.configuration.variant:release" ./gradlew :koverReportIntegrations --no-daemon --build-cache --gradle-user-home cache/ -Dorg.gradle.jvmargs=-javaagent:$DD_TRACER_FOLDER/dd-java-agent.jar=$DD_COMMON_AGENT_CONFIG -Dorg.gradle.jvmargs=-XX:+HeapDumpOnOutOfMemoryError + - GRADLE_OPTS="-Xmx3072m" DD_TAGS="test.configuration.variant:release" ./gradlew :dd-sdk-android-core:koverXmlReportRelease --no-daemon --build-cache --gradle-user-home cache/ -Dorg.gradle.jvmargs=-javaagent:$DD_TRACER_FOLDER/dd-java-agent.jar=$DD_COMMON_AGENT_CONFIG + - GRADLE_OPTS="-Xmx3072m" DD_TAGS="test.configuration.variant:release" ./gradlew :dd-sdk-android-internal:koverXmlReportRelease --no-daemon --build-cache --gradle-user-home cache/ -Dorg.gradle.jvmargs=-javaagent:$DD_TRACER_FOLDER/dd-java-agent.jar=$DD_COMMON_AGENT_CONFIG + - GRADLE_OPTS="-Xmx3072m" DD_TAGS="test.configuration.variant:release" ./gradlew :koverReportFeatures --no-daemon --build-cache --gradle-user-home cache/ -Dorg.gradle.jvmargs=-javaagent:$DD_TRACER_FOLDER/dd-java-agent.jar=$DD_COMMON_AGENT_CONFIG + - GRADLE_OPTS="-Xmx3072m" DD_TAGS="test.configuration.variant:release" ./gradlew :koverReportIntegrations --no-daemon --build-cache --gradle-user-home cache/ -Dorg.gradle.jvmargs=-javaagent:$DD_TRACER_FOLDER/dd-java-agent.jar=$DD_COMMON_AGENT_CONFIG - bash <(cat ./codecov.sh) -t $CODECOV_TOKEN artifacts: when: always expire_in: 1 week reports: junit: "**/build/test-results/testReleaseUnitTest/*.xml" - paths: - - "*.hprof" # TEST PYRAMID # the steps in this section should reflect our test pyramid strategy test-pyramid:core-it-min-api: - tags: [ "macos:sonoma" ] + tags: [ "macos:sonoma", "specific:true" ] stage: test-pyramid timeout: 1h variables: @@ -206,24 +226,24 @@ test-pyramid:core-it-min-api: ANDROID_PLATFORM: "platforms;android-$ANDROID_API" ANDROID_BUILD_TOOLS: "build-tools;$ANDROID_API.0.0" script: - - !reference [.snippets, install-android-sdk] + - !reference [.snippets, install-android-api-components] - !reference [.snippets, run-core-it-instrumented] test-pyramid:core-it-latest-api: - tags: [ "macos:sonoma" ] + tags: [ "macos:sonoma", "specific:true" ] stage: test-pyramid timeout: 1h variables: - ANDROID_API: "34" + ANDROID_API: "35" ANDROID_EMULATOR_IMAGE: "system-images;android-$ANDROID_API;google_apis;${ANDROID_ARCH}" ANDROID_PLATFORM: "platforms;android-$ANDROID_API" ANDROID_BUILD_TOOLS: "build-tools;$ANDROID_API.0.0" script: - - !reference [.snippets, install-android-sdk] + - !reference [.snippets, install-android-api-components] - !reference [.snippets, run-core-it-instrumented] test-pyramid:core-it-median-api: - tags: [ "macos:sonoma" ] + tags: [ "macos:sonoma", "specific:true" ] stage: test-pyramid timeout: 1h variables: @@ -232,7 +252,7 @@ test-pyramid:core-it-median-api: ANDROID_PLATFORM: "platforms;android-$ANDROID_API" ANDROID_BUILD_TOOLS: "build-tools;$ANDROID_API.0.0" script: - - !reference [.snippets, install-android-sdk] + - !reference [.snippets, install-android-api-components] - !reference [.snippets, run-core-it-instrumented] test-pyramid:single-fit-logs: @@ -301,7 +321,7 @@ test-pyramid:single-fit-trace: # RUN INSTRUMENTED TESTS ON MIN API (21), LATEST API (34) and MEDIAN API (28) test-pyramid:legacy-integration-instrumented-min-api: - tags: [ "macos:sonoma" ] + tags: [ "macos:sonoma", "specific:true" ] stage: test-pyramid timeout: 1h variables: @@ -310,24 +330,24 @@ test-pyramid:legacy-integration-instrumented-min-api: ANDROID_PLATFORM: "platforms;android-$ANDROID_API" ANDROID_BUILD_TOOLS: "build-tools;$ANDROID_API.0.0" script: - - !reference [.snippets, install-android-sdk] + - !reference [.snippets, install-android-api-components] - !reference [.snippets, run-legacy-integration-instrumented] test-pyramid:legacy-integration-instrumented-latest-api: - tags: [ "macos:sonoma" ] + tags: [ "macos:sonoma", "specific:true" ] stage: test-pyramid timeout: 1h variables: - ANDROID_API: "34" + ANDROID_API: "35" ANDROID_EMULATOR_IMAGE: "system-images;android-$ANDROID_API;google_apis;${ANDROID_ARCH}" ANDROID_PLATFORM: "platforms;android-$ANDROID_API" ANDROID_BUILD_TOOLS: "build-tools;$ANDROID_API.0.0" script: - - !reference [.snippets, install-android-sdk] + - !reference [.snippets, install-android-api-components] - !reference [.snippets, run-legacy-integration-instrumented] test-pyramid:legacy-integration-instrumented-median-api: - tags: [ "macos:sonoma" ] + tags: [ "macos:sonoma", "specific:true" ] stage: test-pyramid timeout: 1h variables: @@ -336,9 +356,25 @@ test-pyramid:legacy-integration-instrumented-median-api: ANDROID_PLATFORM: "platforms;android-$ANDROID_API" ANDROID_BUILD_TOOLS: "build-tools;$ANDROID_API.0.0" script: - - !reference [.snippets, install-android-sdk] + - !reference [.snippets, install-android-api-components] - !reference [.snippets, run-legacy-integration-instrumented] +test-pyramid:detekt-api-coverage: + tags: [ "arch:amd64" ] + image: $CI_IMAGE_DOCKER + stage: test-pyramid + timeout: 1h + script: + - mkdir -p ./config/ + - aws ssm get-parameter --region us-east-1 --name ci.dd-sdk-android.gradle-properties --with-decryption --query "Parameter.Value" --out text >> ./gradle.properties + - GRADLE_OPTS="-Xmx4096M" ./gradlew assembleLibrariesDebug --stacktrace --no-daemon + - GRADLE_OPTS="-Xmx4096M" ./gradlew printSdkDebugRuntimeClasspath --stacktrace --no-daemon + - GRADLE_OPTS="-Xmx4096M" ./gradlew :tools:detekt:jar --stacktrace --no-daemon + - curl -sSLO https://github.com/detekt/detekt/releases/download/v1.23.4/detekt-cli-1.23.4-all.jar + - java -jar detekt-cli-1.23.4-all.jar --config detekt_test_pyramid.yml --plugins tools/detekt/build/libs/detekt.jar -ex "**/*.kts" --jvm-target 11 -cp $(cat sdk_classpath) + # For now we just print the uncovered apis, eventually we will fail if it's not empty + - grep -v -f apiUsage.log apiSurface.log + test-pyramid:publish-e2e-synthetics: tags: [ "arch:amd64" ] image: $CI_IMAGE_DOCKER @@ -393,7 +429,6 @@ test-pyramid:publish-webview-synthetics: paths: - sample/kotlin/build/outputs/apk/us1/release/kotlin-us1-release.apk - test-pyramid:publish-staging-synthetics: tags: [ "arch:amd64" ] image: $CI_IMAGE_DOCKER @@ -779,9 +814,9 @@ notify:dogfood-app: stage: notify when: on_success script: - - pip3 install GitPython requests - - aws ssm get-parameter --region us-east-1 --name ci.dd-sdk-android.gh_token --with-decryption --query "Parameter.Value" --out text >> ./gh_token - - python3 dogfood.py -v $CI_COMMIT_TAG -t app + - pip3 install GitPython requests + - aws ssm get-parameter --region us-east-1 --name ci.dd-sdk-android.gh_token --with-decryption --query "Parameter.Value" --out text >> ./gh_token + - python3 dogfood.py -v $CI_COMMIT_TAG -t app notify:dogfood-demo: tags: [ "arch:amd64" ] diff --git a/CHANGELOG.md b/CHANGELOG.md index bda28a5c88..7aa3bde4ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,82 @@ +# 2.15.0 / 2024-10-28 + +* [FEATURE] Add `TimeBank` in Session Replay recorder for dynamic optimisation See [#2247](https://github.com/DataDog/dd-sdk-android/pull/2247) +* [FEATURE] Add Session Replay skipped frames count in `session ended` metrics. See [#2256](https://github.com/DataDog/dd-sdk-android/pull/2256) +* [FEATURE] Add a touch privacy override. See [#2334](https://github.com/DataDog/dd-sdk-android/pull/2334) +* [FEATURE] Add precheck conditions when registering the Session Replay feature. See [#2264](https://github.com/DataDog/dd-sdk-android/pull/2264) +* [FEATURE] Add a privacy override for hidden views. See [#2291](https://github.com/DataDog/dd-sdk-android/pull/2291) +* [FEATURE] Add image and textAndInput privacy overrides. See [#2312](https://github.com/DataDog/dd-sdk-android/pull/2312) +* [IMPROVEMENT] Add a dynamic optimization configuration field in `SessionReplayConfiguration`. See [#2259](https://github.com/DataDog/dd-sdk-android/pull/2259) +* [IMPROVEMENT] Use layout text to display `TextView` overflow correctly. See [#2279](https://github.com/DataDog/dd-sdk-android/pull/2279) +* [IMPROVEMENT] Remove the Session Replay `ButtonMapper` border. See [#2280](https://github.com/DataDog/dd-sdk-android/pull/2280) +* [IMPROVEMENT] Force single core for Session Replay. See [#2324](https://github.com/DataDog/dd-sdk-android/pull/2324) +* [IMPROVEMENT] Add a `ViewGroups` Session Replay demo screen in sample app. See [#2285](https://github.com/DataDog/dd-sdk-android/pull/2285) +* [IMPROVEMENT] Run integration tests on API 35 in the testing pyramid. See [#2272](https://github.com/DataDog/dd-sdk-android/pull/2272) +* [IMPROVEMENT] Add `MaterialCardView` support in the Material Session Replay extension. See [#2290](https://github.com/DataDog/dd-sdk-android/pull/2290) +* [IMPROVEMENT] Use an SDK source value in the Session Replay `MobileSegment.source` property. See [#2293](https://github.com/DataDog/dd-sdk-android/pull/2293) +* [IMPROVEMENT] Update the Session Replay schema with a Kotlin Multiplatform source for Mobile segment. See [#2297](https://github.com/DataDog/dd-sdk-android/pull/2297) +* [IMPROVEMENT] Improve test coverage of core unit tests. See [#2294](https://github.com/DataDog/dd-sdk-android/pull/2294) +* [IMPROVEMENT] Improve unit test coverage for RUM, Logs and Trace features. See [#2299](https://github.com/DataDog/dd-sdk-android/pull/2299) +* [IMPROVEMENT] Send retry information into RUM data upload requests. See [#2298](https://github.com/DataDog/dd-sdk-android/pull/2298) +* [IMPROVEMENT] Make the `DataOkHttpUploader` state volatile. See [#2305](https://github.com/DataDog/dd-sdk-android/pull/2305) +* [IMPROVEMENT] Read Session Replay system requirements synchronously with strict mode allowance. See [#2307](https://github.com/DataDog/dd-sdk-android/pull/2307) +* [IMPROVEMENT] Override process importance for Session Replay integration tests. See [#2304](https://github.com/DataDog/dd-sdk-android/pull/2304) +* [IMPROVEMENT] Detekt the api coverage in integration tests. See [#2300](https://github.com/DataDog/dd-sdk-android/pull/2300) +* [IMPROVEMENT] Resolve `PorterDuffColorFilter` case in drawable to color mapper. See [#2319](https://github.com/DataDog/dd-sdk-android/pull/2319) +* [IMPROVEMENT] Prevent obfuscation of Fine Grained Masking enums. See [#2321](https://github.com/DataDog/dd-sdk-android/pull/2321) +* [IMPROVEMENT] Make sure `ConsentAwareFileOrchestrator` is thread safe. See [#2313](https://github.com/DataDog/dd-sdk-android/pull/2313) +* [IMPROVEMENT] Improve RUM integration tests. See [#2317](https://github.com/DataDog/dd-sdk-android/pull/2317) +* [IMPROVEMENT] Add a default sample rate for Session Replay. See [#2323](https://github.com/DataDog/dd-sdk-android/pull/2323) +* [IMPROVEMENT] Remove batch metrics inner sampler to increase sample rate. See [#2328](https://github.com/DataDog/dd-sdk-android/pull/2328) +* [IMPROVEMENT] Add missing integration test for Logs. See [#2330](https://github.com/DataDog/dd-sdk-android/pull/2330) +* [IMPROVEMENT] Update Session Replay integration test payloads. See [#2318](https://github.com/DataDog/dd-sdk-android/pull/2318) +* [MAINTENANCE] Update Datadog Agent to 1.41.0. See [#2331](https://github.com/DataDog/dd-sdk-android/pull/2331) +* [MAINTENANCE] Fix the decompression in Session Replay instrumented tests for API 21. See [#2341](https://github.com/DataDog/dd-sdk-android/pull/2341) +* [MAINTENANCE] Reactivate Session Replay instrumented test for API 21. See [#2342](https://github.com/DataDog/dd-sdk-android/pull/2342) +* [MAINTENANCE] Fix some flaky tests. See [#2281](https://github.com/DataDog/dd-sdk-android/pull/2281) +* [MAINTENANCE] Fix a StrictMode warning regarding I/O disk operation on the main thread. See [#2284](https://github.com/DataDog/dd-sdk-android/pull/2284) +* [MAINTENANCE] Fix flaky feature context integration tests. See [#2295](https://github.com/DataDog/dd-sdk-android/pull/2295) +* [MAINTENANCE] Fix `SeekBarWireframeMapper` flaky test. See [#2308](https://github.com/DataDog/dd-sdk-android/pull/2308) +* [MAINTENANCE] Fix `SpanEventSerializerTest` flakiness. See [#2311](https://github.com/DataDog/dd-sdk-android/pull/2311) +* [MAINTENANCE] Remove an unnecessary legacy privacy line from the sampleApplication. See [#2314](https://github.com/DataDog/dd-sdk-android/pull/2314) +* [MAINTENANCE] Use Java 11 bytecode for public modules. See [#2315](https://github.com/DataDog/dd-sdk-android/pull/2315) +* [MAINTENANCE] Fix RUM integration test `verifyViewEventsOnSwipe`. See [#2326](https://github.com/DataDog/dd-sdk-android/pull/2326) +* [MAINTENANCE] Fix the regression for the `TelemetryErrorEvent` with throwable. See [#2325](https://github.com/DataDog/dd-sdk-android/pull/2325) +* [MAINTENANCE] Fix the execution of legacy instrumentation tests in CI. See [#2329](https://github.com/DataDog/dd-sdk-android/pull/2329) + +# 2.14.0 / 2024-09-25 + +* [FEATURE] Add stop and start APIs for Session Replay. See [#2169](https://github.com/DataDog/dd-sdk-android/pull/2169) +* [FEATURE] Add touch privacy fine grained masking API to Session Replay. See [#2196](https://github.com/DataDog/dd-sdk-android/pull/2196) +* [FEATURE] Add text and input privacy fine grained masking API to Session Replay. See [#2235](https://github.com/DataDog/dd-sdk-android/pull/2235) +* [FEATURE] Introduce the `RumMonitor#addViewLoadingTime` API. See [#2243](https://github.com/DataDog/dd-sdk-android/pull/2243) +* [FEATURE] Introduce the API usage telemetry event and API. See [#2258](https://github.com/DataDog/dd-sdk-android/pull/2258) +* [IMPROVEMENT] Enable Kotlin test fixtures support. See [#2234](https://github.com/DataDog/dd-sdk-android/pull/2234) +* [IMPROVEMENT] Add `isContainer` attribute to session replay span. See [#2244](https://github.com/DataDog/dd-sdk-android/pull/2244) +* [IMPROVEMENT] Update custom detekt CI Job. See [#2118](https://github.com/DataDog/dd-sdk-android/pull/2118) +* [IMPROVEMENT] Randomize privacy levels to support Fine Grained Masking in E2E. See [#2265](https://github.com/DataDog/dd-sdk-android/pull/2265) +* [IMPROVEMENT] Update AGP to 8.6.1. See [#2269](https://github.com/DataDog/dd-sdk-android/pull/2269) +* [IMPROVEMENT] Add telemetry and logs related with `RumMonitor#addViewLoadingTime` API. See [#2267](https://github.com/DataDog/dd-sdk-android/pull/2267) +* [IMPROVEMENT] Handle SSE requests. See [#2270](https://github.com/DataDog/dd-sdk-android/pull/2270) +* [IMPROVEMENT] Do not use magic numbers in `InternalLogger` API. See [#2271](https://github.com/DataDog/dd-sdk-android/pull/2271) +* [IMPROVEMENT] Optimize MD5 byte array to hex string conversion. See [#2273](https://github.com/DataDog/dd-sdk-android/pull/2273) +* [IMPROVEMENT] `CONTRIBUTING` doc changes. See [#2275](https://github.com/DataDog/dd-sdk-android/pull/2275) +* [IMPROVEMENT] Add env tag in benchmark metrics. See [#2276](https://github.com/DataDog/dd-sdk-android/pull/2276) +* [MAINTENANCE] Make image privacy fine grained masking API public in Session Replay. See [#2204](https://github.com/DataDog/dd-sdk-android/pull/2204) +* [MAINTENANCE] Update benchmark metrics memory reader probe interval. See [#2228](https://github.com/DataDog/dd-sdk-android/pull/2228) +* [MAINTENANCE] Fix the flakiness in the `KioskTrackingTest`. See [#2226](https://github.com/DataDog/dd-sdk-android/pull/2226) +* [MAINTENANCE] Fix placeholder dimensions. See [#2248](https://github.com/DataDog/dd-sdk-android/pull/2248) +* [MAINTENANCE] Send fine grained masking instead of legacy privacy in config telemetry. See [#2253](https://github.com/DataDog/dd-sdk-android/pull/2253) +* [MAINTENANCE] Ensure `UploadWorker` uses the SDK instance name. See [#2257](https://github.com/DataDog/dd-sdk-android/pull/2257) +* [MAINTENANCE] Explicitly set `antlr-runtime` transitive dependency version. See [#2261](https://github.com/DataDog/dd-sdk-android/pull/2261) +* [MAINTENANCE] Add the integration tests related with `RumMonitor#addViewLoadingTime` API. See [#2268](https://github.com/DataDog/dd-sdk-android/pull/2268) +* [MAINTENANCE] Fix `DatadogInterceptor` flaky test. See [#2274](https://github.com/DataDog/dd-sdk-android/pull/2274) +* [MAINTENANCE] Fix typos and links in Github issue templates. See [#2277](https://github.com/DataDog/dd-sdk-android/pull/2277) + +# 2.13.1 / 2024-09-09 + +* [BUGFIX] Stop upload worker on upload failure. See [#2242](https://github.com/DataDog/dd-sdk-android/pull/2242) + # 2.13.0 / 2024-09-03 * [FEATURE] Create Benchmark module to collect performance metrics. See [#2141](https://github.com/DataDog/dd-sdk-android/pull/2141) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d690fe547d..c7d0d0da22 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -89,7 +89,7 @@ The whole project is covered by a set of static analysis tools, linters and test Many great ideas for new features come from the community, and we'd be happy to consider yours! -To share your request, you can open an [issue](https://github.com/DataDog/dd-sdk-android/issues/new?labels=enhancement&template=feature_request.md) +To share your request, you can open an [issue](https://github.com/DataDog/dd-sdk-android/issues/new?labels=enhancement&template=FeatureRequest.yml) with the details about what you'd like to see. At a minimum, please provide: - The goal of the new feature; @@ -104,7 +104,7 @@ or UI, contact our support team via https://docs.datadoghq.com/help/ for direct, faster assistance. You may submit bug reports concerning the Datadog SDK for Android by -[opening a Github issue](https://github.com/DataDog/dd-sdk-android/issues/new?labels=bug&template=bug_report.md). +[opening a Github issue](https://github.com/DataDog/dd-sdk-android/issues/new?labels=bug&template=BugReport.yml). At a minimum, please provide: - A description of the problem; @@ -172,14 +172,14 @@ same feature from a Java source code. Our code uses [Detekt](https://detekt.dev/) static analysis with a shared configuration, slightly stricter than the default one. A Detekt check is ran on every on every PR to ensure that all new code follow this rule. -Current Detekt version: 1.22.0 +Current Detekt version: 1.23.4 ### Code style Our coding style is ensured by [KtLint](https://ktlint.github.io/), with the default settings. A KtLint check is ran on every PR to ensure that all new code follow this rule. -Current KtLint version: 0.47.1 +Current KtLint version: 0.50.0 Classes should group their methods in folding regions named after the declaring class. Private methods should be grouped in an `Internal` named folding region. @@ -309,7 +309,7 @@ Here's a test method following those conventions: testedLogger = Logger(mockLogHandler) // When - testedLogger.addAttribute(key, value) + testedLogger.addAttribute(fakeKey, value) testedLogger.v(fakeMessage) // Then @@ -357,5 +357,3 @@ It is recommended to use Closed Box testing as much as possible. To ensure that our tests cover the widest range of possible states and inputs, we use property based testing thanks to the Elmyr library. Given a unit under test, we must make sure that the whole range of possible input is covered for all tests. - - diff --git a/Dockerfile.gitlab b/Dockerfile.gitlab index f0ab16f0a7..0753d95d33 100644 --- a/Dockerfile.gitlab +++ b/Dockerfile.gitlab @@ -25,13 +25,13 @@ RUN set -x \ && apt-get -y clean \ && rm -rf /var/lib/apt/lists/* -ENV GRADLE_VERSION 8.9 -ENV ANDROID_COMPILE_SDK 34 -ENV ANDROID_BUILD_TOOLS 34.0.0 +ENV GRADLE_VERSION 8.10.2 +ENV ANDROID_COMPILE_SDK 35 +ENV ANDROID_BUILD_TOOLS 35.0.0 ENV ANDROID_SDK_TOOLS 11076708 ENV NDK_VERSION 25.1.8937393 ENV CMAKE_VERSION 3.22.1 -ENV DD_TRACER_VERSION 1.26.1 +ENV DD_TRACER_VERSION 1.41.0 # requires build with BuildKit to be available https://docs.docker.com/build/building/variables/#multi-platform-build-arguments ARG TARGETARCH ENV JAVA_HOME=/usr/lib/jvm/java-17-openjdk-${TARGETARCH} diff --git a/build.gradle.kts b/build.gradle.kts index 3bbe1a136b..9987551f03 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -137,6 +137,10 @@ registerSubModuleAggregationTask("koverReportAll", "koverXmlReportRelease") registerSubModuleAggregationTask("koverReportFeatures", "koverXmlReportRelease", ":features:") registerSubModuleAggregationTask("koverReportIntegrations", "koverXmlReportRelease", ":integrations:") +registerSubModuleAggregationTask("printDetektClasspathAll", "printDetektClasspath") +registerSubModuleAggregationTask("printDetektClasspathFeatures", "printDetektClasspath", ":features:") +registerSubModuleAggregationTask("printDetektClasspathIntegrations", "printDetektClasspath", ":integrations:") + tasks.register("instrumentTestAll") { dependsOn(":instrumented:integration:connectedCheck") } diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts index 76991d4eb0..d8a1c375d4 100644 --- a/buildSrc/build.gradle.kts +++ b/buildSrc/build.gradle.kts @@ -39,6 +39,7 @@ dependencies { // check api surface implementation(libs.kotlinGrammarParser) + implementation(libs.kotlinAntlrRuntime) // JsonSchema 2 Poko implementation(libs.gson) diff --git a/buildSrc/src/main/kotlin/com/datadog/gradle/config/AndroidConfig.kt b/buildSrc/src/main/kotlin/com/datadog/gradle/config/AndroidConfig.kt index 8bef196d37..86f1134c4a 100644 --- a/buildSrc/src/main/kotlin/com/datadog/gradle/config/AndroidConfig.kt +++ b/buildSrc/src/main/kotlin/com/datadog/gradle/config/AndroidConfig.kt @@ -15,12 +15,12 @@ import org.gradle.api.Project object AndroidConfig { - const val TARGET_SDK = 34 + const val TARGET_SDK = 35 const val MIN_SDK = 21 const val MIN_SDK_FOR_WEAR = 23 - const val BUILD_TOOLS_VERSION = "34.0.0" + const val BUILD_TOOLS_VERSION = "35.0.0" - val VERSION = Version(2, 14, 0, Version.Type.Snapshot) + val VERSION = Version(2, 16, 0, Version.Type.Snapshot) } // TODO RUM-628 Switch to Java 17 bytecode diff --git a/buildSrc/src/main/kotlin/com/datadog/gradle/config/DetektCustomConfig.kt b/buildSrc/src/main/kotlin/com/datadog/gradle/config/DetektCustomConfig.kt new file mode 100644 index 0000000000..e7d525be96 --- /dev/null +++ b/buildSrc/src/main/kotlin/com/datadog/gradle/config/DetektCustomConfig.kt @@ -0,0 +1,115 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.datadog.gradle.config + +import com.android.build.gradle.LibraryExtension +import org.gradle.api.Project +import org.gradle.api.file.FileCollection +import org.gradle.api.file.FileTree +import org.gradle.api.internal.file.UnionFileTree +import org.gradle.api.internal.tasks.DefaultTaskDependencyFactory +import org.gradle.api.tasks.Copy +import org.gradle.api.tasks.JavaExec +import java.io.File +import java.util.Properties + +fun Project.detektCustomConfig( + vararg moduleDependencies: String +) { + val ext = extensions.findByType(LibraryExtension::class.java) + + tasks.register("printDetektClasspath") { + group = "datadog" + + doLast { + val fileTreeClassPathCollector = UnionFileTree( + DefaultTaskDependencyFactory.withNoAssociatedProject() + ) + val nonFileTreeClassPathCollector = mutableListOf() + + val classpath = ext?.libraryVariants.orEmpty() + .filter { it.name == "jvmDebug" || it.name == "debug" } + .map { libVariant -> + // returns also test part of classpath for now, no idea how to filter it out + libVariant.getCompileClasspath(null).filter { it.exists() } + } + .firstOrNull() + + if (classpath is FileTree) { + fileTreeClassPathCollector.addToUnion(classpath) + } else if (classpath != null) { + nonFileTreeClassPathCollector += classpath + } + + val fileCollections = mutableListOf() + fileCollections.addAll(nonFileTreeClassPathCollector) + if (!fileTreeClassPathCollector.isEmpty) { + fileCollections.add(fileTreeClassPathCollector) + } + val result = fileCollections.flatMap { + it.files + }.toMutableSet() + val localPropertiesFile = File(project.rootDir, "local.properties") + if (localPropertiesFile.exists()) { + val localProperties = Properties().apply { + localPropertiesFile.inputStream().use { load(it) } + } + val sdkDirPath = localProperties["sdk.dir"] + val androidJarFilePath = listOf( + sdkDirPath, + "platforms", + "android-${AndroidConfig.TARGET_SDK}", + "android.jar" + ) + result += File(androidJarFilePath.joinToString(File.separator)) + } + val envSdkHome = System.getenv("ANDROID_SDK_ROOT") + if (!envSdkHome.isNullOrBlank()) { + val androidJarFilePath = listOf( + envSdkHome, + "platforms", + "android-${AndroidConfig.TARGET_SDK}", + "android.jar" + ) + result += File(androidJarFilePath.joinToString(File.separator)) + } + + val output = result.joinToString(File.pathSeparator) { it.absolutePath } + File(projectDir, "detekt_classpath").writeText(output) + } + } + + tasks.register("unzipAarForDetekt", Copy::class.java) { + from(zipTree(layout.buildDirectory.file("outputs/aar/${project.name}-release.aar"))) + into(layout.buildDirectory.dir("extracted")) + } + + tasks.register("customDetektRules", JavaExec::class.java) { + group = "datadog" + + classpath = files("${rootDir.absolutePath}/detekt-cli-1.23.4-all.jar") + + args("--config", "${rootDir.absolutePath}/detekt_custom.yml") + args("--plugins", "${rootDir.absolutePath}/tools/detekt/build/libs/detekt.jar") + args("-i", projectDir.absolutePath) + args("-ex", "**/*.kts") + args("--jvm-target", "11") + + val externalDependencies = File("${projectDir.absolutePath}/detekt_classpath").readText() + val moduleDependenciesClasses = moduleDependencies.map { + "${rootDir.absolutePath}${it.replace(':', '/')}/build/extracted/classes.jar" + }.joinToString(":") + + val dependencies = if (moduleDependenciesClasses.isBlank()) { + externalDependencies + } else { + "$externalDependencies:$moduleDependenciesClasses" + } + + args("-cp", dependencies) + } +} diff --git a/buildSrc/src/main/kotlin/com/datadog/gradle/config/TestPyramidConfig.kt b/buildSrc/src/main/kotlin/com/datadog/gradle/config/TestPyramidConfig.kt index 062149f7f6..247c537863 100644 --- a/buildSrc/src/main/kotlin/com/datadog/gradle/config/TestPyramidConfig.kt +++ b/buildSrc/src/main/kotlin/com/datadog/gradle/config/TestPyramidConfig.kt @@ -19,7 +19,6 @@ fun Project.registerSubModuleAggregationTask( ) { tasks.register(taskName) { project.subprojects.forEach { subProject -> - println("SubProject ${subProject.name} / ${subProject.path}") if (!exceptions.contains(subProject.name) && subProject.name.startsWith(subModuleNamePrefix) && subProject.path.startsWith(subModulePathPrefix) diff --git a/dd-sdk-android-core/api/apiSurface b/dd-sdk-android-core/api/apiSurface index fa1c9f1256..adce3a9d62 100644 --- a/dd-sdk-android-core/api/apiSurface +++ b/dd-sdk-android-core/api/apiSurface @@ -44,12 +44,14 @@ interface com.datadog.android.api.InternalLogger fun log(Level, List, () -> String, Throwable? = null, Boolean = false, Map? = null) fun logMetric(() -> String, Map, Float) fun startPerformanceMeasure(String, com.datadog.android.core.metrics.TelemetryMetricType, Float, String): com.datadog.android.core.metrics.PerformanceMetric? + fun logApiUsage(com.datadog.android.internal.telemetry.InternalTelemetryEvent.ApiUsage, Float = DEFAULT_API_USAGE_TELEMETRY_SAMPLING_RATE) companion object val UNBOUND: InternalLogger interface com.datadog.android.api.SdkCore val name: String val time: com.datadog.android.api.context.TimeInfo val service: String + fun isCoreActive(): Boolean fun setTrackingConsent(com.datadog.android.privacy.TrackingConsent) fun setUserInfo(String? = null, String? = null, String? = null, Map = emptyMap()) fun addUserProperties(Map) @@ -124,8 +126,10 @@ interface com.datadog.android.api.feature.StorageBackedFeature : Feature val storageConfiguration: com.datadog.android.api.storage.FeatureStorageConfiguration data class com.datadog.android.api.net.Request constructor(String, String, String, Map, ByteArray, String? = null) +data class com.datadog.android.api.net.RequestExecutionContext + constructor(Int = 0, Int? = null) interface com.datadog.android.api.net.RequestFactory - fun create(com.datadog.android.api.context.DatadogContext, List, ByteArray?): Request? + fun create(com.datadog.android.api.context.DatadogContext, RequestExecutionContext, List, ByteArray?): Request? companion object const val CONTENT_TYPE_JSON: String const val CONTENT_TYPE_TEXT_UTF8: String @@ -135,6 +139,7 @@ interface com.datadog.android.api.net.RequestFactory const val HEADER_REQUEST_ID: String const val QUERY_PARAM_SOURCE: String const val QUERY_PARAM_TAGS: String + const val DD_IDEMPOTENCY_KEY: String interface com.datadog.android.api.storage.DataWriter fun write(EventBatchWriter, T, EventType): Boolean interface com.datadog.android.api.storage.EventBatchWriter @@ -184,6 +189,7 @@ class com.datadog.android.core.SdkReference constructor(String? = null, (com.datadog.android.api.SdkCore) -> Unit = {}) fun get(): com.datadog.android.api.SdkCore? fun allowThreadDiskReads(() -> T): T +fun allowThreadDiskWrites(() -> T): T enum com.datadog.android.core.configuration.BackPressureMitigation - DROP_OLDEST - IGNORE_NEWEST @@ -265,6 +271,7 @@ interface com.datadog.android.core.internal.persistence.Deserializer? fun java.io.File.readTextSafe(java.nio.charset.Charset = Charsets.UTF_8, com.datadog.android.api.InternalLogger): String? fun java.io.File.readLinesSafe(java.nio.charset.Charset = Charsets.UTF_8, com.datadog.android.api.InternalLogger): List? interface com.datadog.android.core.internal.system.BuildSdkVersionProvider @@ -284,7 +291,6 @@ fun Long.toHexString(): String fun java.math.BigInteger.toHexString(): String fun Thread.State.asString(): String fun Array.loggableStackTrace(): String -fun Throwable.loggableStackTrace(): String enum com.datadog.android.core.metrics.MethodCallSamplingRate constructor(Float) - ALL diff --git a/dd-sdk-android-core/api/dd-sdk-android-core.api b/dd-sdk-android-core/api/dd-sdk-android-core.api index c4959aa531..225d17131b 100644 --- a/dd-sdk-android-core/api/dd-sdk-android-core.api +++ b/dd-sdk-android-core/api/dd-sdk-android-core.api @@ -77,6 +77,7 @@ public abstract interface class com/datadog/android/api/InternalLogger { public static final field Companion Lcom/datadog/android/api/InternalLogger$Companion; public abstract fun log (Lcom/datadog/android/api/InternalLogger$Level;Lcom/datadog/android/api/InternalLogger$Target;Lkotlin/jvm/functions/Function0;Ljava/lang/Throwable;ZLjava/util/Map;)V public abstract fun log (Lcom/datadog/android/api/InternalLogger$Level;Ljava/util/List;Lkotlin/jvm/functions/Function0;Ljava/lang/Throwable;ZLjava/util/Map;)V + public abstract fun logApiUsage (Lcom/datadog/android/internal/telemetry/InternalTelemetryEvent$ApiUsage;F)V public abstract fun logMetric (Lkotlin/jvm/functions/Function0;Ljava/util/Map;F)V public abstract fun startPerformanceMeasure (Ljava/lang/String;Lcom/datadog/android/core/metrics/TelemetryMetricType;FLjava/lang/String;)Lcom/datadog/android/core/metrics/PerformanceMetric; } @@ -88,6 +89,7 @@ public final class com/datadog/android/api/InternalLogger$Companion { public final class com/datadog/android/api/InternalLogger$DefaultImpls { public static synthetic fun log$default (Lcom/datadog/android/api/InternalLogger;Lcom/datadog/android/api/InternalLogger$Level;Lcom/datadog/android/api/InternalLogger$Target;Lkotlin/jvm/functions/Function0;Ljava/lang/Throwable;ZLjava/util/Map;ILjava/lang/Object;)V public static synthetic fun log$default (Lcom/datadog/android/api/InternalLogger;Lcom/datadog/android/api/InternalLogger$Level;Ljava/util/List;Lkotlin/jvm/functions/Function0;Ljava/lang/Throwable;ZLjava/util/Map;ILjava/lang/Object;)V + public static synthetic fun logApiUsage$default (Lcom/datadog/android/api/InternalLogger;Lcom/datadog/android/internal/telemetry/InternalTelemetryEvent$ApiUsage;FILjava/lang/Object;)V } public final class com/datadog/android/api/InternalLogger$Level : java/lang/Enum { @@ -114,6 +116,7 @@ public abstract interface class com/datadog/android/api/SdkCore { public abstract fun getName ()Ljava/lang/String; public abstract fun getService ()Ljava/lang/String; public abstract fun getTime ()Lcom/datadog/android/api/context/TimeInfo; + public abstract fun isCoreActive ()Z public abstract fun setTrackingConsent (Lcom/datadog/android/privacy/TrackingConsent;)V public abstract fun setUserInfo (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;)V } @@ -381,22 +384,39 @@ public final class com/datadog/android/api/net/Request { public fun toString ()Ljava/lang/String; } +public final class com/datadog/android/api/net/RequestExecutionContext { + public fun ()V + public fun (ILjava/lang/Integer;)V + public synthetic fun (ILjava/lang/Integer;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()I + public final fun component2 ()Ljava/lang/Integer; + public final fun copy (ILjava/lang/Integer;)Lcom/datadog/android/api/net/RequestExecutionContext; + public static synthetic fun copy$default (Lcom/datadog/android/api/net/RequestExecutionContext;ILjava/lang/Integer;ILjava/lang/Object;)Lcom/datadog/android/api/net/RequestExecutionContext; + public fun equals (Ljava/lang/Object;)Z + public final fun getAttemptNumber ()I + public final fun getPreviousResponseCode ()Ljava/lang/Integer; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + public abstract interface class com/datadog/android/api/net/RequestFactory { public static final field CONTENT_TYPE_JSON Ljava/lang/String; public static final field CONTENT_TYPE_TEXT_UTF8 Ljava/lang/String; public static final field Companion Lcom/datadog/android/api/net/RequestFactory$Companion; + public static final field DD_IDEMPOTENCY_KEY Ljava/lang/String; public static final field HEADER_API_KEY Ljava/lang/String; public static final field HEADER_EVP_ORIGIN Ljava/lang/String; public static final field HEADER_EVP_ORIGIN_VERSION Ljava/lang/String; public static final field HEADER_REQUEST_ID Ljava/lang/String; public static final field QUERY_PARAM_SOURCE Ljava/lang/String; public static final field QUERY_PARAM_TAGS Ljava/lang/String; - public abstract fun create (Lcom/datadog/android/api/context/DatadogContext;Ljava/util/List;[B)Lcom/datadog/android/api/net/Request; + public abstract fun create (Lcom/datadog/android/api/context/DatadogContext;Lcom/datadog/android/api/net/RequestExecutionContext;Ljava/util/List;[B)Lcom/datadog/android/api/net/Request; } public final class com/datadog/android/api/net/RequestFactory$Companion { public static final field CONTENT_TYPE_JSON Ljava/lang/String; public static final field CONTENT_TYPE_TEXT_UTF8 Ljava/lang/String; + public static final field DD_IDEMPOTENCY_KEY Ljava/lang/String; public static final field HEADER_API_KEY Ljava/lang/String; public static final field HEADER_EVP_ORIGIN Ljava/lang/String; public static final field HEADER_EVP_ORIGIN_VERSION Ljava/lang/String; @@ -519,6 +539,7 @@ public final class com/datadog/android/core/SdkReference { public final class com/datadog/android/core/StrictModeExtKt { public static final fun allowThreadDiskReads (Lkotlin/jvm/functions/Function0;)Ljava/lang/Object; + public static final fun allowThreadDiskWrites (Lkotlin/jvm/functions/Function0;)Ljava/lang/Object; } public final class com/datadog/android/core/configuration/BackPressureMitigation : java/lang/Enum { @@ -715,6 +736,7 @@ public abstract interface class com/datadog/android/core/internal/persistence/De public final class com/datadog/android/core/internal/persistence/file/FileExtKt { public static final fun canReadSafe (Ljava/io/File;Lcom/datadog/android/api/InternalLogger;)Z public static final fun existsSafe (Ljava/io/File;Lcom/datadog/android/api/InternalLogger;)Z + public static final fun listFilesSafe (Ljava/io/File;Lcom/datadog/android/api/InternalLogger;Ljava/io/FilenameFilter;)[Ljava/io/File; public static final fun readLinesSafe (Ljava/io/File;Ljava/nio/charset/Charset;Lcom/datadog/android/api/InternalLogger;)Ljava/util/List; public static synthetic fun readLinesSafe$default (Ljava/io/File;Ljava/nio/charset/Charset;Lcom/datadog/android/api/InternalLogger;ILjava/lang/Object;)Ljava/util/List; public static final fun readTextSafe (Ljava/io/File;Ljava/nio/charset/Charset;Lcom/datadog/android/api/InternalLogger;)Ljava/lang/String; @@ -762,10 +784,6 @@ public final class com/datadog/android/core/internal/utils/ThreadExtKt { public static final fun loggableStackTrace ([Ljava/lang/StackTraceElement;)Ljava/lang/String; } -public final class com/datadog/android/core/internal/utils/ThrowableExtKt { - public static final fun loggableStackTrace (Ljava/lang/Throwable;)Ljava/lang/String; -} - public final class com/datadog/android/core/metrics/MethodCallSamplingRate : java/lang/Enum { public static final field ALL Lcom/datadog/android/core/metrics/MethodCallSamplingRate; public static final field HIGH Lcom/datadog/android/core/metrics/MethodCallSamplingRate; diff --git a/dd-sdk-android-core/build.gradle.kts b/dd-sdk-android-core/build.gradle.kts index 4f57fb00e0..d1f55d12bb 100644 --- a/dd-sdk-android-core/build.gradle.kts +++ b/dd-sdk-android-core/build.gradle.kts @@ -9,6 +9,7 @@ import com.datadog.gradle.config.BuildConfigPropertiesKeys import com.datadog.gradle.config.GradlePropertiesKeys import com.datadog.gradle.config.androidLibraryConfig import com.datadog.gradle.config.dependencyUpdateConfig +import com.datadog.gradle.config.detektCustomConfig import com.datadog.gradle.config.javadocConfig import com.datadog.gradle.config.junitConfig import com.datadog.gradle.config.kotlinConfig @@ -120,6 +121,7 @@ dependencies { ) } } + testImplementation(testFixtures(project(":dd-sdk-android-internal"))) testImplementation(libs.bundles.jUnit5) testImplementation(libs.bundles.testTools) unmock(libs.robolectric) @@ -150,3 +152,4 @@ junitConfig() javadocConfig() dependencyUpdateConfig() publishingConfig("Datadog monitoring library for Android applications.") +detektCustomConfig() diff --git a/dd-sdk-android-core/src/main/kotlin/com/datadog/android/Datadog.kt b/dd-sdk-android-core/src/main/kotlin/com/datadog/android/Datadog.kt index 5dd6637bbf..31218e70cb 100644 --- a/dd-sdk-android-core/src/main/kotlin/com/datadog/android/Datadog.kt +++ b/dd-sdk-android-core/src/main/kotlin/com/datadog/android/Datadog.kt @@ -20,8 +20,8 @@ import com.datadog.android.core.internal.HashGenerator import com.datadog.android.core.internal.NoOpInternalSdkCore import com.datadog.android.core.internal.SdkCoreRegistry import com.datadog.android.core.internal.Sha256HashGenerator -import com.datadog.android.core.internal.utils.loggableStackTrace import com.datadog.android.core.internal.utils.unboundInternalLogger +import com.datadog.android.internal.utils.loggableStackTrace import com.datadog.android.lint.InternalApi import com.datadog.android.privacy.TrackingConsent import java.util.Locale diff --git a/dd-sdk-android-core/src/main/kotlin/com/datadog/android/_InternalProxy.kt b/dd-sdk-android-core/src/main/kotlin/com/datadog/android/_InternalProxy.kt index b2d6c0ce1a..9797bcdd4b 100644 --- a/dd-sdk-android-core/src/main/kotlin/com/datadog/android/_InternalProxy.kt +++ b/dd-sdk-android-core/src/main/kotlin/com/datadog/android/_InternalProxy.kt @@ -12,6 +12,7 @@ import com.datadog.android.api.feature.FeatureScope import com.datadog.android.api.feature.FeatureSdkCore import com.datadog.android.core.configuration.Configuration import com.datadog.android.core.internal.DatadogCore +import com.datadog.android.internal.telemetry.InternalTelemetryEvent import com.datadog.android.lint.InternalApi /** @@ -45,28 +46,28 @@ class _InternalProxy internal constructor( } fun debug(message: String) { - rumFeature?.sendEvent(mapOf("type" to "telemetry_debug", "message" to message)) + val telemetryEvent = InternalTelemetryEvent.Log.Debug( + message = message, + additionalProperties = null + ) + rumFeature?.sendEvent(telemetryEvent) } fun error(message: String, throwable: Throwable? = null) { - rumFeature?.sendEvent( - mapOf( - "type" to "telemetry_error", - "message" to message, - "throwable" to throwable - ) + val telemetryEvent = InternalTelemetryEvent.Log.Error( + message = message, + error = throwable ) + rumFeature?.sendEvent(telemetryEvent) } fun error(message: String, stack: String?, kind: String?) { - rumFeature?.sendEvent( - mapOf( - "type" to "telemetry_error", - "message" to message, - "stacktrace" to stack, - "kind" to kind - ) + val telemetryEvent = InternalTelemetryEvent.Log.Error( + message = message, + stacktrace = stack, + kind = kind ) + rumFeature?.sendEvent(telemetryEvent) } } diff --git a/dd-sdk-android-core/src/main/kotlin/com/datadog/android/api/InternalLogger.kt b/dd-sdk-android-core/src/main/kotlin/com/datadog/android/api/InternalLogger.kt index 3047a13ffe..c2dfc717f6 100644 --- a/dd-sdk-android-core/src/main/kotlin/com/datadog/android/api/InternalLogger.kt +++ b/dd-sdk-android-core/src/main/kotlin/com/datadog/android/api/InternalLogger.kt @@ -9,6 +9,7 @@ package com.datadog.android.api import com.datadog.android.core.internal.logger.SdkInternalLogger import com.datadog.android.core.metrics.PerformanceMetric import com.datadog.android.core.metrics.TelemetryMetricType +import com.datadog.android.internal.telemetry.InternalTelemetryEvent import com.datadog.android.lint.InternalApi import com.datadog.tools.annotation.NoOpImplementation @@ -132,8 +133,22 @@ interface InternalLogger { operationName: String ): PerformanceMetric? + /** + * Logs an API usage from the internal implementation. + * @param apiUsageEvent the API event being tracked + * @param samplingRate value between 0-100 for sampling the event. Note that the sampling rate applied to this + * event will be applied in addition to the global telemetry sampling rate. By default, the sampling rate is 15%. + */ + @InternalApi + fun logApiUsage( + apiUsageEvent: InternalTelemetryEvent.ApiUsage, + samplingRate: Float = DEFAULT_API_USAGE_TELEMETRY_SAMPLING_RATE + ) + companion object { + private const val DEFAULT_API_USAGE_TELEMETRY_SAMPLING_RATE = 15f + /** * Logger for the cases when SDK instance is not yet available. Try to use the logger * provided by [FeatureSdkCore.internalLogger] instead if possible. diff --git a/dd-sdk-android-core/src/main/kotlin/com/datadog/android/api/SdkCore.kt b/dd-sdk-android-core/src/main/kotlin/com/datadog/android/api/SdkCore.kt index dd70f39fe1..fd6914d5bc 100644 --- a/dd-sdk-android-core/src/main/kotlin/com/datadog/android/api/SdkCore.kt +++ b/dd-sdk-android-core/src/main/kotlin/com/datadog/android/api/SdkCore.kt @@ -32,6 +32,12 @@ interface SdkCore { */ val service: String + /** + * Returns true if the core is active. + */ + @AnyThread + fun isCoreActive(): Boolean + /** * Sets the tracking consent regarding the data collection for this instance of the Datadog SDK. * diff --git a/dd-sdk-android-core/src/main/kotlin/com/datadog/android/api/net/RequestExecutionContext.kt b/dd-sdk-android-core/src/main/kotlin/com/datadog/android/api/net/RequestExecutionContext.kt new file mode 100644 index 0000000000..ebd2cb66ee --- /dev/null +++ b/dd-sdk-android-core/src/main/kotlin/com/datadog/android/api/net/RequestExecutionContext.kt @@ -0,0 +1,21 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.datadog.android.api.net + +/** + * Provides information about the request execution context such as the number of attempts made to + * execute the request in case of a retry or the response code of the previous request failure code. + * @param attemptNumber the number of this attempt for a specific batch. + * It'll be 1 for the first attempt, and will be incremented each time an upload for the same batch is retried. + * This takes into account the initial request and all the retries. + * @param previousResponseCode the response code of the previous request failure code in case of a retry. + * In case of the initial request, this value will be null. + */ +data class RequestExecutionContext( + val attemptNumber: Int = 0, + val previousResponseCode: Int? = null +) diff --git a/dd-sdk-android-core/src/main/kotlin/com/datadog/android/api/net/RequestFactory.kt b/dd-sdk-android-core/src/main/kotlin/com/datadog/android/api/net/RequestFactory.kt index ba5db91f8b..2d93fd5d52 100644 --- a/dd-sdk-android-core/src/main/kotlin/com/datadog/android/api/net/RequestFactory.kt +++ b/dd-sdk-android-core/src/main/kotlin/com/datadog/android/api/net/RequestFactory.kt @@ -17,12 +17,16 @@ fun interface RequestFactory { /** * Creates a request for the given batch. * @param context Datadog SDK context. + * @param executionContext Information about the execution context this request in case of a previous retry. + * This information is specific to a certain batch and will be reset for the next batch in case of a drop or + * a successful request. * @param batchData Raw data of the batch. * @param batchMetadata Raw metadata of the batch. * @throws [Exception] in case the request could not be created. */ fun create( context: DatadogContext, + executionContext: RequestExecutionContext, batchData: List, batchMetadata: ByteArray? ): Request? @@ -67,5 +71,10 @@ fun interface RequestFactory { * Datadog tags query parameter name. */ const val QUERY_PARAM_TAGS: String = "ddtags" + + /** + * Datadog Idempotency key header, used to offer more insight into the request retry statistics. + */ + const val DD_IDEMPOTENCY_KEY: String = "DD-IDEMPOTENCY-KEY" } } diff --git a/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/StrictModeExt.kt b/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/StrictModeExt.kt index e6975362c3..3f54bcea81 100644 --- a/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/StrictModeExt.kt +++ b/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/StrictModeExt.kt @@ -28,3 +28,23 @@ fun allowThreadDiskReads( StrictMode.setThreadPolicy(oldPolicy) } } + +/** + * This utility function wraps a call to a method that needs to perform a disk write operation + * on the main thread. + * This prevents adding LogCat noise when customer enable StrictMode logging. + * @param T the type returned by the operation + * @param operation the operation + * @return the value returned by the operation + */ +@InternalApi +fun allowThreadDiskWrites( + operation: () -> T +): T { + val oldPolicy = StrictMode.allowThreadDiskWrites() + try { + return operation() + } finally { + StrictMode.setThreadPolicy(oldPolicy) + } +} diff --git a/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/internal/DatadogCore.kt b/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/internal/DatadogCore.kt index 4599a08245..e9b65d99bc 100644 --- a/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/internal/DatadogCore.kt +++ b/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/internal/DatadogCore.kt @@ -38,6 +38,7 @@ import com.datadog.android.core.internal.utils.scheduleSafe import com.datadog.android.core.internal.utils.submitSafe import com.datadog.android.core.thread.FlushableExecutorService import com.datadog.android.error.internal.CrashReportsFeature +import com.datadog.android.internal.telemetry.InternalTelemetryEvent import com.datadog.android.ndk.internal.NdkCrashHandler import com.datadog.android.privacy.TrackingConsent import com.google.gson.JsonObject @@ -265,6 +266,8 @@ internal class DatadogCore( return coreFeature.createScheduledExecutorService(executorContext) } + override fun isCoreActive(): Boolean = isActive + // endregion // region InternalSdkCore @@ -421,6 +424,7 @@ internal class DatadogCore( processLifecycleMonitor = ProcessLifecycleMonitor( ProcessLifecycleCallback( appContext, + name, internalLogger ) ).apply { @@ -501,17 +505,15 @@ internal class DatadogCore( private fun sendCoreConfigurationTelemetryEvent(configuration: Configuration) { val runnable = Runnable { val rumFeature = getFeature(Feature.RUM_FEATURE_NAME) ?: return@Runnable - val coreConfigurationEvent = mapOf( - "type" to "telemetry_configuration", - "track_errors" to (configuration.crashReportsEnabled), - "batch_size" to configuration.coreConfig.batchSize.windowDurationMs, - "batch_upload_frequency" to configuration.coreConfig.uploadFrequency.baseStepMs, - "use_proxy" to (configuration.coreConfig.proxy != null), - "use_local_encryption" to (configuration.coreConfig.encryption != null), - "batch_processing_level" to configuration.coreConfig.batchProcessingLevel.maxBatchesPerUploadJob, - "use_persistence_strategy_factory" to (configuration.coreConfig.persistenceStrategyFactory != null) + val event = InternalTelemetryEvent.Configuration( + trackErrors = configuration.crashReportsEnabled, + batchSize = configuration.coreConfig.batchSize.windowDurationMs, + useProxy = configuration.coreConfig.proxy != null, + useLocalEncryption = configuration.coreConfig.encryption != null, + batchUploadFrequency = configuration.coreConfig.uploadFrequency.baseStepMs, + batchProcessingLevel = configuration.coreConfig.batchProcessingLevel.maxBatchesPerUploadJob ) - rumFeature.sendEvent(coreConfigurationEvent) + rumFeature.sendEvent(event) } coreFeature.uploadExecutorService.scheduleSafe( diff --git a/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/internal/NoOpInternalSdkCore.kt b/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/internal/NoOpInternalSdkCore.kt index 4b1b1d40f8..e57d2a3554 100644 --- a/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/internal/NoOpInternalSdkCore.kt +++ b/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/internal/NoOpInternalSdkCore.kt @@ -89,6 +89,8 @@ internal object NoOpInternalSdkCore : InternalSdkCore { override fun clearAllData() = Unit + override fun isCoreActive(): Boolean = false + // endregion // region FeatureSdkCore diff --git a/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/internal/SdkFeature.kt b/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/internal/SdkFeature.kt index ca7212e207..f0697803fd 100644 --- a/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/internal/SdkFeature.kt +++ b/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/internal/SdkFeature.kt @@ -351,7 +351,9 @@ internal class SdkFeature( fileMover = FileMover(internalLogger), internalLogger = internalLogger, filePersistenceConfig = filePersistenceConfig, - metricsDispatcher = metricsDispatcher + metricsDispatcher = metricsDispatcher, + coreFeature.trackingConsentProvider, + featureName ) } diff --git a/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/internal/data/upload/DataOkHttpUploader.kt b/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/internal/data/upload/DataOkHttpUploader.kt index 543f3222d6..15ff1a7f2f 100644 --- a/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/internal/data/upload/DataOkHttpUploader.kt +++ b/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/internal/data/upload/DataOkHttpUploader.kt @@ -9,8 +9,10 @@ package com.datadog.android.core.internal.data.upload import android.net.TrafficStats import com.datadog.android.api.InternalLogger import com.datadog.android.api.context.DatadogContext +import com.datadog.android.api.net.RequestExecutionContext import com.datadog.android.api.net.RequestFactory import com.datadog.android.api.storage.RawBatchEvent +import com.datadog.android.core.internal.persistence.BatchId import com.datadog.android.core.internal.system.AndroidInfoProvider import okhttp3.Call import okhttp3.MediaType.Companion.toMediaTypeOrNull @@ -29,16 +31,27 @@ internal class DataOkHttpUploader( val androidInfoProvider: AndroidInfoProvider ) : DataUploader { + @Volatile + private var attempts: Int = 1 + + @Volatile + private var previousUploadStatus: UploadStatus? = null + + @Volatile + private var previousUploadedBatchId: BatchId? = null + // region DataUploader @Suppress("TooGenericExceptionCaught", "ReturnCount") override fun upload( context: DatadogContext, batch: List, - batchMeta: ByteArray? + batchMeta: ByteArray?, + batchId: BatchId? ): UploadStatus { + val executionContext = resolveExecutionContext(batchId) val request = try { - requestFactory.create(context, batch, batchMeta) + requestFactory.create(context, executionContext, batch, batchMeta) ?: return UploadStatus.RequestCreationError(null) } catch (e: Exception) { internalLogger.log( @@ -85,9 +98,10 @@ internal class DataOkHttpUploader( request.description, request.body.size, internalLogger, + attempts = executionContext.attemptNumber, requestId = request.id ) - + previousUploadStatus = uploadStatus return uploadStatus } @@ -104,6 +118,21 @@ internal class DataOkHttpUploader( } // region Internal + private fun resolveExecutionContext(batchID: BatchId?): RequestExecutionContext { + val previousResponseCode: Int? + if ((batchID != null && previousUploadedBatchId != null) && (previousUploadedBatchId == batchID)) { + attempts++ + previousResponseCode = previousUploadStatus?.code + } else { + attempts = 1 + previousResponseCode = null + } + previousUploadedBatchId = batchID + return RequestExecutionContext( + attemptNumber = attempts, + previousResponseCode = previousResponseCode + ) + } @Suppress("UnsafeThirdPartyFunctionCall") // Called within a try/catch block private fun executeUploadRequest( @@ -127,7 +156,9 @@ internal class DataOkHttpUploader( } @Suppress("UnsafeThirdPartyFunctionCall") // Called within a try/catch block - private fun buildOkHttpRequest(request: DatadogRequest): Request { + private fun buildOkHttpRequest( + request: DatadogRequest + ): Request { val mediaType = if (request.contentType == null) { null } else { @@ -172,12 +203,16 @@ internal class DataOkHttpUploader( ): UploadStatus { return when (code) { HTTP_ACCEPTED -> UploadStatus.Success(code) - HTTP_BAD_REQUEST -> UploadStatus.HttpClientError(code) - HTTP_UNAUTHORIZED -> UploadStatus.InvalidTokenError(code) + + HTTP_UNAUTHORIZED, HTTP_FORBIDDEN -> UploadStatus.InvalidTokenError(code) - HTTP_CLIENT_TIMEOUT -> UploadStatus.HttpClientRateLimiting(code) - HTTP_ENTITY_TOO_LARGE -> UploadStatus.HttpClientError(code) + + HTTP_CLIENT_TIMEOUT, HTTP_TOO_MANY_REQUESTS -> UploadStatus.HttpClientRateLimiting(code) + + HTTP_BAD_REQUEST, + HTTP_ENTITY_TOO_LARGE -> UploadStatus.HttpClientError(code) + HTTP_INTERNAL_ERROR, HTTP_BAD_GATEWAY, HTTP_UNAVAILABLE, @@ -198,7 +233,6 @@ internal class DataOkHttpUploader( companion object { const val HTTP_ACCEPTED = 202 - const val HTTP_BAD_REQUEST = 400 const val HTTP_UNAUTHORIZED = 401 const val HTTP_FORBIDDEN = 403 diff --git a/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/internal/data/upload/DataUploadRunnable.kt b/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/internal/data/upload/DataUploadRunnable.kt index 36033e1a55..e5d39904dc 100644 --- a/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/internal/data/upload/DataUploadRunnable.kt +++ b/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/internal/data/upload/DataUploadRunnable.kt @@ -115,7 +115,7 @@ internal class DataUploadRunnable( batch: List, batchMeta: ByteArray? ): UploadStatus { - val status = dataUploader.upload(context, batch, batchMeta) + val status = dataUploader.upload(context, batch, batchMeta, batchId) val removalReason = if (status is UploadStatus.RequestCreationError) { RemovalReason.Invalid } else { diff --git a/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/internal/data/upload/DataUploader.kt b/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/internal/data/upload/DataUploader.kt index 680e12910f..9ec1684d78 100644 --- a/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/internal/data/upload/DataUploader.kt +++ b/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/internal/data/upload/DataUploader.kt @@ -8,11 +8,13 @@ package com.datadog.android.core.internal.data.upload import com.datadog.android.api.context.DatadogContext import com.datadog.android.api.storage.RawBatchEvent +import com.datadog.android.core.internal.persistence.BatchId internal interface DataUploader { fun upload( context: DatadogContext, batch: List, - batchMeta: ByteArray? + batchMeta: ByteArray?, + batchId: BatchId? = null ): UploadStatus } diff --git a/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/internal/data/upload/NoOpDataUploader.kt b/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/internal/data/upload/NoOpDataUploader.kt index 42b7d76815..6795f4b0e1 100644 --- a/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/internal/data/upload/NoOpDataUploader.kt +++ b/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/internal/data/upload/NoOpDataUploader.kt @@ -8,9 +8,15 @@ package com.datadog.android.core.internal.data.upload import com.datadog.android.api.context.DatadogContext import com.datadog.android.api.storage.RawBatchEvent +import com.datadog.android.core.internal.persistence.BatchId internal class NoOpDataUploader : DataUploader { - override fun upload(context: DatadogContext, batch: List, batchMeta: ByteArray?): UploadStatus { + override fun upload( + context: DatadogContext, + batch: List, + batchMeta: ByteArray?, + batchId: BatchId? + ): UploadStatus { return UploadStatus.UnknownStatus } } diff --git a/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/internal/data/upload/RotatingDnsResolver.kt b/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/internal/data/upload/RotatingDnsResolver.kt index c554c6fc98..a176a6bdb6 100644 --- a/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/internal/data/upload/RotatingDnsResolver.kt +++ b/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/internal/data/upload/RotatingDnsResolver.kt @@ -55,16 +55,16 @@ internal class RotatingDnsResolver( } } + // endregion + + // region Internal + private fun safeCopy(list: List): List { return synchronized(list) { list.toList() } } - // endregion - - // region Internal - private fun isValid(knownHost: ResolvedHost): Boolean { return knownHost.getAge() < ttl && knownHost.addresses.isNotEmpty() } diff --git a/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/internal/data/upload/UploadStatus.kt b/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/internal/data/upload/UploadStatus.kt index 44e2a8baed..3670bbe134 100644 --- a/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/internal/data/upload/UploadStatus.kt +++ b/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/internal/data/upload/UploadStatus.kt @@ -7,6 +7,7 @@ package com.datadog.android.core.internal.data.upload import com.datadog.android.api.InternalLogger +import java.util.Locale internal sealed class UploadStatus( val shouldRetry: Boolean = false, @@ -15,8 +16,13 @@ internal sealed class UploadStatus( ) { internal class Success(responseCode: Int) : UploadStatus(shouldRetry = false, code = responseCode) - internal class NetworkError(throwable: Throwable) : UploadStatus(shouldRetry = true, throwable = throwable) - internal class DNSError(throwable: Throwable) : UploadStatus(shouldRetry = true, throwable = throwable) + + internal class NetworkError(throwable: Throwable) : + UploadStatus(shouldRetry = true, throwable = throwable) + + internal class DNSError(throwable: Throwable) : + UploadStatus(shouldRetry = true, throwable = throwable) + internal class RequestCreationError(throwable: Throwable?) : UploadStatus(shouldRetry = false, throwable = throwable) @@ -27,103 +33,98 @@ internal sealed class UploadStatus( internal class HttpClientRateLimiting(responseCode: Int) : UploadStatus(shouldRetry = true, code = responseCode) internal class UnknownHttpError(responseCode: Int) : UploadStatus(shouldRetry = false, code = responseCode) internal class UnknownException(throwable: Throwable) : UploadStatus(shouldRetry = true, throwable = throwable) - internal object UnknownStatus : UploadStatus(shouldRetry = false, code = UNKNOWN_RESPONSE_CODE) fun logStatus( context: String, byteSize: Int, logger: InternalLogger, + attempts: Int, requestId: String? = null ) { - val level = when (this) { - is HttpClientError, - is HttpServerError, - is InvalidTokenError, - is RequestCreationError, - is UnknownException, - is UnknownHttpError -> InternalLogger.Level.ERROR - - is DNSError, - is HttpClientRateLimiting, - is HttpRedirection, - is NetworkError -> InternalLogger.Level.WARN - - is Success -> InternalLogger.Level.INFO - - else -> InternalLogger.Level.VERBOSE - } - - val targets = when (this) { - is HttpClientError, - is HttpClientRateLimiting -> listOf(InternalLogger.Target.USER, InternalLogger.Target.TELEMETRY) - - is DNSError, - is HttpRedirection, - is HttpServerError, - is InvalidTokenError, - is NetworkError, - is RequestCreationError, - is Success, - is UnknownException, - is UnknownHttpError -> listOf(InternalLogger.Target.USER) - - else -> emptyList() - } - + val level = resolveInternalLogLevel() + val targets = resolveInternalLogTarget() logger.log( level, targets, { - buildStatusMessage(requestId, byteSize, context, throwable) + buildStatusMessage(requestId, byteSize, context, throwable, attempts) } ) } + private fun resolveInternalLogTarget() = when (this) { + is HttpClientError, + is HttpClientRateLimiting, + is UnknownStatus -> listOf(InternalLogger.Target.USER, InternalLogger.Target.TELEMETRY) + + is DNSError, + is HttpRedirection, + is HttpServerError, + is InvalidTokenError, + is NetworkError, + is RequestCreationError, + is Success, + is UnknownException, + is UnknownHttpError -> listOf(InternalLogger.Target.USER) + } + + private fun resolveInternalLogLevel() = when (this) { + is HttpClientError, + is HttpServerError, + is InvalidTokenError, + is RequestCreationError, + is UnknownException, + is UnknownHttpError -> InternalLogger.Level.ERROR + + is DNSError, + is HttpClientRateLimiting, + is HttpRedirection, + is UnknownStatus, + is NetworkError -> InternalLogger.Level.WARN + + is Success -> InternalLogger.Level.INFO + } + private fun buildStatusMessage( requestId: String?, byteSize: Int, context: String, - throwable: Throwable? + throwable: Throwable?, + requestAttempts: Int ): String { - return buildString { + val buildString = buildString { if (requestId == null) { append("Batch [$byteSize bytes] ($context)") } else { append("Batch $requestId [$byteSize bytes] ($context)") } - if (this@UploadStatus is Success) { - append(" sent successfully.") - } else if (this@UploadStatus is UnknownStatus) { - append(" status is unknown") - } else { - append(" failed because ") - when (this@UploadStatus) { - is DNSError -> append("of a DNS error") - is HttpClientError -> append("of a processing error or invalid data") - is HttpClientRateLimiting -> append("of an intake rate limitation") - is HttpRedirection -> append("of a network redirection") - is HttpServerError -> append("of a server processing error") - is InvalidTokenError -> append("your token is invalid") - is NetworkError -> append("of a network error") - is RequestCreationError -> append("of an error when creating the request") - is UnknownException -> append("of an unknown error") - is UnknownHttpError -> append("of an unexpected HTTP error (status code = $code)") - else -> {} - } - - if (throwable != null) { - append(" (") - append(throwable.message) - append(")") - } - - if (shouldRetry) { - append("; we will retry later.") - } else { - append("; the batch was dropped.") - } + when (this@UploadStatus) { + is DNSError -> append(" failed because of a DNS error") + is HttpClientError -> append(" failed because of a processing error or invalid data") + is HttpClientRateLimiting -> append(" failed because of an intake rate limitation") + is HttpRedirection -> append(" failed because of a network redirection") + is HttpServerError -> append(" failed because of a server processing error") + is InvalidTokenError -> append(" failed because your token is invalid") + is NetworkError -> append(" failed because of a network error") + is RequestCreationError -> append(" failed because of an error when creating the request") + is UnknownException -> append(" failed because of an unknown error") + is UnknownHttpError -> append(" failed because of an unexpected HTTP error (status code = $code)") + is UnknownStatus -> append(" status is unknown") + is Success -> append(" sent successfully.") + } + + if (throwable != null) { + append(" (") + append(throwable.message) + append(")") + } + + if (shouldRetry) { + append("; we will retry later.") + } else if (this@UploadStatus !is Success) { + append("; the batch was dropped.") } if (this@UploadStatus is InvalidTokenError) { @@ -132,10 +133,19 @@ internal sealed class UploadStatus( "and you're targeting the relevant Datadog site." ) } + append( + ATTEMPTS_LOG_MESSAGE_FORMAT.format( + Locale.US, + requestAttempts, + code + ) + ) } + return buildString } companion object { internal const val UNKNOWN_RESPONSE_CODE = 0 + internal const val ATTEMPTS_LOG_MESSAGE_FORMAT = " This request was attempted %d time(s)." } } diff --git a/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/internal/data/upload/UploadWorker.kt b/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/internal/data/upload/UploadWorker.kt index a5f74790c0..9a3e3f9d5a 100644 --- a/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/internal/data/upload/UploadWorker.kt +++ b/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/internal/data/upload/UploadWorker.kt @@ -18,6 +18,7 @@ import com.datadog.android.core.InternalSdkCore import com.datadog.android.core.internal.NoOpInternalSdkCore import com.datadog.android.core.internal.SdkFeature import com.datadog.android.core.internal.metrics.RemovalReason +import com.datadog.android.core.internal.persistence.BatchId import com.datadog.android.core.internal.utils.unboundInternalLogger import java.util.LinkedList import java.util.Queue @@ -87,6 +88,7 @@ internal class UploadWorker( val nextBatchData = storage.readNextBatch() if (nextBatchData != null) { val uploadStatus = consumeBatch( + nextBatchData.id, context, nextBatchData.data, nextBatchData.metadata, @@ -97,18 +99,21 @@ internal class UploadWorker( RemovalReason.IntakeCode(uploadStatus.code), deleteBatch = !uploadStatus.shouldRetry ) - @Suppress("UnsafeThirdPartyFunctionCall") // safe to add - taskQueue.offer(UploadNextBatchTask(taskQueue, sdkCore, feature)) + if (uploadStatus is UploadStatus.Success) { + @Suppress("UnsafeThirdPartyFunctionCall") // safe to add + taskQueue.offer(UploadNextBatchTask(taskQueue, sdkCore, feature)) + } } } private fun consumeBatch( + batchId: BatchId, context: DatadogContext, batch: List, batchMeta: ByteArray?, uploader: DataUploader ): UploadStatus { - return uploader.upload(context, batch, batchMeta) + return uploader.upload(context, batch, batchMeta, batchId) } } diff --git a/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/internal/lifecycle/ProcessLifecycleCallback.kt b/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/internal/lifecycle/ProcessLifecycleCallback.kt index ea35934c2b..5802142abf 100644 --- a/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/internal/lifecycle/ProcessLifecycleCallback.kt +++ b/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/internal/lifecycle/ProcessLifecycleCallback.kt @@ -16,6 +16,7 @@ import java.lang.ref.WeakReference internal class ProcessLifecycleCallback( appContext: Context, + internal val instanceName: String, private val internalLogger: InternalLogger ) : ProcessLifecycleMonitor.Callback { @@ -25,7 +26,7 @@ internal class ProcessLifecycleCallback( override fun onStarted() { contextWeakRef.get()?.let { if (WorkManager.isInitialized()) { - cancelUploadWorker(it, internalLogger) + cancelUploadWorker(it, instanceName, internalLogger) } } } @@ -37,7 +38,7 @@ internal class ProcessLifecycleCallback( override fun onStopped() { contextWeakRef.get()?.let { if (WorkManager.isInitialized()) { - triggerUploadWorker(it, internalLogger) + triggerUploadWorker(it, instanceName, internalLogger) } } } diff --git a/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/internal/logger/SdkInternalLogger.kt b/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/internal/logger/SdkInternalLogger.kt index 9b643efed7..5e83f859a0 100644 --- a/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/internal/logger/SdkInternalLogger.kt +++ b/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/internal/logger/SdkInternalLogger.kt @@ -16,6 +16,7 @@ import com.datadog.android.core.internal.metrics.MethodCalledTelemetry import com.datadog.android.core.metrics.PerformanceMetric import com.datadog.android.core.metrics.TelemetryMetricType import com.datadog.android.core.sampling.RateBasedSampler +import com.datadog.android.internal.telemetry.InternalTelemetryEvent internal class SdkInternalLogger( private val sdkCore: FeatureSdkCore?, @@ -98,16 +99,12 @@ internal class SdkInternalLogger( samplingRate: Float ) { if (!RateBasedSampler(samplingRate).sample()) return - val rumFeature = sdkCore?.getFeature(Feature.RUM_FEATURE_NAME) ?: return - val message = messageBuilder() - val telemetryEvent = - mapOf( - TYPE_KEY to "mobile_metric", - MESSAGE_KEY to message, - ADDITIONAL_PROPERTIES_KEY to additionalProperties - ) - rumFeature.sendEvent(telemetryEvent) + val metricEvent = InternalTelemetryEvent.Metric( + message = messageBuilder(), + additionalProperties = additionalProperties + ) + rumFeature.sendEvent(metricEvent) } override fun startPerformanceMeasure( @@ -129,6 +126,15 @@ internal class SdkInternalLogger( } } + override fun logApiUsage( + apiUsageEvent: InternalTelemetryEvent.ApiUsage, + samplingRate: Float + ) { + if (!RateBasedSampler(samplingRate).sample()) return + val rumFeature = sdkCore?.getFeature(Feature.RUM_FEATURE_NAME) ?: return + rumFeature.sendEvent(apiUsageEvent) + } + // endregion // region Internal @@ -210,24 +216,16 @@ internal class SdkInternalLogger( level == InternalLogger.Level.WARN || error != null ) { - mutableMapOf( - TYPE_KEY to "telemetry_error", - MESSAGE_KEY to message, - THROWABLE_KEY to error - ).apply { - if (!additionalProperties.isNullOrEmpty()) { - put(ADDITIONAL_PROPERTIES_KEY, additionalProperties) - } - } + InternalTelemetryEvent.Log.Error( + message = message, + additionalProperties = additionalProperties, + error = error + ) } else { - mutableMapOf( - TYPE_KEY to "telemetry_debug", - MESSAGE_KEY to message - ).apply { - if (!additionalProperties.isNullOrEmpty()) { - put(ADDITIONAL_PROPERTIES_KEY, additionalProperties) - } - } + InternalTelemetryEvent.Log.Debug( + message = message, + additionalProperties = additionalProperties + ) } rumFeature.sendEvent(telemetryEvent) } @@ -254,10 +252,6 @@ internal class SdkInternalLogger( companion object { internal const val SDK_LOG_TAG = "DD_LOG" internal const val DEV_LOG_TAG = "Datadog" - private const val MESSAGE_KEY = "message" - private const val TYPE_KEY = "type" - private const val THROWABLE_KEY = "throwable" - private const val ADDITIONAL_PROPERTIES_KEY = "additionalProperties" } // endregion diff --git a/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/internal/metrics/BatchMetricsDispatcher.kt b/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/internal/metrics/BatchMetricsDispatcher.kt index 46ed6a066e..a1d7d20ced 100644 --- a/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/internal/metrics/BatchMetricsDispatcher.kt +++ b/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/internal/metrics/BatchMetricsDispatcher.kt @@ -16,8 +16,6 @@ import com.datadog.android.core.internal.persistence.file.existsSafe import com.datadog.android.core.internal.persistence.file.lengthSafe import com.datadog.android.core.internal.time.TimeProvider import com.datadog.android.core.metrics.MethodCallSamplingRate -import com.datadog.android.core.sampling.RateBasedSampler -import com.datadog.android.core.sampling.Sampler import com.datadog.android.privacy.TrackingConsent import java.io.File import java.util.Locale @@ -28,8 +26,7 @@ internal class BatchMetricsDispatcher( private val uploadConfiguration: DataUploadConfiguration?, private val filePersistenceConfig: FilePersistenceConfig, private val internalLogger: InternalLogger, - private val dateTimeProvider: TimeProvider, - private val sampler: Sampler = RateBasedSampler(METRICS_DISPATCHER_DEFAULT_SAMPLING_RATE) + private val dateTimeProvider: TimeProvider ) : MetricsDispatcher, ProcessLifecycleMonitor.Callback { @@ -39,7 +36,7 @@ internal class BatchMetricsDispatcher( // region MetricsDispatcher override fun sendBatchDeletedMetric(batchFile: File, removalReason: RemovalReason) { - if (!removalReason.includeInMetrics() || trackName == null || !sampler.sample()) { + if (!removalReason.includeInMetrics() || trackName == null) { return } resolveBatchDeletedMetricAttributes(batchFile, removalReason)?.let { @@ -52,7 +49,7 @@ internal class BatchMetricsDispatcher( } override fun sendBatchClosedMetric(batchFile: File, batchMetadata: BatchClosedMetadata) { - if (trackName == null || !sampler.sample() || !batchFile.existsSafe(internalLogger)) { + if (trackName == null || !batchFile.existsSafe(internalLogger)) { return } resolveBatchClosedMetricAttributes(batchFile, batchMetadata)?.let { @@ -190,8 +187,6 @@ internal class BatchMetricsDispatcher( internal const val SR_TRACK_NAME = "sr" internal const val SR_RESOURCES_TRACK_NAME = "sr-resources" - private const val METRICS_DISPATCHER_DEFAULT_SAMPLING_RATE = 1.5f - internal const val WRONG_FILE_NAME_MESSAGE_FORMAT = "Unable to parse the file name as a timestamp: %s" diff --git a/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/internal/metrics/RemovalReason.kt b/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/internal/metrics/RemovalReason.kt index a99ebe3603..8fae3aa693 100644 --- a/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/internal/metrics/RemovalReason.kt +++ b/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/internal/metrics/RemovalReason.kt @@ -11,7 +11,7 @@ internal sealed class RemovalReason { internal fun includeInMetrics(): Boolean { return this !is Flushed } - internal class IntakeCode(private val responseCode: Int) : RemovalReason() { + internal data class IntakeCode(private val responseCode: Int) : RemovalReason() { override fun toString(): String { return "intake-code-$responseCode" } diff --git a/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/internal/persistence/AbstractStorage.kt b/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/internal/persistence/AbstractStorage.kt index 5aae922334..d2a663b6c4 100644 --- a/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/internal/persistence/AbstractStorage.kt +++ b/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/internal/persistence/AbstractStorage.kt @@ -30,7 +30,7 @@ internal class AbstractStorage( private val executorService: ExecutorService, private val internalLogger: InternalLogger, internal val storageConfiguration: FeatureStorageConfiguration, - consentProvider: ConsentProvider + private val consentProvider: ConsentProvider ) : Storage, TrackingConsentProviderCallback { private val grantedPersistenceStrategy: PersistenceStrategy by lazy { @@ -64,13 +64,8 @@ internal class AbstractStorage( forceNewBatch: Boolean, callback: (EventBatchWriter) -> Unit ) { - val strategy = when (datadogContext.trackingConsent) { - TrackingConsent.GRANTED -> grantedPersistenceStrategy - TrackingConsent.PENDING -> pendingPersistenceStrategy - TrackingConsent.NOT_GRANTED -> notGrantedPersistenceStrategy - } - executorService.submitSafe("Data write", internalLogger) { + val strategy = resolvePersistenceStrategy() val writer = object : EventBatchWriter { @WorkerThread override fun currentMetadata(): ByteArray? { @@ -86,6 +81,14 @@ internal class AbstractStorage( } } + @WorkerThread + private fun resolvePersistenceStrategy() = + when (consentProvider.getConsent()) { + TrackingConsent.GRANTED -> grantedPersistenceStrategy + TrackingConsent.PENDING -> pendingPersistenceStrategy + TrackingConsent.NOT_GRANTED -> notGrantedPersistenceStrategy + } + @WorkerThread override fun readNextBatch(): BatchData? { return grantedPersistenceStrategy.lockAndReadNext()?.let { diff --git a/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/internal/persistence/ConsentAwareStorage.kt b/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/internal/persistence/ConsentAwareStorage.kt index 946568b633..45ac605e2e 100644 --- a/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/internal/persistence/ConsentAwareStorage.kt +++ b/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/internal/persistence/ConsentAwareStorage.kt @@ -19,6 +19,7 @@ import com.datadog.android.core.internal.persistence.file.FilePersistenceConfig import com.datadog.android.core.internal.persistence.file.FileReaderWriter import com.datadog.android.core.internal.persistence.file.batch.BatchFileReaderWriter import com.datadog.android.core.internal.persistence.file.existsSafe +import com.datadog.android.core.internal.privacy.ConsentProvider import com.datadog.android.core.internal.utils.submitSafe import com.datadog.android.core.metrics.MethodCallSamplingRate import com.datadog.android.core.metrics.TelemetryMetricType @@ -36,7 +37,9 @@ internal class ConsentAwareStorage( private val fileMover: FileMover, private val internalLogger: InternalLogger, internal val filePersistenceConfig: FilePersistenceConfig, - private val metricsDispatcher: MetricsDispatcher + private val metricsDispatcher: MetricsDispatcher, + private val consentProvider: ConsentProvider, + private val featureName: String ) : Storage { /** @@ -53,28 +56,27 @@ internal class ConsentAwareStorage( forceNewBatch: Boolean, callback: (EventBatchWriter) -> Unit ) { - val orchestrator = when (datadogContext.trackingConsent) { - TrackingConsent.GRANTED -> grantedOrchestrator - TrackingConsent.PENDING -> pendingOrchestrator - TrackingConsent.NOT_GRANTED -> null - } - val metric = internalLogger.startPerformanceMeasure( callerClass = ConsentAwareStorage::class.java.name, metric = TelemetryMetricType.MethodCalled, samplingRate = MethodCallSamplingRate.RARE.rate, - operationName = "writeCurrentBatch[${orchestrator?.getRootDir()?.nameWithoutExtension}]" + operationName = "writeCurrentBatch[$featureName]" ) - executorService.submitSafe("Data write", internalLogger) { + val orchestrator = resolveOrchestrator() + if (orchestrator == null) { + callback.invoke(NoOpEventBatchWriter()) + metric?.stopAndSend(false) + return@submitSafe + } synchronized(writeLock) { - val batchFile = orchestrator?.getWritableFile(forceNewBatch) + val batchFile = orchestrator.getWritableFile(forceNewBatch) val metadataFile = if (batchFile != null) { orchestrator.getMetadataFile(batchFile) } else { null } - val writer = if (orchestrator == null || batchFile == null) { + val writer = if (batchFile == null) { NoOpEventBatchWriter() } else { FileEventBatchWriter( @@ -153,6 +155,16 @@ internal class ConsentAwareStorage( } } + @WorkerThread + private fun resolveOrchestrator(): FileOrchestrator? { + val consent = consentProvider.getConsent() + return when (consent) { + TrackingConsent.GRANTED -> grantedOrchestrator + TrackingConsent.PENDING -> pendingOrchestrator + TrackingConsent.NOT_GRANTED -> null + } + } + @WorkerThread private fun deleteBatch(batch: Batch, reason: RemovalReason) { deleteBatch(batch.file, batch.metaFile, reason) diff --git a/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/internal/persistence/datastore/DataStoreFileHelper.kt b/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/internal/persistence/datastore/DataStoreFileHelper.kt index f1e4e16de6..1096b1e778 100644 --- a/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/internal/persistence/datastore/DataStoreFileHelper.kt +++ b/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/internal/persistence/datastore/DataStoreFileHelper.kt @@ -17,14 +17,13 @@ internal class DataStoreFileHelper( private val internalLogger: InternalLogger ) { internal fun getDataStoreFile( - featureName: String, storageDir: File, + featureName: String, key: String ): File { - val dataStoreDirectory = createDataStoreDirectoryIfNecessary( + val dataStoreDirectory = getDataStoreDirectory( featureName = featureName, - storageDir = storageDir, - internalLogger = internalLogger + storageDir = storageDir ) return File(dataStoreDirectory, key) @@ -39,20 +38,9 @@ internal class DataStoreFileHelper( DataStoreHandler.CURRENT_DATASTORE_VERSION ) - return File( - storageDir, - "$folderName/$featureName" - ) - } - - internal fun createDataStoreDirectoryIfNecessary( - featureName: String, - storageDir: File, - internalLogger: InternalLogger - ): File { - val dataStoreDirectory = getDataStoreDirectory( - storageDir = storageDir, - featureName = featureName + val dataStoreDirectory = File( + File(storageDir, folderName), + featureName ) if (!dataStoreDirectory.existsSafe(internalLogger)) { diff --git a/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/internal/persistence/datastore/DatastoreFileReader.kt b/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/internal/persistence/datastore/DatastoreFileReader.kt index bdcb551666..16292e01e6 100644 --- a/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/internal/persistence/datastore/DatastoreFileReader.kt +++ b/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/internal/persistence/datastore/DatastoreFileReader.kt @@ -10,11 +10,11 @@ import androidx.annotation.WorkerThread import com.datadog.android.api.InternalLogger import com.datadog.android.api.storage.datastore.DataStoreReadCallback import com.datadog.android.core.internal.persistence.Deserializer -import com.datadog.android.core.internal.persistence.datastore.ext.toInt import com.datadog.android.core.internal.persistence.file.existsSafe import com.datadog.android.core.internal.persistence.tlvformat.TLVBlock import com.datadog.android.core.internal.persistence.tlvformat.TLVBlockFileReader import com.datadog.android.core.internal.persistence.tlvformat.TLVBlockType +import com.datadog.android.core.internal.utils.toInt import com.datadog.android.core.persistence.datastore.DataStoreContent import java.io.File import java.util.Locale @@ -34,8 +34,8 @@ internal class DatastoreFileReader( callback: DataStoreReadCallback ) { val datastoreFile = dataStoreFileHelper.getDataStoreFile( - featureName = featureName, storageDir = storageDir, + featureName = featureName, key = key ) diff --git a/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/internal/persistence/datastore/DatastoreFileWriter.kt b/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/internal/persistence/datastore/DatastoreFileWriter.kt index dd72950572..466f71523c 100644 --- a/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/internal/persistence/datastore/DatastoreFileWriter.kt +++ b/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/internal/persistence/datastore/DatastoreFileWriter.kt @@ -9,7 +9,6 @@ package com.datadog.android.core.internal.persistence.datastore import androidx.annotation.WorkerThread import com.datadog.android.api.InternalLogger import com.datadog.android.api.storage.datastore.DataStoreWriteCallback -import com.datadog.android.core.internal.persistence.datastore.ext.toByteArray import com.datadog.android.core.internal.persistence.file.FileReaderWriter import com.datadog.android.core.internal.persistence.file.deleteDirectoryContentsSafe import com.datadog.android.core.internal.persistence.file.deleteSafe @@ -17,6 +16,7 @@ import com.datadog.android.core.internal.persistence.file.existsSafe import com.datadog.android.core.internal.persistence.tlvformat.TLVBlock import com.datadog.android.core.internal.persistence.tlvformat.TLVBlockType import com.datadog.android.core.internal.utils.join +import com.datadog.android.core.internal.utils.toByteArray import com.datadog.android.core.persistence.Serializer import java.io.File @@ -36,8 +36,8 @@ internal class DatastoreFileWriter( version: Int ) { val datastoreFile = dataStoreFileHelper.getDataStoreFile( - featureName = featureName, storageDir = storageDir, + featureName = featureName, key = key ) @@ -71,8 +71,8 @@ internal class DatastoreFileWriter( @WorkerThread internal fun delete(key: String, callback: DataStoreWriteCallback?) { val datastoreFile = dataStoreFileHelper.getDataStoreFile( - featureName = featureName, storageDir = storageDir, + featureName = featureName, key = key ) diff --git a/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/internal/persistence/datastore/ext/ByteArrayExt.kt b/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/internal/persistence/datastore/ext/ByteArrayExt.kt deleted file mode 100644 index c029ffc3a7..0000000000 --- a/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/internal/persistence/datastore/ext/ByteArrayExt.kt +++ /dev/null @@ -1,38 +0,0 @@ -/* - * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. - * This product includes software developed at Datadog (https://www.datadoghq.com/). - * Copyright 2016-Present Datadog, Inc. - */ - -package com.datadog.android.core.internal.persistence.datastore.ext - -import java.nio.ByteBuffer - -internal fun ByteArray.toLong(): Long { - // wrap provides valid backing array - @Suppress("UnsafeThirdPartyFunctionCall") - return ByteBuffer.wrap(this).getLong() -} - -internal fun ByteArray.toInt(): Int { - // wrap provides valid backing array - @Suppress("UnsafeThirdPartyFunctionCall") - return ByteBuffer.wrap(this).getInt() -} - -internal fun ByteArray.toShort(): Short { - // wrap provides valid backing array - @Suppress("UnsafeThirdPartyFunctionCall") - return ByteBuffer.wrap(this).getShort() -} - -@Suppress("TooGenericExceptionCaught", "SwallowedException") -internal fun ByteArray.copyOfRangeSafe(start: Int, end: Int): ByteArray { - return try { - this.copyOfRange(start, end) - } catch (e: IndexOutOfBoundsException) { - byteArrayOf() - } catch (e: IllegalArgumentException) { - byteArrayOf() - } -} diff --git a/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/internal/persistence/datastore/ext/IntExt.kt b/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/internal/persistence/datastore/ext/IntExt.kt deleted file mode 100644 index 87591488fd..0000000000 --- a/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/internal/persistence/datastore/ext/IntExt.kt +++ /dev/null @@ -1,17 +0,0 @@ -/* - * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. - * This product includes software developed at Datadog (https://www.datadoghq.com/). - * Copyright 2016-Present Datadog, Inc. - */ - -package com.datadog.android.core.internal.persistence.datastore.ext - -import java.nio.ByteBuffer - -internal fun Int.toByteArray(): ByteArray { - // capacity is not a negative integer, buffer is not read only, - // has sufficient capacity and is backed by an array - @Suppress("UnsafeThirdPartyFunctionCall") - return ByteBuffer.allocate(Int.SIZE_BYTES) - .putInt(this).array() -} diff --git a/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/internal/persistence/datastore/ext/LongExt.kt b/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/internal/persistence/datastore/ext/LongExt.kt deleted file mode 100644 index b9db105cf6..0000000000 --- a/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/internal/persistence/datastore/ext/LongExt.kt +++ /dev/null @@ -1,17 +0,0 @@ -/* - * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. - * This product includes software developed at Datadog (https://www.datadoghq.com/). - * Copyright 2016-Present Datadog, Inc. - */ - -package com.datadog.android.core.internal.persistence.datastore.ext - -import java.nio.ByteBuffer - -internal fun Long.toByteArray(): ByteArray { - // capacity is not a negative integer, buffer is not read only, - // has sufficient capacity and is backed by an array - @Suppress("UnsafeThirdPartyFunctionCall") - return ByteBuffer.allocate(Long.SIZE_BYTES) - .putLong(this).array() -} diff --git a/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/internal/persistence/file/FileExt.kt b/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/internal/persistence/file/FileExt.kt index aa0f9c255a..0b47b58d73 100644 --- a/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/internal/persistence/file/FileExt.kt +++ b/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/internal/persistence/file/FileExt.kt @@ -11,6 +11,7 @@ import com.datadog.android.api.InternalLogger import com.datadog.android.lint.InternalApi import java.io.File import java.io.FileFilter +import java.io.FilenameFilter import java.nio.charset.Charset /* @@ -118,6 +119,17 @@ internal fun File.listFilesSafe(filter: FileFilter, internalLogger: InternalLogg } } +/** + * Non-throwing version of [File.listFiles]. If exception happens, null is returned. + */ +@InternalApi +fun File.listFilesSafe(internalLogger: InternalLogger, filter: FilenameFilter): Array? { + return safeCall(default = null, internalLogger) { + @Suppress("UnsafeThirdPartyFunctionCall") + listFiles(filter) + } +} + internal fun File.lengthSafe(internalLogger: InternalLogger): Long { return safeCall(default = 0L, internalLogger) { @Suppress("UnsafeThirdPartyFunctionCall") diff --git a/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/internal/persistence/file/FileOrchestrator.kt b/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/internal/persistence/file/FileOrchestrator.kt index c7cd8f7cc5..4ca70d627f 100644 --- a/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/internal/persistence/file/FileOrchestrator.kt +++ b/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/internal/persistence/file/FileOrchestrator.kt @@ -61,4 +61,9 @@ internal interface FileOrchestrator { */ @WorkerThread fun getMetadataFile(file: File): File? + + /** + * @return the name of the root directory of this orchestrator or null if the root directory does not exist. + */ + fun getRootDirName(): String? } diff --git a/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/internal/persistence/file/advanced/ConsentAwareFileOrchestrator.kt b/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/internal/persistence/file/advanced/ConsentAwareFileOrchestrator.kt index 208ac3af94..daf9112d12 100644 --- a/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/internal/persistence/file/advanced/ConsentAwareFileOrchestrator.kt +++ b/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/internal/persistence/file/advanced/ConsentAwareFileOrchestrator.kt @@ -27,6 +27,7 @@ internal open class ConsentAwareFileOrchestrator( internal val internalLogger: InternalLogger ) : FileOrchestrator, TrackingConsentProviderCallback { + @Volatile private lateinit var delegateOrchestrator: FileOrchestrator init { @@ -57,6 +58,10 @@ internal open class ConsentAwareFileOrchestrator( return null } + override fun getRootDirName(): String? { + return null + } + @WorkerThread override fun getFlushableFiles(): List { return grantedOrchestrator.getFlushableFiles() diff --git a/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/internal/persistence/file/batch/BatchFileOrchestrator.kt b/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/internal/persistence/file/batch/BatchFileOrchestrator.kt index cff71302fd..ad7e0493c7 100644 --- a/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/internal/persistence/file/batch/BatchFileOrchestrator.kt +++ b/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/internal/persistence/file/batch/BatchFileOrchestrator.kt @@ -110,6 +110,10 @@ internal class BatchFileOrchestrator( return rootDir } + override fun getRootDirName(): String { + return rootDir.nameWithoutExtension + } + @WorkerThread override fun getMetadataFile(file: File): File? { if (file.parent != rootDir.path) { diff --git a/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/internal/persistence/file/batch/PlainBatchFileReaderWriter.kt b/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/internal/persistence/file/batch/PlainBatchFileReaderWriter.kt index 0fed5c4c27..5ab339d005 100644 --- a/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/internal/persistence/file/batch/PlainBatchFileReaderWriter.kt +++ b/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/internal/persistence/file/batch/PlainBatchFileReaderWriter.kt @@ -26,7 +26,7 @@ internal class PlainBatchFileReaderWriter( private val internalLogger: InternalLogger ) : BatchFileReaderWriter { - // region FileWriter+FileReader + // region FileWriter @WorkerThread override fun writeData( @@ -56,6 +56,10 @@ internal class PlainBatchFileReaderWriter( } } + // endregion + + // region FileReader + @WorkerThread override fun readData( file: File diff --git a/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/internal/persistence/file/single/SingleFileOrchestrator.kt b/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/internal/persistence/file/single/SingleFileOrchestrator.kt index ba7c80c113..c4f0c6a200 100644 --- a/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/internal/persistence/file/single/SingleFileOrchestrator.kt +++ b/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/internal/persistence/file/single/SingleFileOrchestrator.kt @@ -60,5 +60,9 @@ internal class SingleFileOrchestrator( return null } + override fun getRootDirName(): String? { + return file.parentFile?.nameWithoutExtension + } + // endregion } diff --git a/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/internal/persistence/tlvformat/TLVBlockFileReader.kt b/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/internal/persistence/tlvformat/TLVBlockFileReader.kt index afb0c284a2..a5f8db3683 100644 --- a/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/internal/persistence/tlvformat/TLVBlockFileReader.kt +++ b/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/internal/persistence/tlvformat/TLVBlockFileReader.kt @@ -8,10 +8,10 @@ package com.datadog.android.core.internal.persistence.tlvformat import androidx.annotation.WorkerThread import com.datadog.android.api.InternalLogger -import com.datadog.android.core.internal.persistence.datastore.ext.copyOfRangeSafe -import com.datadog.android.core.internal.persistence.datastore.ext.toInt -import com.datadog.android.core.internal.persistence.datastore.ext.toShort import com.datadog.android.core.internal.persistence.file.FileReaderWriter +import com.datadog.android.core.internal.utils.copyOfRangeSafe +import com.datadog.android.core.internal.utils.toInt +import com.datadog.android.core.internal.utils.toShort import java.io.File import java.util.Locale diff --git a/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/internal/utils/ByteArrayExt.kt b/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/internal/utils/ByteArrayExt.kt index f7162c9be7..c28d18664c 100644 --- a/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/internal/utils/ByteArrayExt.kt +++ b/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/internal/utils/ByteArrayExt.kt @@ -8,6 +8,7 @@ package com.datadog.android.core.internal.utils import com.datadog.android.api.InternalLogger import com.datadog.android.lint.InternalApi +import java.nio.ByteBuffer /** * Splits this [ByteArray] to a list of [ByteArray] around occurrences of the specified [delimiter]. @@ -129,3 +130,50 @@ internal fun ByteArray.copyTo( true } } + +/** + * Reads a long from this byte array. + * Note that the ByteArray needs to be at least of size 8. + */ +internal fun ByteArray.toLong(): Long { + // wrap provides valid backing array + @Suppress("UnsafeThirdPartyFunctionCall") + return ByteBuffer.wrap(this).getLong() +} + +/** + * Reads an int from this byte array. + * Note that the ByteArray needs to be at least of size 4. + */ +internal fun ByteArray.toInt(): Int { + // wrap provides valid backing array + @Suppress("UnsafeThirdPartyFunctionCall") + return ByteBuffer.wrap(this).getInt() +} + +/** + * Reads a short from this byte array. + * Note that the ByteArray needs to be at least of size 2. + */ +internal fun ByteArray.toShort(): Short { + // wrap provides valid backing array + @Suppress("UnsafeThirdPartyFunctionCall") + return ByteBuffer.wrap(this).getShort() +} + +/** + * Creates a copy of a range within this ByteArray into a new Byte Array. + * If the copy would have thrown an exception, an empty byte array is returned instead. + * @param fromIndex the start of the range (inclusive) to copy. + * @param toIndex the end of the range (exclusive) to copy. + */ +@Suppress("TooGenericExceptionCaught", "SwallowedException") +internal fun ByteArray.copyOfRangeSafe(fromIndex: Int, toIndex: Int): ByteArray { + return try { + this.copyOfRange(fromIndex, toIndex) + } catch (e: IndexOutOfBoundsException) { + byteArrayOf() + } catch (e: IllegalArgumentException) { + byteArrayOf() + } +} diff --git a/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/internal/utils/MiscUtils.kt b/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/internal/utils/MiscUtils.kt index cb44b9216a..5cada07785 100644 --- a/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/internal/utils/MiscUtils.kt +++ b/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/internal/utils/MiscUtils.kt @@ -51,7 +51,7 @@ internal inline fun retryWithDelay( { "Internal I/O operation failed" }, e ) - return false + false } loopTimeOrigin = System.nanoTime() retryCounter++ @@ -135,7 +135,9 @@ internal fun Any?.fromJsonElement(): Any? { this } } + is JsonObject -> this.asDeepMap() + else -> this } } diff --git a/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/internal/utils/NumberExt.kt b/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/internal/utils/NumberExt.kt index 6cebf1a76b..4ee6e78604 100644 --- a/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/internal/utils/NumberExt.kt +++ b/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/internal/utils/NumberExt.kt @@ -8,6 +8,7 @@ package com.datadog.android.core.internal.utils import com.datadog.android.lint.InternalApi import java.math.BigInteger +import java.nio.ByteBuffer /** * Radix used to convert numbers to hexadecimal strings. @@ -15,19 +16,49 @@ import java.math.BigInteger internal const val HEX_RADIX: Int = 16 /** - * Converts [Int] into hexadecimal representation. + * Converts this [Short] into a [ByteArray] representation. + */ +internal fun Short.toByteArray(): ByteArray { + // capacity is not a negative integer, buffer is not read only, + // has sufficient capacity and is backed by an array + @Suppress("UnsafeThirdPartyFunctionCall") + return ByteBuffer.allocate(Short.SIZE_BYTES).putShort(this).array() +} + +/** + * Converts this [Int] into hexadecimal representation. */ @InternalApi fun Int.toHexString(): String = toString(HEX_RADIX) /** - * Converts [Long] into hexadecimal representation. + * Converts this [Int] into a [ByteArray] representation. + */ +internal fun Int.toByteArray(): ByteArray { + // capacity is not a negative integer, buffer is not read only, + // has sufficient capacity and is backed by an array + @Suppress("UnsafeThirdPartyFunctionCall") + return ByteBuffer.allocate(Int.SIZE_BYTES).putInt(this).array() +} + +/** + * Converts this [Long] into hexadecimal representation. */ @InternalApi fun Long.toHexString(): String = toString(HEX_RADIX) /** - * Converts [BigInteger] into hexadecimal representation. + * Converts this [Long] into a [ByteArray] representation. + */ +internal fun Long.toByteArray(): ByteArray { + // capacity is not a negative integer, buffer is not read only, + // has sufficient capacity and is backed by an array + @Suppress("UnsafeThirdPartyFunctionCall") + return ByteBuffer.allocate(Long.SIZE_BYTES).putLong(this).array() +} + +/** + * Converts this [BigInteger] into hexadecimal representation. */ @InternalApi fun BigInteger.toHexString(): String { diff --git a/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/internal/utils/WorkManagerUtils.kt b/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/internal/utils/WorkManagerUtils.kt index 53018d96ae..13b8a43f86 100644 --- a/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/internal/utils/WorkManagerUtils.kt +++ b/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/internal/utils/WorkManagerUtils.kt @@ -8,13 +8,13 @@ package com.datadog.android.core.internal.utils import android.content.Context import androidx.work.Constraints +import androidx.work.Data import androidx.work.ExistingWorkPolicy import androidx.work.NetworkType import androidx.work.OneTimeWorkRequest import androidx.work.WorkManager import com.datadog.android.api.InternalLogger import com.datadog.android.core.internal.data.upload.UploadWorker -import java.lang.IllegalStateException import java.util.concurrent.TimeUnit internal const val CANCEL_ERROR_MESSAGE = "Error cancelling the UploadWorker" @@ -25,10 +25,14 @@ internal const val TAG_DATADOG_UPLOAD = "DatadogBackgroundUpload" internal const val DELAY_MS: Long = 5000 -internal fun cancelUploadWorker(context: Context, internalLogger: InternalLogger) { +internal fun cancelUploadWorker( + context: Context, + instanceName: String, + internalLogger: InternalLogger +) { try { val workManager = WorkManager.getInstance(context) - workManager.cancelAllWorkByTag(TAG_DATADOG_UPLOAD) + workManager.cancelAllWorkByTag("$TAG_DATADOG_UPLOAD/$instanceName") } catch (e: IllegalStateException) { internalLogger.log( InternalLogger.Level.ERROR, @@ -40,7 +44,11 @@ internal fun cancelUploadWorker(context: Context, internalLogger: InternalLogger } @Suppress("TooGenericExceptionCaught") -internal fun triggerUploadWorker(context: Context, internalLogger: InternalLogger) { +internal fun triggerUploadWorker( + context: Context, + instanceName: String, + internalLogger: InternalLogger +) { try { val workManager = WorkManager.getInstance(context) val constraints = Constraints.Builder() @@ -48,8 +56,9 @@ internal fun triggerUploadWorker(context: Context, internalLogger: InternalLogge .build() val uploadWorkRequest = OneTimeWorkRequest.Builder(UploadWorker::class.java) .setConstraints(constraints) - .addTag(TAG_DATADOG_UPLOAD) + .addTag("$TAG_DATADOG_UPLOAD/$instanceName") .setInitialDelay(DELAY_MS, TimeUnit.MILLISECONDS) + .setInputData(Data.Builder().putString(UploadWorker.DATADOG_INSTANCE_NAME, instanceName).build()) .build() workManager.enqueueUniqueWork( UPLOAD_WORKER_NAME, diff --git a/dd-sdk-android-core/src/main/kotlin/com/datadog/android/error/internal/DatadogExceptionHandler.kt b/dd-sdk-android-core/src/main/kotlin/com/datadog/android/error/internal/DatadogExceptionHandler.kt index 7cad55a33c..6996c6c87f 100644 --- a/dd-sdk-android-core/src/main/kotlin/com/datadog/android/error/internal/DatadogExceptionHandler.kt +++ b/dd-sdk-android-core/src/main/kotlin/com/datadog/android/error/internal/DatadogExceptionHandler.kt @@ -18,6 +18,7 @@ import com.datadog.android.core.internal.thread.waitToIdle import com.datadog.android.core.internal.utils.asString import com.datadog.android.core.internal.utils.loggableStackTrace import com.datadog.android.core.internal.utils.triggerUploadWorker +import com.datadog.android.internal.utils.loggableStackTrace import java.lang.ref.WeakReference import java.util.concurrent.ThreadPoolExecutor @@ -90,7 +91,7 @@ internal class DatadogExceptionHandler( // trigger a task to send the logs ASAP contextRef.get()?.let { if (WorkManager.isInitialized()) { - triggerUploadWorker(it, sdkCore.internalLogger) + triggerUploadWorker(it, sdkCore.name, sdkCore.internalLogger) } } diff --git a/dd-sdk-android-core/src/test/kotlin/com/datadog/android/DatadogTest.kt b/dd-sdk-android-core/src/test/kotlin/com/datadog/android/DatadogTest.kt index d65191f645..71ba6d54b6 100644 --- a/dd-sdk-android-core/src/test/kotlin/com/datadog/android/DatadogTest.kt +++ b/dd-sdk-android-core/src/test/kotlin/com/datadog/android/DatadogTest.kt @@ -19,6 +19,7 @@ import com.datadog.android.core.internal.NoOpInternalSdkCore import com.datadog.android.core.internal.SdkCoreRegistry import com.datadog.android.core.internal.Sha256HashGenerator import com.datadog.android.core.internal.utils.loggableStackTrace +import com.datadog.android.internal.utils.loggableStackTrace import com.datadog.android.privacy.TrackingConsent import com.datadog.android.utils.config.ApplicationContextTestConfiguration import com.datadog.android.utils.config.InternalLoggerTestConfiguration diff --git a/dd-sdk-android-core/src/test/kotlin/com/datadog/android/InternalProxyTest.kt b/dd-sdk-android-core/src/test/kotlin/com/datadog/android/InternalProxyTest.kt index 4fa719f804..e98669ffb8 100644 --- a/dd-sdk-android-core/src/test/kotlin/com/datadog/android/InternalProxyTest.kt +++ b/dd-sdk-android-core/src/test/kotlin/com/datadog/android/InternalProxyTest.kt @@ -11,16 +11,19 @@ import com.datadog.android.api.feature.FeatureScope import com.datadog.android.core.internal.CoreFeature import com.datadog.android.core.internal.DatadogCore import com.datadog.android.core.internal.system.AppVersionProvider +import com.datadog.android.internal.telemetry.InternalTelemetryEvent import com.datadog.android.utils.forge.Configurator import fr.xgouchet.elmyr.annotation.Forgery import fr.xgouchet.elmyr.annotation.StringForgery import fr.xgouchet.elmyr.junit5.ForgeConfiguration import fr.xgouchet.elmyr.junit5.ForgeExtension +import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith import org.junit.jupiter.api.extension.Extensions import org.mockito.junit.jupiter.MockitoExtension import org.mockito.junit.jupiter.MockitoSettings +import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.doReturn import org.mockito.kotlin.mock import org.mockito.kotlin.verify @@ -49,12 +52,11 @@ internal class InternalProxyTest { proxy._telemetry.debug(message) // Then - verify(mockRumFeatureScope).sendEvent( - mapOf( - "type" to "telemetry_debug", - "message" to message - ) - ) + argumentCaptor { + verify(mockRumFeatureScope).sendEvent(capture()) + val logEvent = firstValue as InternalTelemetryEvent.Log.Debug + assertThat(logEvent.message).isEqualTo(message) + } } @Test @@ -73,14 +75,13 @@ internal class InternalProxyTest { proxy._telemetry.error(message, stack, kind) // Then - verify(mockRumFeatureScope).sendEvent( - mapOf( - "type" to "telemetry_error", - "message" to message, - "stacktrace" to stack, - "kind" to kind - ) - ) + argumentCaptor { + verify(mockRumFeatureScope).sendEvent(capture()) + val logEvent = firstValue as InternalTelemetryEvent.Log.Error + assertThat(logEvent.message).isEqualTo(message) + assertThat(logEvent.stacktrace).isEqualTo(stack) + assertThat(logEvent.kind).isEqualTo(kind) + } } @Test @@ -98,13 +99,12 @@ internal class InternalProxyTest { proxy._telemetry.error(message, throwable) // Then - verify(mockRumFeatureScope).sendEvent( - mapOf( - "type" to "telemetry_error", - "message" to message, - "throwable" to throwable - ) - ) + argumentCaptor { + verify(mockRumFeatureScope).sendEvent(capture()) + val logEvent = firstValue as InternalTelemetryEvent.Log.Error + assertThat(logEvent.message).isEqualTo(message) + assertThat(logEvent.error).isEqualTo(throwable) + } } @Test diff --git a/dd-sdk-android-core/src/test/kotlin/com/datadog/android/core/DatadogCoreInitializationTest.kt b/dd-sdk-android-core/src/test/kotlin/com/datadog/android/core/DatadogCoreInitializationTest.kt index 41f3d2c53d..c212da1a34 100644 --- a/dd-sdk-android-core/src/test/kotlin/com/datadog/android/core/DatadogCoreInitializationTest.kt +++ b/dd-sdk-android-core/src/test/kotlin/com/datadog/android/core/DatadogCoreInitializationTest.kt @@ -20,6 +20,7 @@ import com.datadog.android.core.internal.DatadogCore import com.datadog.android.core.internal.SdkFeature import com.datadog.android.core.thread.FlushableExecutorService import com.datadog.android.error.internal.CrashReportsFeature +import com.datadog.android.internal.telemetry.InternalTelemetryEvent import com.datadog.android.privacy.TrackingConsent import com.datadog.android.security.Encryption import com.datadog.android.utils.config.ApplicationContextTestConfiguration @@ -48,6 +49,7 @@ import org.mockito.Mock import org.mockito.junit.jupiter.MockitoExtension import org.mockito.junit.jupiter.MockitoSettings import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.doAnswer import org.mockito.kotlin.mock import org.mockito.kotlin.verify @@ -371,19 +373,22 @@ internal class DatadogCoreInitializationTest { } testedCore.coreFeature.uploadExecutorService.shutdownNow() - verify(mockRumFeature) - .sendEvent( - mapOf( - "type" to "telemetry_configuration", - "use_proxy" to useProxy, - "use_local_encryption" to useLocalEncryption, - "batch_size" to batchSize.windowDurationMs, - "batch_upload_frequency" to uploadFrequency.baseStepMs, - "track_errors" to trackErrors, - "batch_processing_level" to batchProcessingLevel.maxBatchesPerUploadJob, - "use_persistence_strategy_factory" to usePersistenceStrategyFactory - ) - ) + argumentCaptor { + verify(mockRumFeature).sendEvent(capture()) + val telemetryConfigurationEvent = firstValue as InternalTelemetryEvent.Configuration + assertThat(telemetryConfigurationEvent.trackErrors) + .isEqualTo(configuration.crashReportsEnabled) + assertThat(telemetryConfigurationEvent.batchSize) + .isEqualTo(configuration.coreConfig.batchSize.windowDurationMs) + assertThat(telemetryConfigurationEvent.useLocalEncryption) + .isEqualTo(configuration.coreConfig.encryption != null) + assertThat(telemetryConfigurationEvent.batchUploadFrequency) + .isEqualTo(configuration.coreConfig.uploadFrequency.baseStepMs) + assertThat(telemetryConfigurationEvent.batchProcessingLevel) + .isEqualTo(configuration.coreConfig.batchProcessingLevel.maxBatchesPerUploadJob) + assertThat(telemetryConfigurationEvent.useProxy) + .isEqualTo(configuration.coreConfig.proxy != null) + } } // region AdditionalConfig diff --git a/dd-sdk-android-core/src/test/kotlin/com/datadog/android/core/DatadogCoreTest.kt b/dd-sdk-android-core/src/test/kotlin/com/datadog/android/core/DatadogCoreTest.kt index 83f6fda220..4291d9d310 100644 --- a/dd-sdk-android-core/src/test/kotlin/com/datadog/android/core/DatadogCoreTest.kt +++ b/dd-sdk-android-core/src/test/kotlin/com/datadog/android/core/DatadogCoreTest.kt @@ -19,6 +19,7 @@ import com.datadog.android.core.internal.ContextProvider import com.datadog.android.core.internal.CoreFeature import com.datadog.android.core.internal.DatadogCore import com.datadog.android.core.internal.SdkFeature +import com.datadog.android.core.internal.lifecycle.ProcessLifecycleCallback import com.datadog.android.core.internal.lifecycle.ProcessLifecycleMonitor import com.datadog.android.core.internal.net.DefaultFirstPartyHostHeaderTypeResolver import com.datadog.android.core.internal.net.info.NetworkInfoProvider @@ -803,6 +804,20 @@ internal class DatadogCoreTest { assertThat(testedCore.isDeveloperModeEnabled).isFalse() } + @Test + fun `M register process lifecycle monitor W initialize()`() { + // Then + argumentCaptor { + verify(appContext.mockInstance) + .registerActivityLifecycleCallbacks(capture()) + assertThat(lastValue).isInstanceOf(ProcessLifecycleMonitor::class.java) + val callback = (lastValue as ProcessLifecycleMonitor).callback + assertThat(callback).isInstanceOf(ProcessLifecycleCallback::class.java) + val processLifecycleCallback = callback as ProcessLifecycleCallback + assertThat(processLifecycleCallback.instanceName).isEqualTo(fakeInstanceName) + } + } + @Test fun `M unregister process lifecycle monitor W stop()`() { // Given @@ -812,7 +827,7 @@ internal class DatadogCoreTest { testedCore.stop() // Then - argumentCaptor { + argumentCaptor { verify(appContext.mockInstance, times(expectedInvocations)) .unregisterActivityLifecycleCallbacks(capture()) assertThat(lastValue).isInstanceOf(ProcessLifecycleMonitor::class.java) @@ -870,6 +885,34 @@ internal class DatadogCoreTest { } } + @Test + fun `M return false W isActiveCore() { CoreFeature inactive }`() { + // Given + val mockCoreFeature = mock() + whenever(mockCoreFeature.initialized).thenReturn(AtomicBoolean(false)) + testedCore.coreFeature = mockCoreFeature + + // When + val isActive = testedCore.isCoreActive() + + // Then + assertThat(isActive).isFalse() + } + + @Test + fun `M return true W isActiveCore() { CoreFeature active }`() { + // Given + val mockCoreFeature = mock() + whenever(mockCoreFeature.initialized).thenReturn(AtomicBoolean(true)) + testedCore.coreFeature = mockCoreFeature + + // When + val isActive = testedCore.isCoreActive() + + // Then + assertThat(isActive).isTrue() + } + class ErrorRecordingRunnable( private val collector: MutableList, private val delegate: Runnable diff --git a/dd-sdk-android-core/src/test/kotlin/com/datadog/android/core/configuration/ConfigurationBuilderTest.kt b/dd-sdk-android-core/src/test/kotlin/com/datadog/android/core/configuration/ConfigurationBuilderTest.kt index 007eb15baf..fd639a72d1 100644 --- a/dd-sdk-android-core/src/test/kotlin/com/datadog/android/core/configuration/ConfigurationBuilderTest.kt +++ b/dd-sdk-android-core/src/test/kotlin/com/datadog/android/core/configuration/ConfigurationBuilderTest.kt @@ -434,6 +434,21 @@ internal class ConfigurationBuilderTest { ) } + @Test + fun `M build config with allowClearTextHttp W allowClearTextHttp() and build()`() { + // When + val config = testedBuilder + .allowClearTextHttp() + .build() + + // Then + assertThat(config.coreConfig).isEqualTo( + Configuration.DEFAULT_CORE_CONFIG.copy( + needsClearTextHttp = true + ) + ) + } + companion object { val logger = InternalLoggerTestConfiguration() diff --git a/dd-sdk-android-core/src/test/kotlin/com/datadog/android/core/constraints/DatadogDataConstraintsTest.kt b/dd-sdk-android-core/src/test/kotlin/com/datadog/android/core/constraints/DatadogDataConstraintsTest.kt index 095b307ba3..397c088db1 100644 --- a/dd-sdk-android-core/src/test/kotlin/com/datadog/android/core/constraints/DatadogDataConstraintsTest.kt +++ b/dd-sdk-android-core/src/test/kotlin/com/datadog/android/core/constraints/DatadogDataConstraintsTest.kt @@ -12,6 +12,7 @@ import com.datadog.android.utils.times import com.datadog.android.utils.verifyLog import fr.xgouchet.elmyr.Case import fr.xgouchet.elmyr.Forge +import fr.xgouchet.elmyr.annotation.StringForgery import fr.xgouchet.elmyr.junit5.ForgeConfiguration import fr.xgouchet.elmyr.junit5.ForgeExtension import org.assertj.core.api.Assertions.assertThat @@ -326,6 +327,24 @@ internal class DatadogDataConstraintsTest { .containsExactlyEntriesOf(attributes.filterNot { reservedKeys.contains(it.key) }) } + @Test + fun `M drop the reserved attributes W validateAttributes { null keys }`( + @StringForgery value: String + ) { + // GIVEN + val attributes = mapOf(null to value) as Map<*, *> + + @Suppress("UNCHECKED_CAST") // simulate an unsafe map sent from Java + val unsafeAttributes = attributes as Map + + // WHEN + + val sanitizedAttributes = testedConstraints.validateAttributes(unsafeAttributes) + + // THEN + assertThat(sanitizedAttributes).isEmpty() + } + // endregion // region Events diff --git a/dd-sdk-android-core/src/test/kotlin/com/datadog/android/core/internal/data/upload/DataOkHttpUploaderTest.kt b/dd-sdk-android-core/src/test/kotlin/com/datadog/android/core/internal/data/upload/DataOkHttpUploaderTest.kt index d8f88f2e0a..31e8601a5c 100644 --- a/dd-sdk-android-core/src/test/kotlin/com/datadog/android/core/internal/data/upload/DataOkHttpUploaderTest.kt +++ b/dd-sdk-android-core/src/test/kotlin/com/datadog/android/core/internal/data/upload/DataOkHttpUploaderTest.kt @@ -8,14 +8,17 @@ package com.datadog.android.core.internal.data.upload import com.datadog.android.api.InternalLogger import com.datadog.android.api.context.DatadogContext +import com.datadog.android.api.net.RequestExecutionContext import com.datadog.android.api.net.RequestFactory import com.datadog.android.api.storage.RawBatchEvent +import com.datadog.android.core.internal.persistence.BatchId import com.datadog.android.core.internal.system.AndroidInfoProvider import com.datadog.android.utils.forge.Configurator import com.datadog.android.utils.verifyLog import fr.xgouchet.elmyr.Forge import fr.xgouchet.elmyr.annotation.AdvancedForgery import fr.xgouchet.elmyr.annotation.Forgery +import fr.xgouchet.elmyr.annotation.IntForgery import fr.xgouchet.elmyr.annotation.MapForgery import fr.xgouchet.elmyr.annotation.StringForgery import fr.xgouchet.elmyr.annotation.StringForgeryType @@ -41,9 +44,11 @@ import org.mockito.junit.jupiter.MockitoSettings import org.mockito.kotlin.any import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.doReturn +import org.mockito.kotlin.doReturnConsecutively import org.mockito.kotlin.doThrow import org.mockito.kotlin.eq import org.mockito.kotlin.mock +import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.verifyNoInteractions import org.mockito.kotlin.whenever @@ -125,8 +130,11 @@ internal class DataOkHttpUploaderTest { private lateinit var fakeSdkUserAgent: String + private var fakeBatchId: BatchId? = null + @BeforeEach fun `set up`(forge: Forge) { + fakeBatchId = forge.aNullable { getForgery() } whenever(mockCallFactory.newCall(any())) doReturn mockCall whenever(mockAndroidInfoProvider.osVersion) doReturn fakeDeviceVersion @@ -156,7 +164,7 @@ internal class DataOkHttpUploaderTest { body = fakeRequestBody.toByteArray(), contentType = forge.aNullable { fakeContentType } ) - whenever(mockRequestFactory.create(eq(fakeContext), any(), any())) doReturn + whenever(mockRequestFactory.create(eq(fakeContext), any(), any(), any())) doReturn fakeDatadogRequest fakeSystemUserAgent = if (forge.aBool()) forge.anAlphaNumericalString() else "" @@ -190,7 +198,7 @@ internal class DataOkHttpUploaderTest { whenever(mockCall.execute()) doReturn mockResponse(202, "{}") // When - val result = testedUploader.upload(fakeContext, batch, batchMetadata) + val result = testedUploader.upload(fakeContext, batch, batchMetadata, fakeBatchId) // Then assertThat(result).isInstanceOf(UploadStatus.Success::class.java) @@ -213,7 +221,7 @@ internal class DataOkHttpUploaderTest { whenever(mockCall.execute()) doReturn mockResponse(202, "{}") // When - val result = testedUploader.upload(fakeContext, batch, batchMetadata) + val result = testedUploader.upload(fakeContext, batch, batchMetadata, fakeBatchId) // Then assertThat(result).isInstanceOf(UploadStatus.Success::class.java) @@ -236,11 +244,11 @@ internal class DataOkHttpUploaderTest { put(RequestFactory.HEADER_API_KEY, forge.anElementFrom("", invalidValue)) } ) - whenever(mockRequestFactory.create(fakeContext, batch, batchMetadata)) doReturn + whenever(mockRequestFactory.create(eq(fakeContext), any(), eq(batch), eq(batchMetadata))) doReturn fakeDatadogRequest // When - val result = testedUploader.upload(fakeContext, batch, batchMetadata) + val result = testedUploader.upload(fakeContext, batch, batchMetadata, fakeBatchId) // Then assertThat(result).isInstanceOf(UploadStatus.InvalidTokenError::class.java) @@ -261,7 +269,7 @@ internal class DataOkHttpUploaderTest { whenever(mockCall.execute()) doReturn mockResponse(202, message) // When - val result = testedUploader.upload(fakeContext, batch, batchMetadata) + val result = testedUploader.upload(fakeContext, batch, batchMetadata, fakeBatchId) // Then assertThat(result).isInstanceOf(UploadStatus.Success::class.java) @@ -281,7 +289,7 @@ internal class DataOkHttpUploaderTest { whenever(mockCall.execute()) doReturn mockResponse(400, message) // When - val result = testedUploader.upload(fakeContext, batch, batchMetadata) + val result = testedUploader.upload(fakeContext, batch, batchMetadata, fakeBatchId) // Then assertThat(result).isInstanceOf(UploadStatus.HttpClientError::class.java) @@ -301,7 +309,7 @@ internal class DataOkHttpUploaderTest { whenever(mockCall.execute()) doReturn mockResponse(401, message) // When - val result = testedUploader.upload(fakeContext, batch, batchMetadata) + val result = testedUploader.upload(fakeContext, batch, batchMetadata, fakeBatchId) // Then assertThat(result).isInstanceOf(UploadStatus.InvalidTokenError::class.java) @@ -321,7 +329,7 @@ internal class DataOkHttpUploaderTest { whenever(mockCall.execute()) doReturn mockResponse(403, message) // When - val result = testedUploader.upload(fakeContext, batch, batchMetadata) + val result = testedUploader.upload(fakeContext, batch, batchMetadata, fakeBatchId) // Then assertThat(result).isInstanceOf(UploadStatus.InvalidTokenError::class.java) @@ -341,7 +349,7 @@ internal class DataOkHttpUploaderTest { whenever(mockCall.execute()) doReturn mockResponse(408, message) // When - val result = testedUploader.upload(fakeContext, batch, batchMetadata) + val result = testedUploader.upload(fakeContext, batch, batchMetadata, fakeBatchId) // Then assertThat(result).isInstanceOf(UploadStatus.HttpClientRateLimiting::class.java) @@ -361,7 +369,7 @@ internal class DataOkHttpUploaderTest { whenever(mockCall.execute()) doReturn mockResponse(413, message) // When - val result = testedUploader.upload(fakeContext, batch, batchMetadata) + val result = testedUploader.upload(fakeContext, batch, batchMetadata, fakeBatchId) // Then assertThat(result).isInstanceOf(UploadStatus.HttpClientError::class.java) @@ -381,7 +389,7 @@ internal class DataOkHttpUploaderTest { whenever(mockCall.execute()) doReturn mockResponse(429, message) // When - val result = testedUploader.upload(fakeContext, batch, batchMetadata) + val result = testedUploader.upload(fakeContext, batch, batchMetadata, fakeBatchId) // Then assertThat(result).isInstanceOf(UploadStatus.HttpClientRateLimiting::class.java) @@ -401,7 +409,7 @@ internal class DataOkHttpUploaderTest { whenever(mockCall.execute()) doReturn mockResponse(500, message) // When - val result = testedUploader.upload(fakeContext, batch, batchMetadata) + val result = testedUploader.upload(fakeContext, batch, batchMetadata, fakeBatchId) // Then assertThat(result).isInstanceOf(UploadStatus.HttpServerError::class.java) @@ -421,7 +429,7 @@ internal class DataOkHttpUploaderTest { whenever(mockCall.execute()) doReturn mockResponse(502, message) // When - val result = testedUploader.upload(fakeContext, batch, batchMetadata) + val result = testedUploader.upload(fakeContext, batch, batchMetadata, fakeBatchId) // Then assertThat(result).isInstanceOf(UploadStatus.HttpServerError::class.java) @@ -441,7 +449,7 @@ internal class DataOkHttpUploaderTest { whenever(mockCall.execute()) doReturn mockResponse(503, message) // When - val result = testedUploader.upload(fakeContext, batch, batchMetadata) + val result = testedUploader.upload(fakeContext, batch, batchMetadata, fakeBatchId) // Then assertThat(result).isInstanceOf(UploadStatus.HttpServerError::class.java) @@ -461,7 +469,7 @@ internal class DataOkHttpUploaderTest { whenever(mockCall.execute()) doReturn mockResponse(504, message) // When - val result = testedUploader.upload(fakeContext, batch, batchMetadata) + val result = testedUploader.upload(fakeContext, batch, batchMetadata, fakeBatchId) // Then assertThat(result).isInstanceOf(UploadStatus.HttpServerError::class.java) @@ -481,7 +489,7 @@ internal class DataOkHttpUploaderTest { whenever(mockCall.execute()) doReturn mockResponse(507, message) // When - val result = testedUploader.upload(fakeContext, batch, batchMetadata) + val result = testedUploader.upload(fakeContext, batch, batchMetadata, fakeBatchId) // Then assertThat(result).isInstanceOf(UploadStatus.HttpServerError::class.java) @@ -511,7 +519,7 @@ internal class DataOkHttpUploaderTest { whenever(mockCall.execute()) doReturn mockResponse(statusCode, message) // When - val result = testedUploader.upload(fakeContext, batch, batchMetadata) + val result = testedUploader.upload(fakeContext, batch, batchMetadata, fakeBatchId) // Then assertThat(result).isInstanceOf(UploadStatus.UnknownHttpError::class.java) @@ -531,7 +539,7 @@ internal class DataOkHttpUploaderTest { whenever(mockCall.execute()) doThrow IOException(message) // When - val result = testedUploader.upload(fakeContext, batch, batchMetadata) + val result = testedUploader.upload(fakeContext, batch, batchMetadata, fakeBatchId) // Then assertThat(result).isInstanceOf(UploadStatus.NetworkError::class.java) @@ -550,7 +558,7 @@ internal class DataOkHttpUploaderTest { whenever(mockCall.execute()) doThrow UnknownHostException(message) // When - val result = testedUploader.upload(fakeContext, batch, batchMetadata) + val result = testedUploader.upload(fakeContext, batch, batchMetadata, fakeBatchId) // Then assertThat(result).isInstanceOf(UploadStatus.DNSError::class.java) @@ -569,7 +577,7 @@ internal class DataOkHttpUploaderTest { whenever(mockCall.execute()) doThrow throwable // When - val result = testedUploader.upload(fakeContext, batch, batchMetadata) + val result = testedUploader.upload(fakeContext, batch, batchMetadata, fakeBatchId) // Then assertThat(result).isInstanceOf(UploadStatus.UnknownException::class.java) @@ -585,11 +593,11 @@ internal class DataOkHttpUploaderTest { ) { // Given val batchMetadata = batchMeta.toByteArray() - whenever(mockRequestFactory.create(fakeContext, batch, batchMetadata)) + whenever(mockRequestFactory.create(eq(fakeContext), any(), eq(batch), eq(batchMetadata))) .doThrow(fakeException) // When - val result = testedUploader.upload(fakeContext, batch, batchMetadata) + val result = testedUploader.upload(fakeContext, batch, batchMetadata, fakeBatchId) // Then assertThat(result).isInstanceOf(UploadStatus.RequestCreationError::class.java) @@ -605,7 +613,7 @@ internal class DataOkHttpUploaderTest { ) { // Given val batchMetadata = batchMeta.toByteArray() - whenever(mockRequestFactory.create(fakeContext, batch, batchMetadata)) + whenever(mockRequestFactory.create(eq(fakeContext), any(), eq(batch), eq(batchMetadata))) .doThrow(fakeException) // When @@ -623,6 +631,138 @@ internal class DataOkHttpUploaderTest { // endregion + // region ExecutionContext + + @Test + fun `M pass the ExecutionContext to requestFactory W upload { same batchId retried }`( + @Forgery batch: List, + @StringForgery batchMeta: String, + @StringForgery message: String, + @IntForgery(2, 10) retries: Int, + @Forgery batchId: BatchId, + forge: Forge + ) { + // Given + val statusCodes = forge.aList(size = retries) { forge.anInt(400, 600) } + whenever(mockCall.execute()) doReturnConsecutively statusCodes.map { mockResponse(it, message) } + + // When + repeat(retries) { + testedUploader.upload(fakeContext, batch, batchMeta.toByteArray(), batchId) + } + + // Then + argumentCaptor() { + verify(mockRequestFactory, times(retries)) + .create(eq(fakeContext), capture(), eq(batch), eq(batchMeta.toByteArray())) + allValues.forEachIndexed() { index, value -> + assertThat(value.attemptNumber).isEqualTo(index + 1) + if (index == 0) { + assertThat(value.previousResponseCode).isNull() + } else { + assertThat(value.previousResponseCode).isEqualTo(statusCodes[index - 1]) + } + } + } + } + + @Test + fun `M pass the ExecutionContext to requestFactory W upload { same batchId retried, new thread each time }`( + // we are testing the same as the previous test, but we are making sure that the state of the uploader + // it is shared between threads (it should be kept in the heap and not thread local memory) + @Forgery batch: List, + @StringForgery batchMeta: String, + @StringForgery message: String, + @IntForgery(2, 10) retries: Int, + @Forgery batchId: BatchId, + forge: Forge + ) { + // Given + val statusCodes = forge.aList(size = retries) { forge.anInt(400, 600) } + whenever(mockCall.execute()) doReturnConsecutively statusCodes.map { mockResponse(it, message) } + + // When + repeat(retries) { + val thread = Thread { + testedUploader.upload(fakeContext, batch, batchMeta.toByteArray(), batchId) + } + thread.start() + thread.join() + } + + // Then + argumentCaptor() { + verify(mockRequestFactory, times(retries)) + .create(eq(fakeContext), capture(), eq(batch), eq(batchMeta.toByteArray())) + allValues.forEachIndexed() { index, value -> + assertThat(value.attemptNumber).isEqualTo(index + 1) + if (index == 0) { + assertThat(value.previousResponseCode).isNull() + } else { + assertThat(value.previousResponseCode).isEqualTo(statusCodes[index - 1]) + } + } + } + } + + @Test + fun `M pass empty ExecutionContext to requestFactory W upload { null batchId }`( + @Forgery batch: List, + @StringForgery batchMeta: String, + @StringForgery message: String, + @IntForgery(2, 10) retries: Int, + forge: Forge + ) { + // Given + val statusCodes = forge.aList(size = retries) { forge.anInt(400, 600) } + whenever(mockCall.execute()) doReturnConsecutively statusCodes.map { mockResponse(it, message) } + + // When + repeat(retries) { + testedUploader.upload(fakeContext, batch, batchMeta.toByteArray(), null) + } + + // Then + argumentCaptor() { + verify(mockRequestFactory, times(retries)) + .create(eq(fakeContext), capture(), eq(batch), eq(batchMeta.toByteArray())) + allValues.forEach { value -> + assertThat(value.attemptNumber).isEqualTo(1) + assertThat(value.previousResponseCode).isNull() + } + } + } + + @Test + fun `M pass the ExecutionContext to requestFactory W upload { different batchId upload }`( + @Forgery batch: List, + @StringForgery batchMeta: String, + @StringForgery message: String, + forge: Forge + ) { + // Given + val batchIds: List = forge.aList(size = forge.anInt(min = 2, max = 10)) { getForgery() } + val statusCodes = forge.aList(size = batchIds.size) { forge.anInt(200, 300) } + whenever(mockCall.execute()) doReturnConsecutively statusCodes.map { mockResponse(it, message) } + + // When + batchIds.forEach { batchId -> + testedUploader.upload(fakeContext, batch, batchMeta.toByteArray(), batchId) + } + + // Then + argumentCaptor() { + verify(mockRequestFactory, times(batchIds.size)) + .create(eq(fakeContext), capture(), eq(batch), eq(batchMeta.toByteArray())) + allValues.forEach { value -> + assertThat(value.attemptNumber).isEqualTo(1) + assertThat(value.previousResponseCode).isNull() + } + } + } + + // endregion + @Test fun `M log warning W upload() { feature request has user-agent header }`( @Forgery batch: List, @@ -641,11 +781,11 @@ internal class DataOkHttpUploaderTest { } ) - whenever(mockRequestFactory.create(fakeContext, batch, batchMetadata)) doReturn + whenever(mockRequestFactory.create(eq(fakeContext), any(), eq(batch), eq(batchMetadata))) doReturn fakeDatadogRequest // When - testedUploader.upload(fakeContext, batch, batchMetadata) + testedUploader.upload(fakeContext, batch, batchMetadata, fakeBatchId) // Then mockLogger.verifyLog( diff --git a/dd-sdk-android-core/src/test/kotlin/com/datadog/android/core/internal/data/upload/DataUploadRunnableTest.kt b/dd-sdk-android-core/src/test/kotlin/com/datadog/android/core/internal/data/upload/DataUploadRunnableTest.kt index 4cea35d4c0..5b5dbf8451 100644 --- a/dd-sdk-android-core/src/test/kotlin/com/datadog/android/core/internal/data/upload/DataUploadRunnableTest.kt +++ b/dd-sdk-android-core/src/test/kotlin/com/datadog/android/core/internal/data/upload/DataUploadRunnableTest.kt @@ -12,7 +12,6 @@ import com.datadog.android.api.context.NetworkInfo import com.datadog.android.api.storage.RawBatchEvent import com.datadog.android.core.configuration.UploadSchedulerStrategy import com.datadog.android.core.internal.ContextProvider -import com.datadog.android.core.internal.configuration.DataUploadConfiguration import com.datadog.android.core.internal.net.info.NetworkInfoProvider import com.datadog.android.core.internal.persistence.BatchData import com.datadog.android.core.internal.persistence.BatchId @@ -178,7 +177,8 @@ internal class DataUploadRunnableTest { mockDataUploader.upload( fakeContext, batch, - batchMetadata + batchMetadata, + batchId ) ) doReturn fakeUploadStatus @@ -191,7 +191,7 @@ internal class DataUploadRunnableTest { any(), eq(true) ) - verify(mockDataUploader, times(fakeMaxBatchesPerJob)).upload(fakeContext, batch, batchMetadata) + verify(mockDataUploader, times(fakeMaxBatchesPerJob)).upload(fakeContext, batch, batchMetadata, batchId) verify(mockUploadSchedulerStrategy).getMsDelayUntilNextUpload( fakeFeatureName, fakeMaxBatchesPerJob, @@ -226,7 +226,8 @@ internal class DataUploadRunnableTest { mockDataUploader.upload( fakeContext, batch, - batchMetadata + batchMetadata, + batchId ) ) doReturn fakeUploadStatus @@ -239,7 +240,7 @@ internal class DataUploadRunnableTest { any(), eq(true) ) - verify(mockDataUploader, times(fakeMaxBatchesPerJob)).upload(fakeContext, batch, batchMetadata) + verify(mockDataUploader, times(fakeMaxBatchesPerJob)).upload(fakeContext, batch, batchMetadata, batchId) verify(mockUploadSchedulerStrategy).getMsDelayUntilNextUpload( fakeFeatureName, fakeMaxBatchesPerJob, @@ -273,7 +274,8 @@ internal class DataUploadRunnableTest { mockDataUploader.upload( fakeContext, batch, - batchMetadata + batchMetadata, + batchId ) ) doReturn fakeUploadStatus @@ -286,7 +288,7 @@ internal class DataUploadRunnableTest { any(), eq(true) ) - verify(mockDataUploader, times(fakeMaxBatchesPerJob)).upload(fakeContext, batch, batchMetadata) + verify(mockDataUploader, times(fakeMaxBatchesPerJob)).upload(fakeContext, batch, batchMetadata, batchId) verify(mockUploadSchedulerStrategy).getMsDelayUntilNextUpload( fakeFeatureName, fakeMaxBatchesPerJob, @@ -416,7 +418,8 @@ internal class DataUploadRunnableTest { mockDataUploader.upload( fakeContext, batch, - batchMetadata + batchMetadata, + batchId ) ) doReturn fakeUploadStatus @@ -425,7 +428,7 @@ internal class DataUploadRunnableTest { // Then verify(mockStorage, times(fakeMaxBatchesPerJob)).confirmBatchRead(any(), any(), eq(true)) - verify(mockDataUploader, times(fakeMaxBatchesPerJob)).upload(fakeContext, batch, batchMetadata) + verify(mockDataUploader, times(fakeMaxBatchesPerJob)).upload(fakeContext, batch, batchMetadata, batchId) verify(mockUploadSchedulerStrategy).getMsDelayUntilNextUpload( fakeFeatureName, fakeMaxBatchesPerJob, @@ -453,7 +456,8 @@ internal class DataUploadRunnableTest { mockDataUploader.upload( fakeContext, batch, - batchMetadata + batchMetadata, + batchId ) ) doReturn uploadStatus @@ -462,7 +466,7 @@ internal class DataUploadRunnableTest { // Then verify(mockStorage).confirmBatchRead(eq(batchId), any(), eq(false)) - verify(mockDataUploader).upload(fakeContext, batch, batchMetadata) + verify(mockDataUploader).upload(fakeContext, batch, batchMetadata, batchId) verify(mockUploadSchedulerStrategy).getMsDelayUntilNextUpload( fakeFeatureName, 1, @@ -490,7 +494,8 @@ internal class DataUploadRunnableTest { mockDataUploader.upload( fakeContext, batch, - batchMetadata + batchMetadata, + batchId ) ) doReturn uploadStatus @@ -501,7 +506,7 @@ internal class DataUploadRunnableTest { // Then verify(mockStorage, times(runCount)).confirmBatchRead(eq(batchId), any(), eq(false)) - verify(mockDataUploader, times(runCount)).upload(fakeContext, batch, batchMetadata) + verify(mockDataUploader, times(runCount)).upload(fakeContext, batch, batchMetadata, batchId) verify(mockUploadSchedulerStrategy, times(runCount)).getMsDelayUntilNextUpload( fakeFeatureName, 1, @@ -529,7 +534,8 @@ internal class DataUploadRunnableTest { mockDataUploader.upload( fakeContext, batch, - batchMetadata + batchMetadata, + batchId ) ) doReturn uploadStatus @@ -538,7 +544,7 @@ internal class DataUploadRunnableTest { // Then verify(mockStorage).confirmBatchRead(eq(batchId), any(), eq(true)) - verify(mockDataUploader).upload(fakeContext, batch, batchMetadata) + verify(mockDataUploader).upload(fakeContext, batch, batchMetadata, batchId) verify(mockUploadSchedulerStrategy).getMsDelayUntilNextUpload( fakeFeatureName, 1, @@ -552,8 +558,7 @@ internal class DataUploadRunnableTest { @Test fun `M handle the maxBatchesPerJob W run{maxBatchesPerJob smaller availableBatches}`( - forge: Forge, - @Forgery fakeConfiguration: DataUploadConfiguration + forge: Forge ) { // Given testedRunnable = DataUploadRunnable( @@ -570,8 +575,8 @@ internal class DataUploadRunnableTest { ) val batches = forge.aList( size = forge.anInt( - min = fakeConfiguration.maxBatchesPerUploadJob + 1, - max = fakeConfiguration.maxBatchesPerUploadJob + 1000 + min = fakeMaxBatchesPerJob + 1, + max = fakeMaxBatchesPerJob + 1000 ) ) { aList { getForgery() } @@ -584,7 +589,8 @@ internal class DataUploadRunnableTest { mockDataUploader.upload( fakeContext, batch, - batchMetadata[index] + batchMetadata[index], + batchIds[index] ) ) doReturn forge.getForgery(UploadStatus.Success::class.java) } @@ -595,7 +601,7 @@ internal class DataUploadRunnableTest { // Then repeat(fakeMaxBatchesPerJob) { index -> val batch = batches[index] - verify(mockDataUploader).upload(fakeContext, batch, batchMetadata[index]) + verify(mockDataUploader).upload(fakeContext, batch, batchMetadata[index], batchIds[index]) verify(mockStorage).confirmBatchRead( eq(batchIds[index]), any(), @@ -608,8 +614,7 @@ internal class DataUploadRunnableTest { @Test fun `M exhaust the available batches W run {maxBatchesPerJob higher or equal availableBatches}`( - forge: Forge, - @Forgery fakeConfiguration: DataUploadConfiguration + forge: Forge ) { // Given testedRunnable = DataUploadRunnable( @@ -626,7 +631,7 @@ internal class DataUploadRunnableTest { ) val fakeBatchesCount = forge.anInt( min = 1, - max = fakeConfiguration.maxBatchesPerUploadJob + 4 + max = fakeMaxBatchesPerJob + 1 ) val batches = forge.aList(size = fakeBatchesCount) { aList { getForgery() } } val batchIds: List = batches.map { mock() } @@ -639,7 +644,8 @@ internal class DataUploadRunnableTest { mockDataUploader.upload( fakeContext, batch, - batchMetadata[index] + batchMetadata[index], + batchIds[index] ) ) doReturn fakeUploadStatus } @@ -648,9 +654,8 @@ internal class DataUploadRunnableTest { testedRunnable.run() // Then - repeat(fakeMaxBatchesPerJob) { index -> - val batch = batches[index] - verify(mockDataUploader).upload(fakeContext, batch, batchMetadata[index]) + batches.forEachIndexed { index, batch -> + verify(mockDataUploader).upload(fakeContext, batch, batchMetadata[index], batchIds[index]) verify(mockStorage).confirmBatchRead( eq(batchIds[index]), any(), diff --git a/dd-sdk-android-core/src/test/kotlin/com/datadog/android/core/internal/data/upload/UploadStatusTest.kt b/dd-sdk-android-core/src/test/kotlin/com/datadog/android/core/internal/data/upload/UploadStatusTest.kt index c868171294..25e218d929 100644 --- a/dd-sdk-android-core/src/test/kotlin/com/datadog/android/core/internal/data/upload/UploadStatusTest.kt +++ b/dd-sdk-android-core/src/test/kotlin/com/datadog/android/core/internal/data/upload/UploadStatusTest.kt @@ -9,18 +9,19 @@ package com.datadog.android.core.internal.data.upload import com.datadog.android.api.InternalLogger import com.datadog.android.utils.forge.Configurator import com.datadog.android.utils.verifyLog -import fr.xgouchet.elmyr.Forge import fr.xgouchet.elmyr.annotation.Forgery import fr.xgouchet.elmyr.annotation.IntForgery +import fr.xgouchet.elmyr.annotation.StringForgery +import fr.xgouchet.elmyr.annotation.StringForgeryType import fr.xgouchet.elmyr.junit5.ForgeConfiguration import fr.xgouchet.elmyr.junit5.ForgeExtension -import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith import org.junit.jupiter.api.extension.Extensions import org.mockito.Mock import org.mockito.junit.jupiter.MockitoExtension import org.mockito.junit.jupiter.MockitoSettings +import org.mockito.kotlin.times import org.mockito.kotlin.verifyNoMoreInteractions import org.mockito.quality.Strictness @@ -32,6 +33,7 @@ import org.mockito.quality.Strictness @ForgeConfiguration(Configurator::class) internal class UploadStatusTest { + @StringForgery lateinit var fakeContext: String @Mock @@ -40,9 +42,32 @@ internal class UploadStatusTest { @IntForgery(min = 0) var fakeByteSize: Int = 0 - @BeforeEach - fun `set up`(forge: Forge) { - fakeContext = forge.anAlphabeticalString() + @StringForgery(StringForgeryType.HEXADECIMAL) + lateinit var fakeRequestId: String + + @IntForgery + var fakeRequestAttempts: Int = 0 + + @Test + fun `M log SUCCESS only to USER W logStatus() {no request id}`( + @Forgery status: UploadStatus.Success + ) { + // When + status.logStatus( + fakeContext, + fakeByteSize, + mockLogger, + attempts = fakeRequestAttempts + ) + + // Then + mockLogger.verifyLog( + InternalLogger.Level.INFO, + listOf(InternalLogger.Target.USER), + "Batch [$fakeByteSize bytes] ($fakeContext) sent successfully." + + " This request was attempted $fakeRequestAttempts time(s)." + ) + verifyNoMoreInteractions(mockLogger) } @Test @@ -53,14 +78,40 @@ internal class UploadStatusTest { status.logStatus( fakeContext, fakeByteSize, - mockLogger + mockLogger, + attempts = fakeRequestAttempts, + requestId = fakeRequestId ) // Then mockLogger.verifyLog( InternalLogger.Level.INFO, listOf(InternalLogger.Target.USER), - "Batch [$fakeByteSize bytes] ($fakeContext) sent successfully." + "Batch $fakeRequestId [$fakeByteSize bytes] ($fakeContext) sent successfully." + + " This request was attempted $fakeRequestAttempts time(s)." + ) + verifyNoMoreInteractions(mockLogger) + } + + @Test + fun `M log NETWORK_ERROR only to USER W logStatus() {no request id}`( + @Forgery status: UploadStatus.NetworkError + ) { + // When + status.logStatus( + fakeContext, + fakeByteSize, + mockLogger, + attempts = fakeRequestAttempts + ) + + // Then + mockLogger.verifyLog( + InternalLogger.Level.WARN, + listOf(InternalLogger.Target.USER), + "Batch [$fakeByteSize bytes] ($fakeContext) failed " + + "because of a network error (${status.throwable!!.message}); we will retry later." + + " This request was attempted $fakeRequestAttempts time(s)." ) verifyNoMoreInteractions(mockLogger) } @@ -73,7 +124,32 @@ internal class UploadStatusTest { status.logStatus( fakeContext, fakeByteSize, - mockLogger + mockLogger, + attempts = fakeRequestAttempts, + requestId = fakeRequestId + ) + + // Then + mockLogger.verifyLog( + InternalLogger.Level.WARN, + listOf(InternalLogger.Target.USER), + "Batch $fakeRequestId [$fakeByteSize bytes] ($fakeContext) failed " + + "because of a network error (${status.throwable!!.message}); we will retry later." + + " This request was attempted $fakeRequestAttempts time(s)." + ) + verifyNoMoreInteractions(mockLogger) + } + + @Test + fun `M log DNS_ERROR only to USER W logStatus() {no request id}`( + @Forgery status: UploadStatus.DNSError + ) { + // When + status.logStatus( + fakeContext, + fakeByteSize, + mockLogger, + attempts = fakeRequestAttempts ) // Then @@ -81,7 +157,8 @@ internal class UploadStatusTest { InternalLogger.Level.WARN, listOf(InternalLogger.Target.USER), "Batch [$fakeByteSize bytes] ($fakeContext) failed " + - "because of a network error (${status.throwable!!.message}); we will retry later." + "because of a DNS error (${status.throwable!!.message}); we will retry later." + + " This request was attempted $fakeRequestAttempts time(s)." ) verifyNoMoreInteractions(mockLogger) } @@ -94,15 +171,44 @@ internal class UploadStatusTest { status.logStatus( fakeContext, fakeByteSize, - mockLogger + mockLogger, + attempts = fakeRequestAttempts, + requestId = fakeRequestId ) // Then mockLogger.verifyLog( InternalLogger.Level.WARN, listOf(InternalLogger.Target.USER), + "Batch $fakeRequestId [$fakeByteSize bytes] ($fakeContext) failed " + + "because of a DNS error (${status.throwable!!.message}); we will retry later." + + " This request was attempted $fakeRequestAttempts time(s)." + + ) + verifyNoMoreInteractions(mockLogger) + } + + @Test + fun `M log INVALID_TOKEN_ERROR only to USER W logStatus() {no request id}`( + @Forgery status: UploadStatus.InvalidTokenError + ) { + // When + status.logStatus( + fakeContext, + fakeByteSize, + mockLogger, + attempts = fakeRequestAttempts + ) + + // Then + mockLogger.verifyLog( + InternalLogger.Level.ERROR, + listOf(InternalLogger.Target.USER), "Batch [$fakeByteSize bytes] ($fakeContext) failed " + - "because of a DNS error (${status.throwable!!.message}); we will retry later." + "because your token is invalid; the batch was dropped. " + + "Make sure that the provided token still " + + "exists and you're targeting the relevant Datadog site." + + " This request was attempted $fakeRequestAttempts time(s)." ) verifyNoMoreInteractions(mockLogger) } @@ -115,17 +221,43 @@ internal class UploadStatusTest { status.logStatus( fakeContext, fakeByteSize, - mockLogger + mockLogger, + attempts = fakeRequestAttempts, + requestId = fakeRequestId ) // Then mockLogger.verifyLog( InternalLogger.Level.ERROR, listOf(InternalLogger.Target.USER), - "Batch [$fakeByteSize bytes] ($fakeContext) failed " + + "Batch $fakeRequestId [$fakeByteSize bytes] ($fakeContext) failed " + "because your token is invalid; the batch was dropped. " + "Make sure that the provided token still " + - "exists and you're targeting the relevant Datadog site." + "exists and you're targeting the relevant Datadog site." + + " This request was attempted $fakeRequestAttempts time(s)." + ) + verifyNoMoreInteractions(mockLogger) + } + + @Test + fun `M log HTTP_REDIRECTION only to USER W logStatus() {no request id}`( + @Forgery status: UploadStatus.HttpRedirection + ) { + // When + status.logStatus( + fakeContext, + fakeByteSize, + mockLogger, + attempts = fakeRequestAttempts + ) + + // Then + mockLogger.verifyLog( + InternalLogger.Level.WARN, + listOf(InternalLogger.Target.USER), + "Batch [$fakeByteSize bytes] ($fakeContext) failed " + + "because of a network redirection; the batch was dropped." + + " This request was attempted $fakeRequestAttempts time(s)." ) verifyNoMoreInteractions(mockLogger) } @@ -138,15 +270,42 @@ internal class UploadStatusTest { status.logStatus( fakeContext, fakeByteSize, - mockLogger + mockLogger, + attempts = fakeRequestAttempts, + requestId = fakeRequestId ) // Then mockLogger.verifyLog( InternalLogger.Level.WARN, listOf(InternalLogger.Target.USER), + "Batch $fakeRequestId [$fakeByteSize bytes] ($fakeContext) failed " + + "because of a network redirection; the batch was dropped." + + " This request was attempted $fakeRequestAttempts time(s)." + ) + verifyNoMoreInteractions(mockLogger) + } + + @Test + fun `M log HTTP_CLIENT_ERROR to USER and TELEMETRY W logStatus() {no request id}`( + @Forgery status: UploadStatus.HttpClientError + ) { + // When + status.logStatus( + fakeContext, + fakeByteSize, + mockLogger, + attempts = fakeRequestAttempts + ) + + // Then + mockLogger.verifyLog( + InternalLogger.Level.ERROR, + listOf(InternalLogger.Target.USER, InternalLogger.Target.TELEMETRY), "Batch [$fakeByteSize bytes] ($fakeContext) failed " + - "because of a network redirection; the batch was dropped." + "because of a processing error or invalid data; " + + "the batch was dropped." + + " This request was attempted $fakeRequestAttempts time(s)." ) verifyNoMoreInteractions(mockLogger) } @@ -159,29 +318,33 @@ internal class UploadStatusTest { status.logStatus( fakeContext, fakeByteSize, - mockLogger + mockLogger, + attempts = fakeRequestAttempts, + requestId = fakeRequestId ) // Then mockLogger.verifyLog( InternalLogger.Level.ERROR, listOf(InternalLogger.Target.USER, InternalLogger.Target.TELEMETRY), - "Batch [$fakeByteSize bytes] ($fakeContext) failed " + + "Batch $fakeRequestId [$fakeByteSize bytes] ($fakeContext) failed " + "because of a processing error or invalid data; " + - "the batch was dropped." + "the batch was dropped." + + " This request was attempted $fakeRequestAttempts time(s)." ) verifyNoMoreInteractions(mockLogger) } @Test - fun `M log HTTP_CLIENT_ERROR_RATE_LIMITING to USER and TELEMETRY W logStatus()`( + fun `M log HTTP_CLIENT_ERROR_RATE_LIMITING to USER and TELEMETRY W logStatus() {no request id}`( @Forgery status: UploadStatus.HttpClientRateLimiting ) { // When status.logStatus( fakeContext, fakeByteSize, - mockLogger + mockLogger, + attempts = fakeRequestAttempts ) // Then @@ -189,19 +352,46 @@ internal class UploadStatusTest { InternalLogger.Level.WARN, listOf(InternalLogger.Target.USER, InternalLogger.Target.TELEMETRY), "Batch [$fakeByteSize bytes] ($fakeContext) failed because of an intake rate limitation; " + - "we will retry later." + "we will retry later." + + " This request was attempted $fakeRequestAttempts time(s)." + ) } @Test - fun `M log HTTP_SERVER_ERROR only to USER W logStatus()`( + fun `M log HTTP_CLIENT_ERROR_RATE_LIMITING to USER and TELEMETRY W logStatus()`( + @Forgery status: UploadStatus.HttpClientRateLimiting + ) { + // When + status.logStatus( + fakeContext, + fakeByteSize, + mockLogger, + attempts = fakeRequestAttempts, + requestId = fakeRequestId + ) + + // Then + mockLogger.verifyLog( + InternalLogger.Level.WARN, + listOf(InternalLogger.Target.USER, InternalLogger.Target.TELEMETRY), + "Batch $fakeRequestId [$fakeByteSize bytes] ($fakeContext) failed because of an intake rate limitation; " + + "we will retry later." + + " This request was attempted $fakeRequestAttempts time(s)." + + ) + } + + @Test + fun `M log HTTP_SERVER_ERROR only to USER W logStatus() {no request id}`( @Forgery status: UploadStatus.HttpServerError ) { // When status.logStatus( fakeContext, fakeByteSize, - mockLogger + mockLogger, + attempts = fakeRequestAttempts ) // Then @@ -209,11 +399,59 @@ internal class UploadStatusTest { InternalLogger.Level.ERROR, listOf(InternalLogger.Target.USER), "Batch [$fakeByteSize bytes] ($fakeContext) failed " + - "because of a server processing error; we will retry later." + "because of a server processing error; we will retry later." + + " This request was attempted $fakeRequestAttempts time(s)." + ) verifyNoMoreInteractions(mockLogger) } + @Test + fun `M log HTTP_SERVER_ERROR only to USER W logStatus()`( + @Forgery status: UploadStatus.HttpServerError + ) { + // When + status.logStatus( + fakeContext, + fakeByteSize, + mockLogger, + attempts = fakeRequestAttempts, + requestId = fakeRequestId + ) + + // Then + mockLogger.verifyLog( + InternalLogger.Level.ERROR, + listOf(InternalLogger.Target.USER), + "Batch $fakeRequestId [$fakeByteSize bytes] ($fakeContext) failed " + + "because of a server processing error; we will retry later." + + " This request was attempted $fakeRequestAttempts time(s)." + ) + verifyNoMoreInteractions(mockLogger) + } + + @Test + fun `M log UNKNOWN_HTTP_ERROR only to USER W logStatus() {no request id}`( + @Forgery status: UploadStatus.UnknownHttpError + ) { + // When + status.logStatus( + fakeContext, + fakeByteSize, + mockLogger, + attempts = fakeRequestAttempts + ) + + // Then + mockLogger.verifyLog( + InternalLogger.Level.ERROR, + listOf(InternalLogger.Target.USER), + "Batch [$fakeByteSize bytes] ($fakeContext) failed " + + "because of an unexpected HTTP error (status code = ${status.code}); the batch was dropped." + + " This request was attempted $fakeRequestAttempts time(s)." + ) + } + @Test fun `M log UNKNOWN_HTTP_ERROR only to USER W logStatus()`( @Forgery status: UploadStatus.UnknownHttpError @@ -222,7 +460,31 @@ internal class UploadStatusTest { status.logStatus( fakeContext, fakeByteSize, - mockLogger + mockLogger, + attempts = fakeRequestAttempts, + requestId = fakeRequestId + ) + + // Then + mockLogger.verifyLog( + InternalLogger.Level.ERROR, + listOf(InternalLogger.Target.USER), + "Batch $fakeRequestId [$fakeByteSize bytes] ($fakeContext) failed " + + "because of an unexpected HTTP error (status code = ${status.code}); the batch was dropped." + + " This request was attempted $fakeRequestAttempts time(s)." + ) + } + + @Test + fun `M log UNKNOWN_EXCEPTION only to USER W logStatus() {no request id}`( + @Forgery status: UploadStatus.UnknownException + ) { + // When + status.logStatus( + fakeContext, + fakeByteSize, + mockLogger, + attempts = fakeRequestAttempts ) // Then @@ -230,7 +492,8 @@ internal class UploadStatusTest { InternalLogger.Level.ERROR, listOf(InternalLogger.Target.USER), "Batch [$fakeByteSize bytes] ($fakeContext) failed " + - "because of an unexpected HTTP error (status code = ${status.code}); the batch was dropped." + "because of an unknown error (${status.throwable!!.message}); we will retry later." + + " This request was attempted $fakeRequestAttempts time(s)." ) } @@ -242,7 +505,31 @@ internal class UploadStatusTest { status.logStatus( fakeContext, fakeByteSize, - mockLogger + mockLogger, + attempts = fakeRequestAttempts, + requestId = fakeRequestId + ) + + // Then + mockLogger.verifyLog( + InternalLogger.Level.ERROR, + listOf(InternalLogger.Target.USER), + "Batch $fakeRequestId [$fakeByteSize bytes] ($fakeContext) failed " + + "because of an unknown error (${status.throwable!!.message}); we will retry later." + + " This request was attempted $fakeRequestAttempts time(s)." + ) + } + + @Test + fun `M log INVALID_REQUEST_ERROR only to USER W logStatus() {no request id}`( + @Forgery status: UploadStatus.RequestCreationError + ) { + // When + status.logStatus( + fakeContext, + fakeByteSize, + mockLogger, + attempts = fakeRequestAttempts ) // Then @@ -250,7 +537,9 @@ internal class UploadStatusTest { InternalLogger.Level.ERROR, listOf(InternalLogger.Target.USER), "Batch [$fakeByteSize bytes] ($fakeContext) failed " + - "because of an unknown error (${status.throwable!!.message}); we will retry later." + "because of an error when creating the request (${status.throwable!!.message});" + + " the batch was dropped." + + " This request was attempted $fakeRequestAttempts time(s)." ) } @@ -262,16 +551,62 @@ internal class UploadStatusTest { status.logStatus( fakeContext, fakeByteSize, - mockLogger + mockLogger, + attempts = fakeRequestAttempts, + requestId = fakeRequestId ) // Then mockLogger.verifyLog( InternalLogger.Level.ERROR, listOf(InternalLogger.Target.USER), - "Batch [$fakeByteSize bytes] ($fakeContext) failed " + + "Batch $fakeRequestId [$fakeByteSize bytes] ($fakeContext) failed " + "because of an error when creating the request (${status.throwable!!.message});" + - " the batch was dropped." + " the batch was dropped." + + " This request was attempted $fakeRequestAttempts time(s)." + ) + } + + @Test + fun `M log UNKNOWN_STATUS only to USER W logStatus() {no request id}`() { + // When + val status = UploadStatus.UnknownStatus + status.logStatus( + fakeContext, + fakeByteSize, + mockLogger, + attempts = fakeRequestAttempts + ) + + // Then + mockLogger.verifyLog( + InternalLogger.Level.WARN, + listOf(InternalLogger.Target.USER, InternalLogger.Target.TELEMETRY), + "Batch [$fakeByteSize bytes] ($fakeContext) status is unknown;" + + " the batch was dropped." + + " This request was attempted $fakeRequestAttempts time(s)." + ) + } + + @Test + fun `M log UNKNOWN_STATUS only to USER W logStatus()`() { + // When + val status = UploadStatus.UnknownStatus + status.logStatus( + fakeContext, + fakeByteSize, + mockLogger, + attempts = fakeRequestAttempts, + requestId = fakeRequestId + ) + + // Then + mockLogger.verifyLog( + InternalLogger.Level.WARN, + listOf(InternalLogger.Target.USER, InternalLogger.Target.TELEMETRY), + "Batch $fakeRequestId [$fakeByteSize bytes] ($fakeContext) status is unknown;" + + " the batch was dropped." + + " This request was attempted $fakeRequestAttempts time(s)." ) } } diff --git a/dd-sdk-android-core/src/test/kotlin/com/datadog/android/core/internal/data/upload/UploadWorkerTest.kt b/dd-sdk-android-core/src/test/kotlin/com/datadog/android/core/internal/data/upload/UploadWorkerTest.kt index 6ea2d8b36f..154daf267b 100644 --- a/dd-sdk-android-core/src/test/kotlin/com/datadog/android/core/internal/data/upload/UploadWorkerTest.kt +++ b/dd-sdk-android-core/src/test/kotlin/com/datadog/android/core/internal/data/upload/UploadWorkerTest.kt @@ -12,12 +12,12 @@ import androidx.work.ListenableWorker import androidx.work.Worker import androidx.work.WorkerParameters import com.datadog.android.Datadog -import com.datadog.android.api.InternalLogger import com.datadog.android.api.context.DatadogContext -import com.datadog.android.api.storage.EventBatchWriter import com.datadog.android.api.storage.RawBatchEvent import com.datadog.android.core.InternalSdkCore +import com.datadog.android.core.internal.NoOpInternalSdkCore import com.datadog.android.core.internal.SdkFeature +import com.datadog.android.core.internal.data.upload.UploadStatus.Companion.UNKNOWN_RESPONSE_CODE import com.datadog.android.core.internal.metrics.RemovalReason import com.datadog.android.core.internal.persistence.BatchData import com.datadog.android.core.internal.persistence.BatchId @@ -25,12 +25,12 @@ import com.datadog.android.core.internal.persistence.Storage import com.datadog.android.utils.config.ApplicationContextTestConfiguration import com.datadog.android.utils.config.InternalLoggerTestConfiguration import com.datadog.android.utils.forge.Configurator -import com.datadog.android.utils.verifyLog import com.datadog.tools.unit.annotations.TestConfigurationsProvider import com.datadog.tools.unit.extensions.TestConfigurationExtension import com.datadog.tools.unit.extensions.config.TestConfiguration import fr.xgouchet.elmyr.Forge import fr.xgouchet.elmyr.annotation.Forgery +import fr.xgouchet.elmyr.annotation.IntForgery import fr.xgouchet.elmyr.annotation.StringForgery import fr.xgouchet.elmyr.junit5.ForgeConfiguration import fr.xgouchet.elmyr.junit5.ForgeExtension @@ -40,23 +40,18 @@ import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith import org.junit.jupiter.api.extension.Extensions -import org.junit.jupiter.api.fail -import org.junit.jupiter.params.ParameterizedTest -import org.junit.jupiter.params.provider.MethodSource import org.mockito.Mock import org.mockito.invocation.InvocationOnMock import org.mockito.junit.jupiter.MockitoExtension import org.mockito.junit.jupiter.MockitoSettings -import org.mockito.kotlin.argThat import org.mockito.kotlin.doReturn -import org.mockito.kotlin.eq import org.mockito.kotlin.mock import org.mockito.kotlin.verify import org.mockito.kotlin.verifyNoInteractions +import org.mockito.kotlin.verifyNoMoreInteractions import org.mockito.kotlin.whenever import org.mockito.quality.Strictness import org.mockito.stubbing.Answer -import java.util.concurrent.Executors @Extensions( ExtendWith(MockitoExtension::class), @@ -72,48 +67,42 @@ internal class UploadWorkerTest { @Mock lateinit var mockSdkCore: InternalSdkCore - @Mock - lateinit var mockFeatureA: SdkFeature + @Forgery + lateinit var fakeDatadogContext: DatadogContext - @Mock - lateinit var mockStorageA: Storage + @StringForgery + lateinit var fakeInstanceName: String - @Mock - lateinit var mockUploaderA: DataUploader + @Forgery + lateinit var fakeWorkerParameters: WorkerParameters - @Mock - lateinit var mockFeatureB: SdkFeature + var fakeFeaturesCount: Int = 0 - @Mock - lateinit var mockStorageB: Storage + lateinit var mockFeatures: List - @Mock - lateinit var mockUploaderB: DataUploader + lateinit var mockUploaders: List - @Forgery - lateinit var fakeWorkerParameters: WorkerParameters + lateinit var mockStorages: List - @StringForgery - lateinit var fakeInstanceName: String + lateinit var fakeFeatureBatches: List>> - @Forgery - lateinit var fakeContext: DatadogContext + lateinit var fakeFeatureBatchIds: List> + + lateinit var fakeFeatureBatchMetadata: List> @BeforeEach - fun `set up`() { - whenever(mockSdkCore.getDatadogContext()) doReturn fakeContext + fun `set up`(forge: Forge) { + whenever(mockSdkCore.getDatadogContext()) doReturn fakeDatadogContext Datadog.registry.register(fakeInstanceName, mockSdkCore) + val fakeData = Data.Builder() .putString(UploadWorker.DATADOG_INSTANCE_NAME, fakeInstanceName) .build() fakeWorkerParameters = fakeWorkerParameters.copyWith(fakeData) - stubFeatures( - mockSdkCore, - listOf(mockFeatureA, mockFeatureB), - listOf(mockStorageA, mockStorageB), - listOf(mockUploaderA, mockUploaderB) - ) + fakeFeaturesCount = forge.anInt(2, 8) + createFakeBatches(forge) + stubFeaturesStorage() testedWorker = UploadWorker( appContext.mockInstance, @@ -126,530 +115,799 @@ internal class UploadWorkerTest { Datadog.registry.clear() } - // region doWork + // region setup - @Test - fun `M send batches W doWork() {single batch per feature}`( - @Forgery batchA: List, - @StringForgery batchAMeta: String, - @Forgery batchB: List, - @StringForgery batchBMeta: String, - forge: Forge + private fun createFakeBatches(forge: Forge) { + fakeFeatureBatches = List(fakeFeaturesCount) { + forge.aList { forge.aList { forge.getForgery() } } + } + + fakeFeatureBatchIds = List(fakeFeaturesCount) { featureIndex -> + forge.aList(fakeFeatureBatches[featureIndex].size) { BatchId(forge.aString()) } + } + + fakeFeatureBatchMetadata = List(fakeFeaturesCount) { featureIndex -> + forge.aList(fakeFeatureBatches[featureIndex].size) { forge.aNullable { aString().toByteArray() } } + } + } + + private fun stubFeaturesStorage() { + mockFeatures = List(fakeFeaturesCount) { mock() } + mockUploaders = List(fakeFeaturesCount) { mock() } + mockStorages = List(fakeFeaturesCount) { mock() } + + whenever(mockSdkCore.getAllFeatures()) doReturn mockFeatures + + mockFeatures.forEachIndexed { featureIndex, feature -> + whenever(feature.uploader) doReturn mockUploaders[featureIndex] + whenever(feature.storage) doReturn mockStorages[featureIndex] + + val fakeBatches = fakeFeatureBatches[featureIndex] + val fakeBatchIds = fakeFeatureBatchIds[featureIndex] + val fakeBatchMetadata = fakeFeatureBatchMetadata[featureIndex] + + val batchesCount = fakeBatches.size + whenever(mockStorages[featureIndex].readNextBatch()) + .thenAnswer(object : Answer { + var invocationCount: Int = 0 + + override fun answer(invocation: InvocationOnMock): BatchData? { + if (invocationCount >= batchesCount) { + return null + } + val fakeBatch = fakeBatches[invocationCount] + val fakeBatchId = fakeBatchIds[invocationCount] + val fakeMetadata = fakeBatchMetadata[invocationCount] + invocationCount++ + + return BatchData( + id = fakeBatchId, + data = fakeBatch, + metadata = fakeMetadata + ) + } + }) + } + } + + private fun stubFeaturesUploaders( + successStatusCode: Int = 202, + successfulUntilIdx: Int = Int.MAX_VALUE, + secondaryStatus: UploadStatus = UploadStatus.UnknownStatus ) { - // Given - val batchAMetadata = forge.aNullable { batchAMeta.toByteArray() } - val batchBMetadata = forge.aNullable { batchBMeta.toByteArray() } - - val batchId1 = mock() - val batchId2 = mock() - stubReadSequence( - mockStorageA, - batchId1, - batchA, - batchAMetadata - ) + mockFeatures.forEachIndexed { featureIndex, _ -> + val mockUploader = mockUploaders[featureIndex] + val fakeBatches = fakeFeatureBatches[featureIndex] + val fakeBatchMetadata = fakeFeatureBatchMetadata[featureIndex] + + fakeBatches.forEachIndexed { batchIndex, batch -> + val status = if (batchIndex < successfulUntilIdx) { + UploadStatus.Success(successStatusCode) + } else { + secondaryStatus + } + whenever( + mockUploader.upload( + fakeDatadogContext, + batch, + fakeBatchMetadata[batchIndex], + fakeFeatureBatchIds[featureIndex][batchIndex] + ) + ) doReturn status + } + } + } - stubReadSequence( - mockStorageB, - batchId2, - batchB, - batchBMetadata - ) + // endregion - val uploadStatus1 = forge.getForgery(UploadStatus.Success::class.java) - val uploadStatus2 = forge.getForgery(UploadStatus.Success::class.java) - whenever( - mockUploaderA.upload( - fakeContext, - batchA, - batchAMetadata - ) - ) doReturn uploadStatus1 - whenever( - mockUploaderB.upload( - fakeContext, - batchB, - batchBMetadata - ) - ) doReturn uploadStatus2 + // region doWork + + @Test + fun `M do nothing W doWork() {no sdk}`() { + // Given + Datadog.registry.unregister(fakeInstanceName) // When val result = testedWorker.doWork() // Then - verify(mockUploaderA).upload( - fakeContext, - batchA, - batchAMetadata - ) - verify(mockUploaderB).upload( - fakeContext, - batchB, - batchBMetadata - ) + assertThat(result).isEqualTo(ListenableWorker.Result.success()) + mockUploaders.forEach { verifyNoInteractions(it) } + mockStorages.forEach { verifyNoInteractions(it) } + } - verify(mockStorageA).confirmBatchRead( - eq(batchId1), - argThat { this.toString() == "intake-code-${uploadStatus1.code}" }, - eq(true) - ) - verify(mockStorageB).confirmBatchRead( - eq(batchId2), - argThat { this.toString() == "intake-code-${uploadStatus2.code}" }, - eq(true) - ) + @Test + fun `M do nothing W doWork() {no op sdk}`() { + // Given + Datadog.registry.unregister(fakeInstanceName) + Datadog.registry.register(fakeInstanceName, NoOpInternalSdkCore) + + // When + val result = testedWorker.doWork() - assertThat(result) - .isEqualTo(ListenableWorker.Result.success()) + // Then + assertThat(result).isEqualTo(ListenableWorker.Result.success()) + mockUploaders.forEach { verifyNoInteractions(it) } + mockStorages.forEach { verifyNoInteractions(it) } } - @ParameterizedTest - @MethodSource("errorStatusValues") - fun `M send and keep batches W doWork() {single batch per feature with error}`( - status: UploadStatus, - @Forgery batchA: List, - @StringForgery batchAMeta: String, - @Forgery batchB: List, - @StringForgery batchBMeta: String, - forge: Forge + @Test + fun `M send all batches W doWork() {all success}`( + @IntForgery(200, 300) successStatusCode: Int ) { // Given - val batchAMetadata = forge.aNullable { batchAMeta.toByteArray() } - val batchBMetadata = forge.aNullable { batchBMeta.toByteArray() } - - val batchId1 = mock() - stubReadSequence( - mockStorageA, - batchId1, - batchA, - batchAMetadata - ) - - val batchId2 = mock() - stubReadSequence( - mockStorageB, - batchId2, - batchB, - batchBMetadata - ) - - whenever( - mockUploaderA.upload( - fakeContext, - batchA, - batchAMetadata - ) - ) doReturn status - whenever( - mockUploaderB.upload( - fakeContext, - batchB, - batchBMetadata - ) - ) doReturn status + stubFeaturesUploaders(successStatusCode) // When val result = testedWorker.doWork() // Then - verify(mockUploaderA).upload( - fakeContext, - batchA, - batchAMetadata - ) - verify(mockUploaderB).upload( - fakeContext, - batchB, - batchBMetadata - ) - verify(mockStorageA).confirmBatchRead( - eq(batchId1), - argThat { this.toString() == "intake-code-${status.code}" }, - eq(!status.shouldRetry) - ) - verify(mockStorageB).confirmBatchRead( - eq(batchId2), - argThat { this.toString() == "intake-code-${status.code}" }, - eq(!status.shouldRetry) - ) - - assertThat(result) - .isEqualTo(ListenableWorker.Result.success()) + assertThat(result).isEqualTo(ListenableWorker.Result.success()) + repeat(fakeFeaturesCount) { featureIndex -> + val mockUploader = mockUploaders[featureIndex] + val mockStorage = mockStorages[featureIndex] + val fakeBatches = fakeFeatureBatches[featureIndex] + val fakeMetadata = fakeFeatureBatchMetadata[featureIndex] + val fakeBatchIds = fakeFeatureBatchIds[featureIndex] + + fakeBatches.forEachIndexed { batchIndex, fakeBatch -> + verify(mockUploader).upload( + context = fakeDatadogContext, + batch = fakeBatch, + batchMeta = fakeMetadata[batchIndex], + batchId = fakeFeatureBatchIds[featureIndex][batchIndex] + ) + verify(mockStorage).confirmBatchRead( + batchId = fakeBatchIds[batchIndex], + removalReason = RemovalReason.IntakeCode(successStatusCode), + deleteBatch = true + ) + } + } } @Test - fun `M send batches W doWork() {multiple batches, all Success}`(forge: Forge) { + fun `M send all batches until failure W doWork() {unauthorized}`( + @IntForgery(200, 300) successStatusCode: Int, + @IntForgery(0, 8) successfulBatchCount: Int, + @IntForgery(400, 499) failureStatusCode: Int + + ) { // Given - val batchesA = forge.aList { - aList { RawBatchEvent(aString().toByteArray()) } - } - val batchesAMeta = forge.aList(batchesA.size) { - forge.aNullable { forge.aString().toByteArray() } - } - val aIds = forge.aList(batchesA.size) { mock() } + val failingStatus = UploadStatus.InvalidTokenError(failureStatusCode) + stubFeaturesUploaders(successStatusCode, successfulBatchCount, failingStatus) - val batchB = forge.aList { RawBatchEvent(aString().toByteArray()) } - val batchBMeta = forge.aString().toByteArray() + // When + val result = testedWorker.doWork() - stubMultipleReadSequence( - mockStorageA, - aIds, - batchesA, - batchesAMeta - ) + // Then + assertThat(result).isEqualTo(ListenableWorker.Result.success()) + repeat(fakeFeaturesCount) { featureIndex -> + val mockUploader = mockUploaders[featureIndex] + val mockStorage = mockStorages[featureIndex] + val fakeBatches = fakeFeatureBatches[featureIndex] + val fakeMetadata = fakeFeatureBatchMetadata[featureIndex] + val fakeBatchIds = fakeFeatureBatchIds[featureIndex] + + fakeBatches.forEachIndexed { batchIndex, fakeBatch -> + // n successful batches + if (batchIndex < successfulBatchCount) { + verify(mockUploader).upload( + context = fakeDatadogContext, + batch = fakeBatch, + batchMeta = fakeMetadata[batchIndex], + batchId = fakeFeatureBatchIds[featureIndex][batchIndex] + ) + verify(mockStorage).confirmBatchRead( + batchId = fakeBatchIds[batchIndex], + removalReason = RemovalReason.IntakeCode(successStatusCode), + deleteBatch = true + ) + } - val batchId = mock() - stubReadSequence( - mockStorageB, - batchId, - batchB, - batchBMeta - ) + // failing batch + if (batchIndex == successfulBatchCount) { + verify(mockUploader).upload( + context = fakeDatadogContext, + batch = fakeBatch, + batchMeta = fakeMetadata[batchIndex], + batchId = fakeFeatureBatchIds[featureIndex][batchIndex] + ) + verify(mockStorage).confirmBatchRead( + batchId = fakeBatchIds[batchIndex], + removalReason = RemovalReason.IntakeCode(failureStatusCode), + deleteBatch = true + ) + } + } - val aStatuses = batchesA.map { forge.getForgery(UploadStatus.Success::class.java) } - batchesA.forEachIndexed { index, batch -> - whenever( - mockUploaderA.upload( - fakeContext, - batch, - batchesAMeta[index] - ) - ) doReturn aStatuses[index] + verifyNoMoreInteractions(mockUploader) } + } - val successStatus = forge.getForgery(UploadStatus.Success::class.java) - whenever( - mockUploaderB.upload( - fakeContext, - batchB, - batchBMeta - ) - ) doReturn successStatus + @Test + fun `M send all batches until failure W doWork() {rate limiting}`( + @IntForgery(200, 300) successStatusCode: Int, + @IntForgery(0, 8) successfulBatchCount: Int, + @IntForgery(400, 499) failureStatusCode: Int + ) { + // Given + val failingStatus = UploadStatus.HttpClientRateLimiting(failureStatusCode) + stubFeaturesUploaders(successStatusCode, successfulBatchCount, failingStatus) // When val result = testedWorker.doWork() // Then - batchesA.forEachIndexed { index, batch -> - verify(mockUploaderA).upload( - fakeContext, - batch, - batchesAMeta[index] - ) - verify(mockStorageA).confirmBatchRead( - eq(aIds[index]), - argThat { this.toString() == "intake-code-${aStatuses[index].code}" }, - eq(true) - ) - } + assertThat(result).isEqualTo(ListenableWorker.Result.success()) + repeat(fakeFeaturesCount) { featureIndex -> + val mockUploader = mockUploaders[featureIndex] + val mockStorage = mockStorages[featureIndex] + val fakeBatches = fakeFeatureBatches[featureIndex] + val fakeMetadata = fakeFeatureBatchMetadata[featureIndex] + val fakeBatchIds = fakeFeatureBatchIds[featureIndex] + + fakeBatches.forEachIndexed { batchIndex, fakeBatch -> + // n successful batches + if (batchIndex < successfulBatchCount) { + verify(mockUploader).upload( + context = fakeDatadogContext, + batch = fakeBatch, + batchMeta = fakeMetadata[batchIndex], + batchId = fakeFeatureBatchIds[featureIndex][batchIndex] + ) + verify(mockStorage).confirmBatchRead( + batchId = fakeBatchIds[batchIndex], + removalReason = RemovalReason.IntakeCode(successStatusCode), + deleteBatch = true + ) + } - verify(mockUploaderB).upload( - fakeContext, - batchB, - batchBMeta - ) - verify(mockStorageB).confirmBatchRead( - eq(batchId), - argThat { this.toString() == "intake-code-${successStatus.code}" }, - eq(true) - ) - assertThat(result) - .isEqualTo(ListenableWorker.Result.success()) + // failing batch + if (batchIndex == successfulBatchCount) { + verify(mockUploader).upload( + context = fakeDatadogContext, + batch = fakeBatch, + batchMeta = fakeMetadata[batchIndex], + batchId = fakeFeatureBatchIds[featureIndex][batchIndex] + ) + verify(mockStorage).confirmBatchRead( + batchId = fakeBatchIds[batchIndex], + removalReason = RemovalReason.IntakeCode(failureStatusCode), + deleteBatch = false + ) + } + } + + verifyNoMoreInteractions(mockUploader) + } } @Test - fun `M send batches W doWork() {multiple batches, all Success, async storage}`(forge: Forge) { + fun `M send all batches until failure W doWork() {client error}`( + @IntForgery(200, 300) successStatusCode: Int, + @IntForgery(0, 8) successfulBatchCount: Int, + @IntForgery(400, 499) failureStatusCode: Int + ) { // Given - val batchesA = forge.aList { - aList { RawBatchEvent(aString().toByteArray()) } - } - val batchesAMeta = forge.aList(batchesA.size) { - aNullable { aString().toByteArray() } - } - val aIds = forge.aList(batchesA.size) { mock() } - - val batchB = forge.aList { RawBatchEvent(aString().toByteArray()) } - val batchBMeta = forge.aString().toByteArray() - val aStatuses = batchesA.map { forge.getForgery(UploadStatus.Success::class.java) } + val failingStatus = UploadStatus.HttpClientError(failureStatusCode) + stubFeaturesUploaders(successStatusCode, successfulBatchCount, failingStatus) - stubMultipleReadSequence( - mockStorageA, - aIds, - batchesA, - batchesAMeta - ) + // When + val result = testedWorker.doWork() - val batchId = mock() - stubReadSequence( - mockStorageB, - batchId, - batchB, - batchBMeta - ) + // Then + assertThat(result).isEqualTo(ListenableWorker.Result.success()) + repeat(fakeFeaturesCount) { featureIndex -> + val mockUploader = mockUploaders[featureIndex] + val mockStorage = mockStorages[featureIndex] + val fakeBatches = fakeFeatureBatches[featureIndex] + val fakeMetadata = fakeFeatureBatchMetadata[featureIndex] + val fakeBatchIds = fakeFeatureBatchIds[featureIndex] + + fakeBatches.forEachIndexed { batchIndex, fakeBatch -> + // n successful batches + if (batchIndex < successfulBatchCount) { + verify(mockUploader).upload( + context = fakeDatadogContext, + batch = fakeBatch, + batchMeta = fakeMetadata[batchIndex], + batchId = fakeFeatureBatchIds[featureIndex][batchIndex] + ) + verify(mockStorage).confirmBatchRead( + batchId = fakeBatchIds[batchIndex], + removalReason = RemovalReason.IntakeCode(successStatusCode), + deleteBatch = true + ) + } - val executorService = Executors.newSingleThreadExecutor() - whenever(mockFeatureA.storage) doReturn StorageDelegate(mockStorageA) - whenever(mockFeatureB.storage) doReturn StorageDelegate(mockStorageB) + // failing batch + if (batchIndex == successfulBatchCount) { + verify(mockUploader).upload( + context = fakeDatadogContext, + batch = fakeBatch, + batchMeta = fakeMetadata[batchIndex], + batchId = fakeFeatureBatchIds[featureIndex][batchIndex] + ) + verify(mockStorage).confirmBatchRead( + batchId = fakeBatchIds[batchIndex], + removalReason = RemovalReason.IntakeCode(failureStatusCode), + deleteBatch = true + ) + } + } - batchesA.forEachIndexed { index, batch -> - whenever( - mockUploaderA.upload( - fakeContext, - batch, - batchesAMeta[index] - ) - ) doReturn aStatuses[index] + verifyNoMoreInteractions(mockUploader) } + } - val successStatus = forge.getForgery(UploadStatus.Success::class.java) - whenever( - mockUploaderB.upload( - fakeContext, - batchB, - batchBMeta - ) - ) doReturn successStatus + @Test + fun `M send all batches until failure W doWork() {server error}`( + @IntForgery(200, 300) successStatusCode: Int, + @IntForgery(0, 8) successfulBatchCount: Int, + @IntForgery(400, 499) failureStatusCode: Int + ) { + // Given + val failingStatus = UploadStatus.HttpServerError(failureStatusCode) + stubFeaturesUploaders(successStatusCode, successfulBatchCount, failingStatus) // When val result = testedWorker.doWork() // Then - batchesA.forEachIndexed { index, batch -> - verify(mockUploaderA).upload( - fakeContext, - batch, - batchesAMeta[index] - ) - verify(mockStorageA).confirmBatchRead( - eq(aIds[index]), - argThat { this.toString() == "intake-code-${aStatuses[index].code}" }, - eq(true) - ) - } - - verify(mockUploaderB).upload( - fakeContext, - batchB, - batchBMeta - ) - verify(mockStorageB).confirmBatchRead( - eq(batchId), - argThat { this.toString() == "intake-code-${successStatus.code}" }, - eq(true) - ) + assertThat(result).isEqualTo(ListenableWorker.Result.success()) + repeat(fakeFeaturesCount) { featureIndex -> + val mockUploader = mockUploaders[featureIndex] + val mockStorage = mockStorages[featureIndex] + val fakeBatches = fakeFeatureBatches[featureIndex] + val fakeMetadata = fakeFeatureBatchMetadata[featureIndex] + val fakeBatchIds = fakeFeatureBatchIds[featureIndex] + + fakeBatches.forEachIndexed { batchIndex, fakeBatch -> + // n successful batches + if (batchIndex < successfulBatchCount) { + verify(mockUploader).upload( + context = fakeDatadogContext, + batch = fakeBatch, + batchMeta = fakeMetadata[batchIndex], + batchId = fakeFeatureBatchIds[featureIndex][batchIndex] + ) + verify(mockStorage).confirmBatchRead( + batchId = fakeBatchIds[batchIndex], + removalReason = RemovalReason.IntakeCode(successStatusCode), + deleteBatch = true + ) + } - assertThat(result) - .isEqualTo(ListenableWorker.Result.success()) + // failing batch + if (batchIndex == successfulBatchCount) { + verify(mockUploader).upload( + context = fakeDatadogContext, + batch = fakeBatch, + batchMeta = fakeMetadata[batchIndex], + batchId = fakeFeatureBatchIds[featureIndex][batchIndex] + ) + verify(mockStorage).confirmBatchRead( + batchId = fakeBatchIds[batchIndex], + removalReason = RemovalReason.IntakeCode(failureStatusCode), + deleteBatch = false + ) + } + } - executorService.shutdown() + verifyNoMoreInteractions(mockUploader) + } } - @ParameterizedTest - @MethodSource("errorStatusValues") - fun `M send batches W doWork() {multiple batches, some fails with retry}`( - failingStatus: UploadStatus, - forge: Forge + @Test + fun `M send all batches until failure W doWork() {redirection}`( + @IntForgery(200, 300) successStatusCode: Int, + @IntForgery(0, 8) successfulBatchCount: Int, + @IntForgery(300, 399) failureStatusCode: Int ) { // Given - val batchesA = forge.aList { - aList { RawBatchEvent(aString().toByteArray()) } - } - val batchesAMeta = forge.aList(batchesA.size) { - aNullable { aString().toByteArray() } - } - val aIds = forge.aList(batchesA.size) { mock() } + val failingStatus = UploadStatus.HttpRedirection(failureStatusCode) + stubFeaturesUploaders(successStatusCode, successfulBatchCount, failingStatus) + + // When + val result = testedWorker.doWork() - val batchB = forge.aList { RawBatchEvent(aString().toByteArray()) } - val batchBMeta = forge.aString().toByteArray() + // Then + assertThat(result).isEqualTo(ListenableWorker.Result.success()) + repeat(fakeFeaturesCount) { featureIndex -> + val mockUploader = mockUploaders[featureIndex] + val mockStorage = mockStorages[featureIndex] + val fakeBatches = fakeFeatureBatches[featureIndex] + val fakeMetadata = fakeFeatureBatchMetadata[featureIndex] + val fakeBatchIds = fakeFeatureBatchIds[featureIndex] + + fakeBatches.forEachIndexed { batchIndex, fakeBatch -> + // n successful batches + if (batchIndex < successfulBatchCount) { + verify(mockUploader).upload( + context = fakeDatadogContext, + batch = fakeBatch, + batchMeta = fakeMetadata[batchIndex], + batchId = fakeFeatureBatchIds[featureIndex][batchIndex] + ) + verify(mockStorage).confirmBatchRead( + batchId = fakeBatchIds[batchIndex], + removalReason = RemovalReason.IntakeCode(successStatusCode), + deleteBatch = true + ) + } - val failingBatchIndex = forge.anInt(min = 0, max = batchesA.size) - val aStatuses = List(batchesA.size) { index -> - if (index == failingBatchIndex) { - failingStatus - } else { - forge.getForgery(UploadStatus.Success::class.java) + // failing batch + if (batchIndex == successfulBatchCount) { + verify(mockUploader).upload( + context = fakeDatadogContext, + batch = fakeBatch, + batchMeta = fakeMetadata[batchIndex], + batchId = fakeFeatureBatchIds[featureIndex][batchIndex] + ) + verify(mockStorage).confirmBatchRead( + batchId = fakeBatchIds[batchIndex], + removalReason = RemovalReason.IntakeCode(failureStatusCode), + deleteBatch = true + ) + } } + + verifyNoMoreInteractions(mockUploader) } + } - stubMultipleReadSequence( - mockStorageA, - aIds, - batchesA, - batchesAMeta - ) + @Test + fun `M send all batches until failure W doWork() {unknown http error}`( + @IntForgery(200, 300) successStatusCode: Int, + @IntForgery(0, 8) successfulBatchCount: Int, + @IntForgery(400, 499) failureStatusCode: Int + ) { + // Given + val failingStatus = UploadStatus.UnknownHttpError(failureStatusCode) + stubFeaturesUploaders(successStatusCode, successfulBatchCount, failingStatus) - val batchId = mock() - stubReadSequence( - mockStorageB, - batchId, - batchB, - batchBMeta - ) + // When + val result = testedWorker.doWork() - val fakeUploadSuccess2 = forge.getForgery(UploadStatus.Success::class.java) - batchesA.forEachIndexed { index, batch -> - whenever( - mockUploaderA.upload( - fakeContext, - batch, - batchesAMeta[index] - ) - ) doReturn aStatuses[index] + // Then + assertThat(result).isEqualTo(ListenableWorker.Result.success()) + repeat(fakeFeaturesCount) { featureIndex -> + val mockUploader = mockUploaders[featureIndex] + val mockStorage = mockStorages[featureIndex] + val fakeBatches = fakeFeatureBatches[featureIndex] + val fakeMetadata = fakeFeatureBatchMetadata[featureIndex] + val fakeBatchIds = fakeFeatureBatchIds[featureIndex] + + fakeBatches.forEachIndexed { batchIndex, fakeBatch -> + // n successful batches + if (batchIndex < successfulBatchCount) { + verify(mockUploader).upload( + context = fakeDatadogContext, + batch = fakeBatch, + batchMeta = fakeMetadata[batchIndex], + batchId = fakeFeatureBatchIds[featureIndex][batchIndex] + ) + verify(mockStorage).confirmBatchRead( + batchId = fakeBatchIds[batchIndex], + removalReason = RemovalReason.IntakeCode(successStatusCode), + deleteBatch = true + ) + } + + // failing batch + if (batchIndex == successfulBatchCount) { + verify(mockUploader).upload( + context = fakeDatadogContext, + batch = fakeBatch, + batchMeta = fakeMetadata[batchIndex], + batchId = fakeFeatureBatchIds[featureIndex][batchIndex] + ) + verify(mockStorage).confirmBatchRead( + batchId = fakeBatchIds[batchIndex], + removalReason = RemovalReason.IntakeCode(failureStatusCode), + deleteBatch = true + ) + } + } + + verifyNoMoreInteractions(mockUploader) } + } - whenever( - mockUploaderB.upload( - fakeContext, - batchB, - batchBMeta - ) - ) doReturn fakeUploadSuccess2 + @Test + fun `M send all batches until failure W doWork() {network error}`( + @IntForgery(200, 300) successStatusCode: Int, + @IntForgery(0, 8) successfulBatchCount: Int, + @Forgery throwable: Exception + ) { + // Given + val failingStatus = UploadStatus.NetworkError(throwable) + stubFeaturesUploaders(successStatusCode, successfulBatchCount, failingStatus) // When val result = testedWorker.doWork() // Then - batchesA.forEachIndexed { index, batch -> - verify(mockUploaderA).upload( - fakeContext, - batch, - batchesAMeta[index] - ) + assertThat(result).isEqualTo(ListenableWorker.Result.success()) + repeat(fakeFeaturesCount) { featureIndex -> + val mockUploader = mockUploaders[featureIndex] + val mockStorage = mockStorages[featureIndex] + val fakeBatches = fakeFeatureBatches[featureIndex] + val fakeMetadata = fakeFeatureBatchMetadata[featureIndex] + val fakeBatchIds = fakeFeatureBatchIds[featureIndex] + + fakeBatches.forEachIndexed { batchIndex, fakeBatch -> + // n successful batches + if (batchIndex < successfulBatchCount) { + verify(mockUploader).upload( + context = fakeDatadogContext, + batch = fakeBatch, + batchMeta = fakeMetadata[batchIndex], + batchId = fakeFeatureBatchIds[featureIndex][batchIndex] + ) + verify(mockStorage).confirmBatchRead( + batchId = fakeBatchIds[batchIndex], + removalReason = RemovalReason.IntakeCode(successStatusCode), + deleteBatch = true + ) + } - if (index == failingBatchIndex) { - verify(mockStorageA).confirmBatchRead( - eq(aIds[index]), - argThat { this.toString() == "intake-code-${aStatuses[index].code}" }, - eq(!failingStatus.shouldRetry) - ) - } else { - verify(mockStorageA).confirmBatchRead( - eq(aIds[index]), - argThat { this.toString() == "intake-code-${aStatuses[index].code}" }, - eq(true) - ) + // failing batch + if (batchIndex == successfulBatchCount) { + verify(mockUploader).upload( + context = fakeDatadogContext, + batch = fakeBatch, + batchMeta = fakeMetadata[batchIndex], + batchId = fakeFeatureBatchIds[featureIndex][batchIndex] + ) + verify(mockStorage).confirmBatchRead( + batchId = fakeBatchIds[batchIndex], + removalReason = RemovalReason.IntakeCode(UNKNOWN_RESPONSE_CODE), + deleteBatch = false + ) + } } - } - verify(mockUploaderB).upload( - fakeContext, - batchB, - batchBMeta - ) - verify(mockStorageB).confirmBatchRead( - eq(batchId), - argThat { this.toString() == "intake-code-${fakeUploadSuccess2.code}" }, - eq(true) - ) - assertThat(result) - .isEqualTo(ListenableWorker.Result.success()) + verifyNoMoreInteractions(mockUploader) + } } @Test - fun `M log error W doWork() { SDK is not initialized }`() { + fun `M send all batches until failure W doWork() {dns error}`( + @IntForgery(200, 300) successStatusCode: Int, + @IntForgery(0, 8) successfulBatchCount: Int, + @Forgery throwable: Exception + ) { // Given - Datadog.registry.clear() + val failingStatus = UploadStatus.DNSError(throwable) + stubFeaturesUploaders(successStatusCode, successfulBatchCount, failingStatus) // When val result = testedWorker.doWork() // Then - logger.mockInternalLogger.verifyLog( - InternalLogger.Level.ERROR, - InternalLogger.Target.USER, - UploadWorker.MESSAGE_NOT_INITIALIZED - ) - verifyNoInteractions(mockFeatureA, mockUploaderA) - verifyNoInteractions(mockFeatureB, mockUploaderB) + assertThat(result).isEqualTo(ListenableWorker.Result.success()) + repeat(fakeFeaturesCount) { featureIndex -> + val mockUploader = mockUploaders[featureIndex] + val mockStorage = mockStorages[featureIndex] + val fakeBatches = fakeFeatureBatches[featureIndex] + val fakeMetadata = fakeFeatureBatchMetadata[featureIndex] + val fakeBatchIds = fakeFeatureBatchIds[featureIndex] + + fakeBatches.forEachIndexed { batchIndex, fakeBatch -> + // n successful batches + if (batchIndex < successfulBatchCount) { + verify(mockUploader).upload( + context = fakeDatadogContext, + batch = fakeBatch, + batchMeta = fakeMetadata[batchIndex], + batchId = fakeFeatureBatchIds[featureIndex][batchIndex] + ) + verify(mockStorage).confirmBatchRead( + batchId = fakeBatchIds[batchIndex], + removalReason = RemovalReason.IntakeCode(successStatusCode), + deleteBatch = true + ) + } + + // failing batch + if (batchIndex == successfulBatchCount) { + verify(mockUploader).upload( + context = fakeDatadogContext, + batch = fakeBatch, + batchMeta = fakeMetadata[batchIndex], + batchId = fakeFeatureBatchIds[featureIndex][batchIndex] + ) + verify(mockStorage).confirmBatchRead( + batchId = fakeBatchIds[batchIndex], + removalReason = RemovalReason.IntakeCode(UNKNOWN_RESPONSE_CODE), + deleteBatch = false + ) + } + } - assertThat(result) - .isEqualTo(ListenableWorker.Result.success()) + verifyNoMoreInteractions(mockUploader) + } } - // endregion + @Test + fun `M send all batches until failure W doWork() {request creation error}`( + @IntForgery(200, 300) successStatusCode: Int, + @IntForgery(0, 8) successfulBatchCount: Int, + @Forgery throwable: Exception + ) { + // Given + val failingStatus = UploadStatus.RequestCreationError(throwable) + stubFeaturesUploaders(successStatusCode, successfulBatchCount, failingStatus) - // region private + // When + val result = testedWorker.doWork() - private fun stubFeatures( - core: InternalSdkCore, - features: List, - storages: List, - uploaders: List - ) { - whenever(core.getAllFeatures()) doReturn features - features.forEachIndexed { index, feature -> - whenever(feature.uploader) doReturn uploaders[index] - whenever(feature.storage) doReturn storages[index] + // Then + assertThat(result).isEqualTo(ListenableWorker.Result.success()) + repeat(fakeFeaturesCount) { featureIndex -> + val mockUploader = mockUploaders[featureIndex] + val mockStorage = mockStorages[featureIndex] + val fakeBatches = fakeFeatureBatches[featureIndex] + val fakeMetadata = fakeFeatureBatchMetadata[featureIndex] + val fakeBatchIds = fakeFeatureBatchIds[featureIndex] + + fakeBatches.forEachIndexed { batchIndex, fakeBatch -> + // n successful batches + if (batchIndex < successfulBatchCount) { + verify(mockUploader).upload( + context = fakeDatadogContext, + batch = fakeBatch, + batchMeta = fakeMetadata[batchIndex], + batchId = fakeFeatureBatchIds[featureIndex][batchIndex] + ) + verify(mockStorage).confirmBatchRead( + batchId = fakeBatchIds[batchIndex], + removalReason = RemovalReason.IntakeCode(successStatusCode), + deleteBatch = true + ) + } + + // failing batch + if (batchIndex == successfulBatchCount) { + verify(mockUploader).upload( + context = fakeDatadogContext, + batch = fakeBatch, + batchMeta = fakeMetadata[batchIndex], + batchId = fakeFeatureBatchIds[featureIndex][batchIndex] + ) + verify(mockStorage).confirmBatchRead( + batchId = fakeBatchIds[batchIndex], + removalReason = RemovalReason.IntakeCode(UNKNOWN_RESPONSE_CODE), + deleteBatch = true + ) + } + } + + verifyNoMoreInteractions(mockUploader) } } - private fun stubReadSequence( - storage: Storage, - batchId: BatchId, - batch: List, - batchMetadata: ByteArray? + @Test + fun `M send all batches until failure W doWork() {unknown exception}`( + @IntForgery(200, 300) successStatusCode: Int, + @IntForgery(0, 8) successfulBatchCount: Int, + @Forgery throwable: Exception ) { - stubMultipleReadSequence( - storage, - listOf(batchId), - listOf(batch), - listOf(batchMetadata) - ) - } + // Given + val failingStatus = UploadStatus.UnknownException(throwable) + stubFeaturesUploaders(successStatusCode, successfulBatchCount, failingStatus) - private fun stubMultipleReadSequence( - storage: Storage, - batchIds: List, - batches: List>, - batchMetadata: List - ) { - whenever(storage.readNextBatch()) - .thenAnswer(object : Answer { - var invocationCount: Int = 0 + // When + val result = testedWorker.doWork() - override fun answer(invocation: InvocationOnMock): BatchData? { - if (invocationCount >= batches.size) { - return null - } + // Then + assertThat(result).isEqualTo(ListenableWorker.Result.success()) + repeat(fakeFeaturesCount) { featureIndex -> + val mockUploader = mockUploaders[featureIndex] + val mockStorage = mockStorages[featureIndex] + val fakeBatches = fakeFeatureBatches[featureIndex] + val fakeMetadata = fakeFeatureBatchMetadata[featureIndex] + val fakeBatchIds = fakeFeatureBatchIds[featureIndex] + + fakeBatches.forEachIndexed { batchIndex, fakeBatch -> + // n successful batches + if (batchIndex < successfulBatchCount) { + verify(mockUploader).upload( + context = fakeDatadogContext, + batch = fakeBatch, + batchMeta = fakeMetadata[batchIndex], + batchId = fakeFeatureBatchIds[featureIndex][batchIndex] + ) + verify(mockStorage).confirmBatchRead( + batchId = fakeBatchIds[batchIndex], + removalReason = RemovalReason.IntakeCode(successStatusCode), + deleteBatch = true + ) + } - val batchId = batchIds[invocationCount] - return BatchData( - id = batchId, - data = batches[invocationCount], - metadata = batchMetadata[invocationCount++] + // failing batch + if (batchIndex == successfulBatchCount) { + verify(mockUploader).upload( + context = fakeDatadogContext, + batch = fakeBatch, + batchMeta = fakeMetadata[batchIndex], + batchId = fakeFeatureBatchIds[featureIndex][batchIndex] + ) + verify(mockStorage).confirmBatchRead( + batchId = fakeBatchIds[batchIndex], + removalReason = RemovalReason.IntakeCode(UNKNOWN_RESPONSE_CODE), + deleteBatch = false ) } - }) - } + } - private class StorageDelegate( - private val delegate: Storage - ) : Storage { - override fun writeCurrentBatch( - datadogContext: DatadogContext, - forceNewBatch: Boolean, - callback: (EventBatchWriter) -> Unit - ) { - fail("we don't expect this one to be called") + verifyNoMoreInteractions(mockUploader) } + } - override fun readNextBatch(): BatchData? { - return delegate.readNextBatch() - } + @Test + fun `M send all batches until failure W doWork() {unknown status}`( + @IntForgery(200, 300) successStatusCode: Int, + @IntForgery(0, 8) successfulBatchCount: Int + ) { + // Given + val failingStatus = UploadStatus.UnknownStatus + stubFeaturesUploaders(successStatusCode, successfulBatchCount, failingStatus) - override fun confirmBatchRead( - batchId: BatchId, - removalReason: RemovalReason, - deleteBatch: Boolean - ) { - delegate.confirmBatchRead(batchId, removalReason, deleteBatch) - } + // When + val result = testedWorker.doWork() + + // Then + assertThat(result).isEqualTo(ListenableWorker.Result.success()) + repeat(fakeFeaturesCount) { featureIndex -> + val mockUploader = mockUploaders[featureIndex] + val mockStorage = mockStorages[featureIndex] + val fakeBatches = fakeFeatureBatches[featureIndex] + val fakeMetadata = fakeFeatureBatchMetadata[featureIndex] + val fakeBatchIds = fakeFeatureBatchIds[featureIndex] + + fakeBatches.forEachIndexed { batchIndex, fakeBatch -> + // n successful batches + if (batchIndex < successfulBatchCount) { + verify(mockUploader).upload( + context = fakeDatadogContext, + batch = fakeBatch, + batchMeta = fakeMetadata[batchIndex], + batchId = fakeFeatureBatchIds[featureIndex][batchIndex] + ) + verify(mockStorage).confirmBatchRead( + batchId = fakeBatchIds[batchIndex], + removalReason = RemovalReason.IntakeCode(successStatusCode), + deleteBatch = true + ) + } + + // failing batch + if (batchIndex == successfulBatchCount) { + verify(mockUploader).upload( + context = fakeDatadogContext, + batch = fakeBatch, + batchMeta = fakeMetadata[batchIndex], + batchId = fakeFeatureBatchIds[featureIndex][batchIndex] + ) + verify(mockStorage).confirmBatchRead( + batchId = fakeBatchIds[batchIndex], + removalReason = RemovalReason.IntakeCode(UNKNOWN_RESPONSE_CODE), + deleteBatch = true + ) + } + } - override fun dropAll() { - fail("we don't expect this one to be called") + verifyNoMoreInteractions(mockUploader) } } + // endregion + + // region Internal + private fun WorkerParameters.copyWith( inputData: Data ): WorkerParameters { @@ -681,21 +939,26 @@ internal class UploadWorkerTest { } @JvmStatic - fun errorStatusValues(): List { + fun errorWithRetryStatusValues(): List { val forge = Forge().apply { Configurator().configure(this) } return listOf( - forge.getForgery(UploadStatus.HttpClientError::class.java), + forge.getForgery(UploadStatus.Success::class.java), + + forge.getForgery(UploadStatus.InvalidTokenError::class.java), forge.getForgery(UploadStatus.HttpClientRateLimiting::class.java), - forge.getForgery(UploadStatus.HttpRedirection::class.java), + forge.getForgery(UploadStatus.HttpClientError::class.java), forge.getForgery(UploadStatus.HttpServerError::class.java), - forge.getForgery(UploadStatus.InvalidTokenError::class.java), + forge.getForgery(UploadStatus.HttpRedirection::class.java), + forge.getForgery(UploadStatus.UnknownHttpError::class.java), + forge.getForgery(UploadStatus.NetworkError::class.java), + forge.getForgery(UploadStatus.DNSError::class.java), forge.getForgery(UploadStatus.RequestCreationError::class.java), + forge.getForgery(UploadStatus.UnknownException::class.java), - forge.getForgery(UploadStatus.UnknownHttpError::class.java), forge.getForgery(UploadStatus.UnknownStatus::class.java) ) } diff --git a/dd-sdk-android-core/src/test/kotlin/com/datadog/android/core/internal/lifecycle/ProcessLifecycleCallbackTest.kt b/dd-sdk-android-core/src/test/kotlin/com/datadog/android/core/internal/lifecycle/ProcessLifecycleCallbackTest.kt index 003b7cb47c..420697cad4 100644 --- a/dd-sdk-android-core/src/test/kotlin/com/datadog/android/core/internal/lifecycle/ProcessLifecycleCallbackTest.kt +++ b/dd-sdk-android-core/src/test/kotlin/com/datadog/android/core/internal/lifecycle/ProcessLifecycleCallbackTest.kt @@ -20,8 +20,10 @@ import com.datadog.tools.unit.annotations.TestConfigurationsProvider import com.datadog.tools.unit.extensions.TestConfigurationExtension import com.datadog.tools.unit.extensions.config.TestConfiguration import com.datadog.tools.unit.setStaticValue +import fr.xgouchet.elmyr.annotation.StringForgery import fr.xgouchet.elmyr.junit5.ForgeConfiguration import fr.xgouchet.elmyr.junit5.ForgeExtension +import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test @@ -32,7 +34,7 @@ import org.mockito.Mock import org.mockito.junit.jupiter.MockitoExtension import org.mockito.junit.jupiter.MockitoSettings import org.mockito.kotlin.any -import org.mockito.kotlin.argThat +import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.doReturn import org.mockito.kotlin.eq import org.mockito.kotlin.mock @@ -58,9 +60,12 @@ internal class ProcessLifecycleCallbackTest { @Mock lateinit var mockInternalLogger: InternalLogger + @StringForgery + lateinit var fakeInstanceName: String + @BeforeEach fun `set up`() { - testedCallback = ProcessLifecycleCallback(appContext.mockInstance, mockInternalLogger) + testedCallback = ProcessLifecycleCallback(appContext.mockInstance, fakeInstanceName, mockInternalLogger) } @AfterEach @@ -84,14 +89,17 @@ internal class ProcessLifecycleCallbackTest { testedCallback.onStopped() // Then - verify(mockWorkManager).enqueueUniqueWork( - eq(UPLOAD_WORKER_NAME), - eq(ExistingWorkPolicy.REPLACE), - argThat { - this.workSpec.workerClassName == UploadWorker::class.java.canonicalName && - this.tags.contains(TAG_DATADOG_UPLOAD) - } - ) + argumentCaptor { + verify(mockWorkManager).enqueueUniqueWork( + eq(UPLOAD_WORKER_NAME), + eq(ExistingWorkPolicy.REPLACE), + capture() + ) + val workSpec = lastValue.workSpec + assertThat(workSpec.workerClassName).isEqualTo(UploadWorker::class.java.canonicalName) + assertThat(workSpec.input.getString(UploadWorker.DATADOG_INSTANCE_NAME)).isEqualTo(fakeInstanceName) + assertThat(lastValue.tags).contains("$TAG_DATADOG_UPLOAD/$fakeInstanceName") + } } @Test @@ -124,7 +132,7 @@ internal class ProcessLifecycleCallbackTest { testedCallback.onStarted() // Then - verify(mockWorkManager).cancelAllWorkByTag(TAG_DATADOG_UPLOAD) + verify(mockWorkManager).cancelAllWorkByTag("$TAG_DATADOG_UPLOAD/$fakeInstanceName") } @Test diff --git a/dd-sdk-android-core/src/test/kotlin/com/datadog/android/core/internal/logger/SdkInternalLoggerTest.kt b/dd-sdk-android-core/src/test/kotlin/com/datadog/android/core/internal/logger/SdkInternalLoggerTest.kt index 1765da8dbb..de4349e503 100644 --- a/dd-sdk-android-core/src/test/kotlin/com/datadog/android/core/internal/logger/SdkInternalLoggerTest.kt +++ b/dd-sdk-android-core/src/test/kotlin/com/datadog/android/core/internal/logger/SdkInternalLoggerTest.kt @@ -14,11 +14,13 @@ import com.datadog.android.api.feature.FeatureScope import com.datadog.android.api.feature.FeatureSdkCore import com.datadog.android.core.internal.metrics.MethodCalledTelemetry import com.datadog.android.core.metrics.TelemetryMetricType +import com.datadog.android.internal.telemetry.InternalTelemetryEvent import com.datadog.android.utils.forge.Configurator import com.datadog.tools.unit.forge.aThrowable import com.datadog.tools.unit.forge.exhaustiveAttributes import fr.xgouchet.elmyr.Forge import fr.xgouchet.elmyr.annotation.FloatForgery +import fr.xgouchet.elmyr.annotation.Forgery import fr.xgouchet.elmyr.annotation.IntForgery import fr.xgouchet.elmyr.annotation.StringForgery import fr.xgouchet.elmyr.annotation.StringForgeryType @@ -34,6 +36,7 @@ import org.junit.jupiter.api.extension.Extensions import org.mockito.Mock import org.mockito.junit.jupiter.MockitoSettings import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.doReturn import org.mockito.kotlin.mock import org.mockito.kotlin.mockingDetails @@ -280,13 +283,12 @@ internal class SdkInternalLoggerTest { ) // Then - verify(mockRumFeatureScope) - .sendEvent( - mapOf( - "type" to "telemetry_debug", - "message" to fakeMessage - ) - ) + argumentCaptor() { + verify(mockRumFeatureScope).sendEvent(capture()) + val logEvent = firstValue as InternalTelemetryEvent.Log.Debug + assertThat(logEvent.message).isEqualTo(fakeMessage) + assertThat(logEvent.additionalProperties).isNull() + } } @Test @@ -314,14 +316,12 @@ internal class SdkInternalLoggerTest { ) // Then - verify(mockRumFeatureScope) - .sendEvent( - mapOf( - "type" to "telemetry_debug", - "message" to fakeMessage, - "additionalProperties" to fakeAdditionalProperties - ) - ) + argumentCaptor() { + verify(mockRumFeatureScope).sendEvent(capture()) + val logEvent = firstValue as InternalTelemetryEvent.Log.Debug + assertThat(logEvent.message).isEqualTo(fakeMessage) + assertThat(logEvent.additionalProperties).isEqualTo(fakeAdditionalProperties) + } } @Test @@ -346,13 +346,12 @@ internal class SdkInternalLoggerTest { ) // Then - verify(mockRumFeatureScope) - .sendEvent( - mapOf( - "type" to "telemetry_debug", - "message" to fakeMessage - ) - ) + argumentCaptor() { + verify(mockRumFeatureScope).sendEvent(capture()) + val logEvent = firstValue as InternalTelemetryEvent.Log.Debug + assertThat(logEvent.message).isEqualTo(fakeMessage) + assertThat(logEvent.additionalProperties).isEmpty() + } } @Test @@ -378,15 +377,12 @@ internal class SdkInternalLoggerTest { ) // Then - verify(mockRumFeatureScope) - .sendEvent( - mapOf( - "type" to "telemetry_error", - "message" to fakeMessage, - "throwable" to null, - "additionalProperties" to fakeAdditionalProperties - ) - ) + argumentCaptor() { + verify(mockRumFeatureScope).sendEvent(capture()) + val logEvent = firstValue as InternalTelemetryEvent.Log.Error + assertThat(logEvent.message).isEqualTo(fakeMessage) + assertThat(logEvent.additionalProperties).isEqualTo(fakeAdditionalProperties) + } } @Test @@ -413,15 +409,13 @@ internal class SdkInternalLoggerTest { ) // Then - verify(mockRumFeatureScope) - .sendEvent( - mapOf( - "type" to "telemetry_error", - "message" to fakeMessage, - "throwable" to fakeThrowable, - "additionalProperties" to fakeAdditionalProperties - ) - ) + argumentCaptor() { + verify(mockRumFeatureScope).sendEvent(capture()) + val logEvent = firstValue as InternalTelemetryEvent.Log.Error + assertThat(logEvent.message).isEqualTo(fakeMessage) + assertThat(logEvent.error).isEqualTo(fakeThrowable) + assertThat(logEvent.additionalProperties).isEqualTo(fakeAdditionalProperties) + } } @Test @@ -448,13 +442,11 @@ internal class SdkInternalLoggerTest { } // Then - verify(mockRumFeatureScope) - .sendEvent( - mapOf( - "type" to "telemetry_debug", - "message" to fakeMessage - ) - ) + argumentCaptor() { + verify(mockRumFeatureScope).sendEvent(capture()) + val logEvent = firstValue as InternalTelemetryEvent.Log.Debug + assertThat(logEvent.message).isEqualTo(fakeMessage) + } } @Test @@ -477,14 +469,12 @@ internal class SdkInternalLoggerTest { ) // Then - verify(mockRumFeatureScope) - .sendEvent( - mapOf( - "type" to "mobile_metric", - "message" to fakeMessage, - "additionalProperties" to fakeAdditionalProperties - ) - ) + argumentCaptor() { + verify(mockRumFeatureScope).sendEvent(capture()) + val metricEvent = firstValue as InternalTelemetryEvent.Metric + assertThat(metricEvent.message).isEqualTo(fakeMessage) + assertThat(metricEvent.additionalProperties).isEqualTo(fakeAdditionalProperties) + } } @Test @@ -518,7 +508,7 @@ internal class SdkInternalLoggerTest { } @Test - fun `M send metric W metric() {sampling 0 percent}`( + fun `M not send metric W metric() {sampling 0 percent}`( @StringForgery fakeMessage: String, forge: Forge ) { @@ -541,7 +531,7 @@ internal class SdkInternalLoggerTest { } @Test - fun `M do nothing metric W metric { rum feature not initialized }`( + fun `M do nothing W metric { rum feature not initialized }`( @StringForgery fakeMessage: String, @FloatForgery(0f, 100f) fakeSampleRate: Float, forge: Forge @@ -562,6 +552,85 @@ internal class SdkInternalLoggerTest { } } + @Test + fun `M send api usage telemetry W logApiUsage() { sampling rate 100 percent }`( + @Forgery fakeApiUsageInternalTelemetryEvent: InternalTelemetryEvent.ApiUsage + ) { + // Given + val mockRumFeatureScope = mock() + whenever(mockSdkCore.getFeature(Feature.RUM_FEATURE_NAME)) doReturn mockRumFeatureScope + + // When + testedInternalLogger.logApiUsage(fakeApiUsageInternalTelemetryEvent, 100.0f) + + // Then + argumentCaptor() { + verify(mockRumFeatureScope).sendEvent(capture()) + val apiUsageEvent = firstValue as InternalTelemetryEvent.ApiUsage + assertThat(apiUsageEvent).isEqualTo(fakeApiUsageInternalTelemetryEvent) + } + } + + @Test + fun `M send api usage telemetry W metric() {sampling x percent}`( + @FloatForgery(25f, 75f) fakeSampleRate: Float, + @Forgery fakeApiUsageInternalTelemetryEvent: InternalTelemetryEvent.ApiUsage + ) { + // Given + val mockRumFeatureScope = mock() + whenever(mockSdkCore.getFeature(Feature.RUM_FEATURE_NAME)) doReturn mockRumFeatureScope + val repeatCount = 100 + val expectedCallCount = (repeatCount * fakeSampleRate / 100f).toInt() + val marginOfError = (repeatCount * 0.25f).toInt() + + // When + repeat(100) { + testedInternalLogger.logApiUsage( + fakeApiUsageInternalTelemetryEvent, + fakeSampleRate + ) + } + + // Then + val count = mockingDetails(mockRumFeatureScope).invocations.filter { it.method.name == "sendEvent" }.size + assertThat(count).isCloseTo(expectedCallCount, offset(marginOfError)) + } + + @Test + fun `M not send any api usage telemetry W logApiUsage() {sampling 0 percent}`( + @Forgery fakeApiUsageInternalTelemetryEvent: InternalTelemetryEvent.ApiUsage + ) { + // Given + val mockRumFeatureScope = mock() + whenever(mockSdkCore.getFeature(Feature.RUM_FEATURE_NAME)) doReturn mockRumFeatureScope + + // When + testedInternalLogger.logApiUsage( + fakeApiUsageInternalTelemetryEvent, + 0.0f + ) + + // Then + verify(mockRumFeatureScope, never()).sendEvent(any()) + } + + @Test + fun `M do nothing W logApiUsage { rum feature not initialized }`( + @FloatForgery(0f, 100f) fakeSampleRate: Float, + @Forgery fakeApiUsageInternalTelemetryEvent: InternalTelemetryEvent.ApiUsage + ) { + // Given + whenever(mockSdkCore.getFeature(Feature.RUM_FEATURE_NAME)) doReturn null + + // When + assertDoesNotThrow { + testedInternalLogger.logApiUsage( + fakeApiUsageInternalTelemetryEvent, + fakeSampleRate + ) + } + } + @Test fun `M create PerformanceMetric W startPerformanceMeasure() {MethodCalled, 100 percent}`( @StringForgery fakeCaller: String, diff --git a/dd-sdk-android-core/src/test/kotlin/com/datadog/android/core/internal/metrics/BatchMetricsDispatcherTest.kt b/dd-sdk-android-core/src/test/kotlin/com/datadog/android/core/internal/metrics/BatchMetricsDispatcherTest.kt index a563c0d55a..871e9078d3 100644 --- a/dd-sdk-android-core/src/test/kotlin/com/datadog/android/core/internal/metrics/BatchMetricsDispatcherTest.kt +++ b/dd-sdk-android-core/src/test/kotlin/com/datadog/android/core/internal/metrics/BatchMetricsDispatcherTest.kt @@ -11,7 +11,6 @@ import com.datadog.android.api.feature.Feature import com.datadog.android.core.internal.configuration.DataUploadConfiguration import com.datadog.android.core.internal.persistence.file.FilePersistenceConfig import com.datadog.android.core.internal.time.TimeProvider -import com.datadog.android.core.sampling.Sampler import com.datadog.android.utils.forge.Configurator import fr.xgouchet.elmyr.Forge import fr.xgouchet.elmyr.annotation.Forgery @@ -30,7 +29,6 @@ import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.doReturn import org.mockito.kotlin.eq import org.mockito.kotlin.mock -import org.mockito.kotlin.reset import org.mockito.kotlin.verify import org.mockito.kotlin.verifyNoInteractions import org.mockito.kotlin.verifyNoMoreInteractions @@ -63,15 +61,11 @@ internal class BatchMetricsDispatcherTest { @Mock lateinit var mockInternalLogger: InternalLogger - @Mock - lateinit var mockSampler: Sampler - @Forgery lateinit var fakeFilePersistenceConfig: FilePersistenceConfig @BeforeEach fun `set up`(forge: Forge) { - whenever(mockSampler.sample()).doReturn(true) fakeFeatureName = forge.anElementFrom( listOf( Feature.RUM_FEATURE_NAME, @@ -87,8 +81,7 @@ internal class BatchMetricsDispatcherTest { fakeUploadConfiguration, fakeFilePersistenceConfig, mockInternalLogger, - mockDateTimeProvider, - mockSampler + mockDateTimeProvider ) } @@ -286,36 +279,6 @@ internal class BatchMetricsDispatcherTest { verifyNoMoreInteractions(mockInternalLogger) } - @Test - fun `M do nothing W sendBatchDeletedMetric { sampled out }`(forge: Forge) { - // Given - reset(mockSampler) - whenever(mockSampler.sample()).doReturn(false) - val fakeReason = forge.forgeIncludeInMetricReason() - val fakeFile: File = forge.forgeValidFile() - - // When - testedBatchMetricsDispatcher.sendBatchDeletedMetric(fakeFile, fakeReason) - - // Then - verifyNoInteractions(mockInternalLogger) - } - - @Test - fun `M do nothing W sendBatchDeletedMetric { reason notIncludedInMetrics }`(forge: Forge) { - // Given - reset(mockSampler) - whenever(mockSampler.sample()).doReturn(false) - val fakeReason: RemovalReason.Flushed = forge.getForgery() - val fakeFile: File = forge.forgeValidFile() - - // When - testedBatchMetricsDispatcher.sendBatchDeletedMetric(fakeFile, fakeReason) - - // Then - verifyNoInteractions(mockInternalLogger) - } - @Test fun `M do nothing W sendBatchDeletedMetric { feature unknown }`(forge: Forge) { // Given @@ -325,8 +288,7 @@ internal class BatchMetricsDispatcherTest { fakeUploadConfiguration, fakeFilePersistenceConfig, mockInternalLogger, - mockDateTimeProvider, - mockSampler + mockDateTimeProvider ) val fakeReason: RemovalReason.Flushed = forge.getForgery() val fakeFile: File = forge.forgeValidFile() @@ -561,8 +523,7 @@ internal class BatchMetricsDispatcherTest { fakeUploadConfiguration, fakeFilePersistenceConfig, mockInternalLogger, - mockDateTimeProvider, - mockSampler + mockDateTimeProvider ) val fakeFile: File = forge.forgeValidClosedFile() @@ -573,23 +534,6 @@ internal class BatchMetricsDispatcherTest { verifyNoInteractions(mockInternalLogger) } - @Test - fun `M do nothing W sendBatchClosedMetric { sampled out }`( - @Forgery fakeMetadata: BatchClosedMetadata, - forge: Forge - ) { - // Given - reset(mockSampler) - whenever(mockSampler.sample()).doReturn(false) - val fakeFile: File = forge.forgeValidClosedFile() - - // When - testedBatchMetricsDispatcher.sendBatchClosedMetric(fakeFile, fakeMetadata) - - // Then - verifyNoInteractions(mockInternalLogger) - } - private fun resolveDefaultDeleteExtraProperties(file: File): MutableMap { return mutableMapOf( BatchMetricsDispatcher.TYPE_KEY to BatchMetricsDispatcher.BATCH_DELETED_TYPE_VALUE, diff --git a/dd-sdk-android-core/src/test/kotlin/com/datadog/android/core/internal/persistence/AbstractStorageTest.kt b/dd-sdk-android-core/src/test/kotlin/com/datadog/android/core/internal/persistence/AbstractStorageTest.kt index c8e4fa9a0b..d02a0ed4ef 100644 --- a/dd-sdk-android-core/src/test/kotlin/com/datadog/android/core/internal/persistence/AbstractStorageTest.kt +++ b/dd-sdk-android-core/src/test/kotlin/com/datadog/android/core/internal/persistence/AbstractStorageTest.kt @@ -109,7 +109,7 @@ internal class AbstractStorageTest { @Forgery fakeBatchEvent: RawBatchEvent ) { // Given - val sdkContext = fakeDatadogContext.copy(trackingConsent = TrackingConsent.GRANTED) + whenever(mockConsentProvider.getConsent()) doReturn TrackingConsent.GRANTED val mockWriteCallback = mock<(EventBatchWriter) -> Unit>() whenever(mockGrantedPersistenceStrategy.write(any(), anyOrNull(), any())) doReturn fakeResult var result: Boolean? = null @@ -118,7 +118,7 @@ internal class AbstractStorageTest { } // When - testedStorage.writeCurrentBatch(sdkContext, forceNewBatch, mockWriteCallback) + testedStorage.writeCurrentBatch(fakeDatadogContext, forceNewBatch, mockWriteCallback) // Then assertThat(result).isEqualTo(fakeResult) @@ -138,7 +138,7 @@ internal class AbstractStorageTest { @StringForgery fakeBatchMetadata: String ) { // Given - val sdkContext = fakeDatadogContext.copy(trackingConsent = TrackingConsent.GRANTED) + whenever(mockConsentProvider.getConsent()) doReturn TrackingConsent.GRANTED val mockWriteCallback = mock<(EventBatchWriter) -> Unit>() val batchMetadata = fakeBatchMetadata.toByteArray() whenever(mockGrantedPersistenceStrategy.write(any(), anyOrNull(), any())) doReturn fakeResult @@ -148,7 +148,7 @@ internal class AbstractStorageTest { } // When - testedStorage.writeCurrentBatch(sdkContext, forceNewBatch, mockWriteCallback) + testedStorage.writeCurrentBatch(fakeDatadogContext, forceNewBatch, mockWriteCallback) // Then assertThat(result).isEqualTo(fakeResult) @@ -165,7 +165,8 @@ internal class AbstractStorageTest { @BoolForgery forceNewBatch: Boolean ) { // Given - val sdkContext = fakeDatadogContext.copy(trackingConsent = TrackingConsent.GRANTED) + whenever(mockConsentProvider.getConsent()) doReturn TrackingConsent.GRANTED + whenever(mockGrantedPersistenceStrategy.currentMetadata()) doReturn null val mockWriteCallback = mock<(EventBatchWriter) -> Unit>() whenever(mockGrantedPersistenceStrategy.currentMetadata()) doReturn null var resultMetadata: ByteArray? = null @@ -174,7 +175,7 @@ internal class AbstractStorageTest { } // When - testedStorage.writeCurrentBatch(sdkContext, forceNewBatch, mockWriteCallback) + testedStorage.writeCurrentBatch(fakeDatadogContext, forceNewBatch, mockWriteCallback) // Then assertThat(resultMetadata).isNull() @@ -192,7 +193,7 @@ internal class AbstractStorageTest { @StringForgery fakeBatchMetadata: String ) { // Given - val sdkContext = fakeDatadogContext.copy(trackingConsent = TrackingConsent.GRANTED) + whenever(mockConsentProvider.getConsent()) doReturn TrackingConsent.GRANTED val mockWriteCallback = mock<(EventBatchWriter) -> Unit>() val batchMetadata = fakeBatchMetadata.toByteArray() whenever(mockGrantedPersistenceStrategy.currentMetadata()) doReturn batchMetadata @@ -202,7 +203,7 @@ internal class AbstractStorageTest { } // When - testedStorage.writeCurrentBatch(sdkContext, forceNewBatch, mockWriteCallback) + testedStorage.writeCurrentBatch(fakeDatadogContext, forceNewBatch, mockWriteCallback) // Then assertThat(resultMetadata).isEqualTo(batchMetadata) @@ -221,7 +222,7 @@ internal class AbstractStorageTest { @Forgery fakeBatchEvent: RawBatchEvent ) { // Given - val sdkContext = fakeDatadogContext.copy(trackingConsent = TrackingConsent.PENDING) + whenever(mockConsentProvider.getConsent()) doReturn TrackingConsent.PENDING val mockWriteCallback = mock<(EventBatchWriter) -> Unit>() whenever(mockPendingPersistenceStrategy.write(any(), anyOrNull(), any())) doReturn fakeResult var result: Boolean? = null @@ -230,7 +231,7 @@ internal class AbstractStorageTest { } // When - testedStorage.writeCurrentBatch(sdkContext, forceNewBatch, mockWriteCallback) + testedStorage.writeCurrentBatch(fakeDatadogContext, forceNewBatch, mockWriteCallback) // Then assertThat(result).isEqualTo(fakeResult) @@ -250,7 +251,7 @@ internal class AbstractStorageTest { @StringForgery fakeBatchMetadata: String ) { // Given - val sdkContext = fakeDatadogContext.copy(trackingConsent = TrackingConsent.PENDING) + whenever(mockConsentProvider.getConsent()) doReturn TrackingConsent.PENDING val mockWriteCallback = mock<(EventBatchWriter) -> Unit>() val batchMetadata = fakeBatchMetadata.toByteArray() whenever(mockPendingPersistenceStrategy.write(any(), anyOrNull(), any())) doReturn fakeResult @@ -260,7 +261,7 @@ internal class AbstractStorageTest { } // When - testedStorage.writeCurrentBatch(sdkContext, forceNewBatch, mockWriteCallback) + testedStorage.writeCurrentBatch(fakeDatadogContext, forceNewBatch, mockWriteCallback) // Then assertThat(result).isEqualTo(fakeResult) @@ -277,7 +278,7 @@ internal class AbstractStorageTest { @BoolForgery forceNewBatch: Boolean ) { // Given - val sdkContext = fakeDatadogContext.copy(trackingConsent = TrackingConsent.PENDING) + whenever(mockConsentProvider.getConsent()) doReturn TrackingConsent.PENDING val mockWriteCallback = mock<(EventBatchWriter) -> Unit>() var resultMetadata: ByteArray? = null whenever(mockWriteCallback.invoke(any())) doAnswer { @@ -286,7 +287,7 @@ internal class AbstractStorageTest { whenever(mockPendingPersistenceStrategy.currentMetadata()) doReturn null // When - testedStorage.writeCurrentBatch(sdkContext, forceNewBatch, mockWriteCallback) + testedStorage.writeCurrentBatch(fakeDatadogContext, forceNewBatch, mockWriteCallback) // Then assertThat(resultMetadata).isNull() @@ -304,7 +305,7 @@ internal class AbstractStorageTest { @StringForgery fakeBatchMetadata: String ) { // Given - val sdkContext = fakeDatadogContext.copy(trackingConsent = TrackingConsent.PENDING) + whenever(mockConsentProvider.getConsent()) doReturn TrackingConsent.PENDING val mockWriteCallback = mock<(EventBatchWriter) -> Unit>() val batchMetadata = fakeBatchMetadata.toByteArray() var resultMetadata: ByteArray? = null @@ -314,7 +315,7 @@ internal class AbstractStorageTest { whenever(mockPendingPersistenceStrategy.currentMetadata()) doReturn batchMetadata // When - testedStorage.writeCurrentBatch(sdkContext, forceNewBatch, mockWriteCallback) + testedStorage.writeCurrentBatch(fakeDatadogContext, forceNewBatch, mockWriteCallback) // Then assertThat(resultMetadata).isEqualTo(batchMetadata) @@ -333,7 +334,7 @@ internal class AbstractStorageTest { @StringForgery fakeBatchMetadata: String ) { // Given - val sdkContext = fakeDatadogContext.copy(trackingConsent = TrackingConsent.NOT_GRANTED) + whenever(mockConsentProvider.getConsent()) doReturn TrackingConsent.NOT_GRANTED val mockWriteCallback = mock<(EventBatchWriter) -> Unit>() val batchMetadata = fakeBatchMetadata.toByteArray() var result: Boolean? = null @@ -342,7 +343,7 @@ internal class AbstractStorageTest { } // When - testedStorage.writeCurrentBatch(sdkContext, forceNewBatch, mockWriteCallback) + testedStorage.writeCurrentBatch(fakeDatadogContext, forceNewBatch, mockWriteCallback) // Then assertThat(result).isFalse() @@ -358,7 +359,7 @@ internal class AbstractStorageTest { @BoolForgery forceNewBatch: Boolean ) { // Given - val sdkContext = fakeDatadogContext.copy(trackingConsent = TrackingConsent.NOT_GRANTED) + whenever(mockConsentProvider.getConsent()) doReturn TrackingConsent.NOT_GRANTED val mockWriteCallback = mock<(EventBatchWriter) -> Unit>() var resultMetadata: ByteArray? = null whenever(mockWriteCallback.invoke(any())) doAnswer { @@ -366,7 +367,7 @@ internal class AbstractStorageTest { } // When - testedStorage.writeCurrentBatch(sdkContext, forceNewBatch, mockWriteCallback) + testedStorage.writeCurrentBatch(fakeDatadogContext, forceNewBatch, mockWriteCallback) // Then assertThat(resultMetadata).isNull() diff --git a/dd-sdk-android-core/src/test/kotlin/com/datadog/android/core/internal/persistence/ConsentAwareStorageTest.kt b/dd-sdk-android-core/src/test/kotlin/com/datadog/android/core/internal/persistence/ConsentAwareStorageTest.kt index ab06b9d06c..9012a3157a 100644 --- a/dd-sdk-android-core/src/test/kotlin/com/datadog/android/core/internal/persistence/ConsentAwareStorageTest.kt +++ b/dd-sdk-android-core/src/test/kotlin/com/datadog/android/core/internal/persistence/ConsentAwareStorageTest.kt @@ -18,6 +18,7 @@ import com.datadog.android.core.internal.persistence.file.FileOrchestrator import com.datadog.android.core.internal.persistence.file.FilePersistenceConfig import com.datadog.android.core.internal.persistence.file.FileReaderWriter import com.datadog.android.core.internal.persistence.file.batch.BatchFileReaderWriter +import com.datadog.android.core.internal.privacy.ConsentProvider import com.datadog.android.core.metrics.PerformanceMetric import com.datadog.android.core.metrics.TelemetryMetricType import com.datadog.android.privacy.TrackingConsent @@ -113,17 +114,26 @@ internal class ConsentAwareStorageTest { @Forgery lateinit var mockGrantedRootParentFile: File + @Mock + lateinit var mockConsentProvider: ConsentProvider + + @StringForgery + lateinit var fakeFeatureName: String + @BeforeEach - fun `set up`() { + fun `set up`(forge: Forge) { + whenever(mockConsentProvider.getConsent()) doReturn forge.aValueFrom(TrackingConsent::class.java) whenever(mockPendingOrchestrator.getRootDir()) doReturn File(mockPendingRootParentFile, fakeRootDirName) whenever(mockGrantedOrchestrator.getRootDir()) doReturn File(mockGrantedRootParentFile, fakeRootDirName) + whenever(mockPendingOrchestrator.getRootDirName()) doReturn fakeRootDirName + whenever(mockGrantedOrchestrator.getRootDirName()) doReturn fakeRootDirName whenever( mockInternalLogger.startPerformanceMeasure( "com.datadog.android.core.internal.persistence.ConsentAwareStorage", TelemetryMetricType.MethodCalled, 0.001f, - "writeCurrentBatch[$fakeRootDirName]" + "writeCurrentBatch[$fakeFeatureName]" ) ) doReturn mockMetric @@ -137,7 +147,9 @@ internal class ConsentAwareStorageTest { mockFileMover, mockInternalLogger, mockFilePersistenceConfig, - mockMetricsDispatcher + mockMetricsDispatcher, + mockConsentProvider, + fakeFeatureName ) } @@ -151,18 +163,17 @@ internal class ConsentAwareStorageTest { ) { // Given val mockCallback = mock<(EventBatchWriter) -> Unit>() - val sdkContext = fakeDatadogContext.copy(trackingConsent = TrackingConsent.GRANTED) + whenever(mockConsentProvider.getConsent()) doReturn TrackingConsent.GRANTED whenever(mockGrantedOrchestrator.getWritableFile(forceNewBatch)) doReturn file val mockMetaFile: File? = forge.aNullable { mock() } whenever(mockGrantedOrchestrator.getMetadataFile(file)) doReturn mockMetaFile // When - testedStorage.writeCurrentBatch(sdkContext, forceNewBatch, callback = mockCallback) + testedStorage.writeCurrentBatch(fakeDatadogContext, forceNewBatch, callback = mockCallback) // Then verify(mockGrantedOrchestrator).getWritableFile(forceNewBatch) verify(mockGrantedOrchestrator).getMetadataFile(file) - verify(mockGrantedOrchestrator).getRootDir() argumentCaptor { verify(mockCallback).invoke(capture()) assertThat(firstValue).isInstanceOf(FileEventBatchWriter::class.java) @@ -183,15 +194,14 @@ internal class ConsentAwareStorageTest { ) { // Given val mockCallback = mock<(EventBatchWriter) -> Unit>() - val sdkContext = fakeDatadogContext.copy(trackingConsent = TrackingConsent.GRANTED) + whenever(mockConsentProvider.getConsent()) doReturn TrackingConsent.GRANTED whenever(mockGrantedOrchestrator.getWritableFile(forceNewBatch)) doReturn null // When - testedStorage.writeCurrentBatch(sdkContext, forceNewBatch, callback = mockCallback) + testedStorage.writeCurrentBatch(fakeDatadogContext, forceNewBatch, callback = mockCallback) // Then verify(mockGrantedOrchestrator).getWritableFile(forceNewBatch) - verify(mockGrantedOrchestrator).getRootDir() argumentCaptor { verify(mockCallback).invoke(capture()) assertThat(firstValue).isInstanceOf(NoOpEventBatchWriter::class.java) @@ -214,18 +224,17 @@ internal class ConsentAwareStorageTest { ) { // Given val mockCallback = mock<(EventBatchWriter) -> Unit>() - val sdkContext = fakeDatadogContext.copy(trackingConsent = TrackingConsent.PENDING) + whenever(mockConsentProvider.getConsent()) doReturn TrackingConsent.PENDING whenever(mockPendingOrchestrator.getWritableFile(forceNewBatch)) doReturn file val mockMetaFile: File? = forge.aNullable { mock() } whenever(mockPendingOrchestrator.getMetadataFile(file)) doReturn mockMetaFile // When - testedStorage.writeCurrentBatch(sdkContext, forceNewBatch, callback = mockCallback) + testedStorage.writeCurrentBatch(fakeDatadogContext, forceNewBatch, callback = mockCallback) // Then verify(mockPendingOrchestrator).getWritableFile(forceNewBatch) verify(mockPendingOrchestrator).getMetadataFile(file) - verify(mockPendingOrchestrator).getRootDir() argumentCaptor { verify(mockCallback).invoke(capture()) assertThat(firstValue).isInstanceOf(FileEventBatchWriter::class.java) @@ -246,15 +255,14 @@ internal class ConsentAwareStorageTest { ) { // Given val mockCallback = mock<(EventBatchWriter) -> Unit>() - val sdkContext = fakeDatadogContext.copy(trackingConsent = TrackingConsent.PENDING) + whenever(mockConsentProvider.getConsent()) doReturn TrackingConsent.PENDING whenever(mockPendingOrchestrator.getWritableFile(forceNewBatch)) doReturn null // When - testedStorage.writeCurrentBatch(sdkContext, forceNewBatch, callback = mockCallback) + testedStorage.writeCurrentBatch(fakeDatadogContext, forceNewBatch, callback = mockCallback) // Then verify(mockPendingOrchestrator).getWritableFile(forceNewBatch) - verify(mockPendingOrchestrator).getRootDir() argumentCaptor { verify(mockCallback).invoke(capture()) assertThat(firstValue).isInstanceOf(NoOpEventBatchWriter::class.java) @@ -275,7 +283,7 @@ internal class ConsentAwareStorageTest { ) { // Given val mockCallback = mock<(EventBatchWriter) -> Unit>() - val sdkContext = fakeDatadogContext.copy(trackingConsent = TrackingConsent.NOT_GRANTED) + whenever(mockConsentProvider.getConsent()) doReturn TrackingConsent.NOT_GRANTED whenever( mockInternalLogger.startPerformanceMeasure( "com.datadog.android.core.internal.persistence.ConsentAwareStorage", @@ -286,7 +294,7 @@ internal class ConsentAwareStorageTest { ) doReturn mockMetric // When - testedStorage.writeCurrentBatch(sdkContext, forceNewBatch, callback = mockCallback) + testedStorage.writeCurrentBatch(fakeDatadogContext, forceNewBatch, callback = mockCallback) // Then argumentCaptor { @@ -322,7 +330,9 @@ internal class ConsentAwareStorageTest { mockFileMover, mockInternalLogger, mockFilePersistenceConfig, - mockMetricsDispatcher + mockMetricsDispatcher, + mockConsentProvider, + fakeFeatureName ) // When @@ -338,11 +348,6 @@ internal class ConsentAwareStorageTest { RejectedExecutionException::class.java, false ) - if (fakeDatadogContext.trackingConsent == TrackingConsent.PENDING) { - verify(mockPendingOrchestrator).getRootDir() - } else if (fakeDatadogContext.trackingConsent == TrackingConsent.GRANTED) { - verify(mockGrantedOrchestrator).getRootDir() - } verifyNoMoreInteractions( mockGrantedOrchestrator, mockPendingOrchestrator, @@ -370,7 +375,9 @@ internal class ConsentAwareStorageTest { mockFileMover, mockInternalLogger, mockFilePersistenceConfig, - mockMetricsDispatcher + mockMetricsDispatcher, + mockConsentProvider, + fakeFeatureName ) var accumulator: Byte = 0 val event = forge.aString().toByteArray() @@ -387,7 +394,7 @@ internal class ConsentAwareStorageTest { eventType = fakeEventType ) } - val sdkContext = fakeDatadogContext.copy(trackingConsent = TrackingConsent.GRANTED) + whenever(mockConsentProvider.getConsent()) doReturn TrackingConsent.GRANTED whenever(mockGrantedOrchestrator.getWritableFile(forceNewBatch)) doReturn file val mockMetaFile = mock().apply { whenever(exists()) doReturn true } whenever(mockMetaReaderWriter.readData(mockMetaFile)) doAnswer { @@ -408,7 +415,7 @@ internal class ConsentAwareStorageTest { // When repeat(threadsCount) { - testedStorage.writeCurrentBatch(sdkContext, forceNewBatch, callback = callback) + testedStorage.writeCurrentBatch(fakeDatadogContext, forceNewBatch, callback = callback) } executor.shutdown() executor.awaitTermination(1, TimeUnit.SECONDS) @@ -547,7 +554,9 @@ internal class ConsentAwareStorageTest { mockFileMover, mockInternalLogger, mockFilePersistenceConfig, - mockMetricsDispatcher + mockMetricsDispatcher, + mockConsentProvider, + fakeFeatureName ) whenever(mockGrantedOrchestrator.getReadableFile(emptySet())) doReturn file @@ -668,7 +677,9 @@ internal class ConsentAwareStorageTest { mockFileMover, mockInternalLogger, mockFilePersistenceConfig, - mockMetricsDispatcher + mockMetricsDispatcher, + mockConsentProvider, + fakeFeatureName ) whenever(mockGrantedOrchestrator.getAllFiles()) doReturn listOf(grantedFile) @@ -723,7 +734,9 @@ internal class ConsentAwareStorageTest { mockFileMover, mockInternalLogger, mockFilePersistenceConfig, - mockMetricsDispatcher + mockMetricsDispatcher, + mockConsentProvider, + fakeFeatureName ) whenever(mockGrantedOrchestrator.getReadableFile(any())) doReturnConsecutively files diff --git a/dd-sdk-android-core/src/test/kotlin/com/datadog/android/core/internal/persistence/file/datastore/DataStoreFileHandlerTest.kt b/dd-sdk-android-core/src/test/kotlin/com/datadog/android/core/internal/persistence/datastore/DataStoreFileHandlerTest.kt similarity index 94% rename from dd-sdk-android-core/src/test/kotlin/com/datadog/android/core/internal/persistence/file/datastore/DataStoreFileHandlerTest.kt rename to dd-sdk-android-core/src/test/kotlin/com/datadog/android/core/internal/persistence/datastore/DataStoreFileHandlerTest.kt index 7da88fee09..c6e0c2c7b0 100644 --- a/dd-sdk-android-core/src/test/kotlin/com/datadog/android/core/internal/persistence/file/datastore/DataStoreFileHandlerTest.kt +++ b/dd-sdk-android-core/src/test/kotlin/com/datadog/android/core/internal/persistence/datastore/DataStoreFileHandlerTest.kt @@ -4,15 +4,12 @@ * Copyright 2016-Present Datadog, Inc. */ -package com.datadog.android.core.internal.persistence.file.datastore +package com.datadog.android.core.internal.persistence.datastore import com.datadog.android.api.InternalLogger import com.datadog.android.api.storage.datastore.DataStoreReadCallback import com.datadog.android.api.storage.datastore.DataStoreWriteCallback import com.datadog.android.core.internal.persistence.Deserializer -import com.datadog.android.core.internal.persistence.datastore.DataStoreFileHandler -import com.datadog.android.core.internal.persistence.datastore.DatastoreFileReader -import com.datadog.android.core.internal.persistence.datastore.DatastoreFileWriter import com.datadog.android.core.persistence.Serializer import com.datadog.android.core.persistence.datastore.DataStoreContent import com.datadog.android.utils.forge.Configurator @@ -170,4 +167,13 @@ internal class DataStoreFileHandlerTest { callback = mockDataStoreWriteCallback ) } + + @Test + fun `M call dataStoreWriter W clearAll()`() { + // When + testedDataStoreHandler.clearAllData() + + // Then + verify(mockDatastoreFileWriter).clearAllData() + } } diff --git a/dd-sdk-android-core/src/test/kotlin/com/datadog/android/core/internal/persistence/datastore/DataStoreFileHelperTest.kt b/dd-sdk-android-core/src/test/kotlin/com/datadog/android/core/internal/persistence/datastore/DataStoreFileHelperTest.kt new file mode 100644 index 0000000000..5d3a911b48 --- /dev/null +++ b/dd-sdk-android-core/src/test/kotlin/com/datadog/android/core/internal/persistence/datastore/DataStoreFileHelperTest.kt @@ -0,0 +1,74 @@ +package com.datadog.android.core.internal.persistence.datastore + +import com.datadog.android.api.InternalLogger +import com.datadog.android.utils.forge.Configurator +import fr.xgouchet.elmyr.annotation.StringForgery +import fr.xgouchet.elmyr.junit5.ForgeConfiguration +import fr.xgouchet.elmyr.junit5.ForgeExtension +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.junit.jupiter.api.extension.Extensions +import org.junit.jupiter.api.io.TempDir +import org.mockito.Mock +import org.mockito.junit.jupiter.MockitoExtension +import org.mockito.junit.jupiter.MockitoSettings +import org.mockito.quality.Strictness +import java.io.File + +@Extensions( + ExtendWith(MockitoExtension::class), + ExtendWith(ForgeExtension::class) +) +@MockitoSettings(strictness = Strictness.LENIENT) +@ForgeConfiguration(Configurator::class) +internal class DataStoreFileHelperTest { + + lateinit var testedHelper: DataStoreFileHelper + + @TempDir + lateinit var tempDir: File + + @StringForgery + lateinit var fakeFeatureName: String + + @Mock + lateinit var mockInternalLogger: InternalLogger + + @BeforeEach + fun `set up`() { + testedHelper = DataStoreFileHelper(mockInternalLogger) + } + + @Test + fun `M return datastore file W getDataStoreFile()`( + @StringForgery fakeKey: String + ) { + // Given + val expectedDataStoreDir = File(tempDir, "datastore_v0") + val expectedFeatureDir = File(expectedDataStoreDir, fakeFeatureName) + val expectedFile = File(expectedFeatureDir, fakeKey) + + // When + val result = testedHelper.getDataStoreFile(tempDir, fakeFeatureName, fakeKey) + + // Then + assertThat(result).isEqualTo(expectedFile) + assertThat(result.parentFile).exists().canWrite() + } + + @Test + fun `M return datastore dir W getDataStoreDirectory()`() { + // Given + val expectedDataStoreDir = File(tempDir, "datastore_v0") + val expectedFeatureDir = File(expectedDataStoreDir, fakeFeatureName) + + // When + val result = testedHelper.getDataStoreDirectory(tempDir, fakeFeatureName) + + // Then + assertThat(result).isEqualTo(expectedFeatureDir) + assertThat(result).exists().canWrite() + } +} diff --git a/dd-sdk-android-core/src/test/kotlin/com/datadog/android/core/internal/persistence/file/datastore/DataStoreFileReaderTest.kt b/dd-sdk-android-core/src/test/kotlin/com/datadog/android/core/internal/persistence/datastore/DataStoreFileReaderTest.kt similarity index 96% rename from dd-sdk-android-core/src/test/kotlin/com/datadog/android/core/internal/persistence/file/datastore/DataStoreFileReaderTest.kt rename to dd-sdk-android-core/src/test/kotlin/com/datadog/android/core/internal/persistence/datastore/DataStoreFileReaderTest.kt index aac558bf7e..0f02d24abd 100644 --- a/dd-sdk-android-core/src/test/kotlin/com/datadog/android/core/internal/persistence/file/datastore/DataStoreFileReaderTest.kt +++ b/dd-sdk-android-core/src/test/kotlin/com/datadog/android/core/internal/persistence/datastore/DataStoreFileReaderTest.kt @@ -4,13 +4,11 @@ * Copyright 2016-Present Datadog, Inc. */ -package com.datadog.android.core.internal.persistence.file.datastore +package com.datadog.android.core.internal.persistence.datastore import com.datadog.android.api.InternalLogger import com.datadog.android.api.storage.datastore.DataStoreReadCallback import com.datadog.android.core.internal.persistence.Deserializer -import com.datadog.android.core.internal.persistence.datastore.DataStoreFileHelper -import com.datadog.android.core.internal.persistence.datastore.DatastoreFileReader import com.datadog.android.core.internal.persistence.datastore.DatastoreFileReader.Companion.INVALID_NUMBER_OF_BLOCKS_ERROR import com.datadog.android.core.internal.persistence.datastore.DatastoreFileReader.Companion.UNEXPECTED_BLOCKS_ORDER_ERROR import com.datadog.android.core.internal.persistence.file.existsSafe @@ -94,8 +92,8 @@ internal class DataStoreFileReaderTest { whenever( mockDataStoreFileHelper.getDataStoreFile( - featureName = eq(fakeFeatureName), storageDir = eq(mockStorageDir), + featureName = eq(fakeFeatureName), key = any() ) ).thenReturn(mockDataStoreFile) @@ -142,7 +140,7 @@ internal class DataStoreFileReaderTest { @Test fun `M log error W read() { invalid number of blocks }`() { // Given - blocksReturned.removeLast() + blocksReturned.removeAt(blocksReturned.lastIndex) val foundBlocks = blocksReturned.size val expectedBlocks = TLVBlockType.values().size @@ -213,7 +211,7 @@ internal class DataStoreFileReaderTest { @Test fun `M return onFailure W read() { invalid number of blocks }`() { // Given - blocksReturned.removeLast() + blocksReturned.removeAt(blocksReturned.lastIndex) val expectedMessage = INVALID_NUMBER_OF_BLOCKS_ERROR.format(Locale.US, blocksReturned.size, TLVBlockType.values().size) val mockCallback = mock>() diff --git a/dd-sdk-android-core/src/test/kotlin/com/datadog/android/core/internal/persistence/file/datastore/DataStoreFileWriterTest.kt b/dd-sdk-android-core/src/test/kotlin/com/datadog/android/core/internal/persistence/datastore/DataStoreFileWriterTest.kt similarity index 96% rename from dd-sdk-android-core/src/test/kotlin/com/datadog/android/core/internal/persistence/file/datastore/DataStoreFileWriterTest.kt rename to dd-sdk-android-core/src/test/kotlin/com/datadog/android/core/internal/persistence/datastore/DataStoreFileWriterTest.kt index 48bab14272..88feeb65ff 100644 --- a/dd-sdk-android-core/src/test/kotlin/com/datadog/android/core/internal/persistence/file/datastore/DataStoreFileWriterTest.kt +++ b/dd-sdk-android-core/src/test/kotlin/com/datadog/android/core/internal/persistence/datastore/DataStoreFileWriterTest.kt @@ -4,12 +4,10 @@ * Copyright 2016-Present Datadog, Inc. */ -package com.datadog.android.core.internal.persistence.file.datastore +package com.datadog.android.core.internal.persistence.datastore import com.datadog.android.api.InternalLogger import com.datadog.android.api.storage.datastore.DataStoreWriteCallback -import com.datadog.android.core.internal.persistence.datastore.DataStoreFileHelper -import com.datadog.android.core.internal.persistence.datastore.DatastoreFileWriter import com.datadog.android.core.internal.persistence.datastore.DatastoreFileWriter.Companion.FAILED_TO_SERIALIZE_DATA_ERROR import com.datadog.android.core.internal.persistence.file.FileReaderWriter import com.datadog.android.core.internal.persistence.file.deleteSafe @@ -87,8 +85,8 @@ internal class DataStoreFileWriterTest { whenever( mockDataStoreFileHelper.getDataStoreFile( - featureName = eq(fakeFeatureName), storageDir = eq(mockStorageDir), + featureName = eq(fakeFeatureName), key = any() ) ).thenReturn(mockDataStoreFile) diff --git a/dd-sdk-android-core/src/test/kotlin/com/datadog/android/core/internal/persistence/file/advanced/ConsentAwareFileOrchestratorTest.kt b/dd-sdk-android-core/src/test/kotlin/com/datadog/android/core/internal/persistence/file/advanced/ConsentAwareFileOrchestratorTest.kt index 37b113fd41..aa3eecaa62 100644 --- a/dd-sdk-android-core/src/test/kotlin/com/datadog/android/core/internal/persistence/file/advanced/ConsentAwareFileOrchestratorTest.kt +++ b/dd-sdk-android-core/src/test/kotlin/com/datadog/android/core/internal/persistence/file/advanced/ConsentAwareFileOrchestratorTest.kt @@ -463,6 +463,42 @@ internal class ConsentAwareFileOrchestratorTest { // endregion + // region getRootDirName + + @Test + fun `M return null W getRootDirName() {initial consent}`( + @Forgery consent: TrackingConsent + ) { + // Given + instantiateTestedOrchestrator(consent) + + // When + val result = testedOrchestrator.getRootDirName() + + // Then + assertThat(result).isNull() + verifyNoInteractions(mockPendingOrchestrator, mockGrantedOrchestrator) + } + + @Test + fun `M return null W getRootDirName() {updated consent}`( + @Forgery initialConsent: TrackingConsent, + @Forgery updatedConsent: TrackingConsent + ) { + // Given + instantiateTestedOrchestrator(initialConsent) + + // When + testedOrchestrator.onConsentUpdated(initialConsent, updatedConsent) + val result = testedOrchestrator.getRootDirName() + + // Then + assertThat(result).isNull() + verifyNoInteractions(mockPendingOrchestrator, mockGrantedOrchestrator) + } + + // endregion + // region getMetadataFile @Test diff --git a/dd-sdk-android-core/src/test/kotlin/com/datadog/android/core/internal/persistence/file/advanced/SingleFileOrchestratorTest.kt b/dd-sdk-android-core/src/test/kotlin/com/datadog/android/core/internal/persistence/file/advanced/SingleFileOrchestratorTest.kt index dbebd9259d..a3330e0912 100644 --- a/dd-sdk-android-core/src/test/kotlin/com/datadog/android/core/internal/persistence/file/advanced/SingleFileOrchestratorTest.kt +++ b/dd-sdk-android-core/src/test/kotlin/com/datadog/android/core/internal/persistence/file/advanced/SingleFileOrchestratorTest.kt @@ -22,6 +22,7 @@ import org.junit.jupiter.api.io.TempDir import org.mockito.Mock import org.mockito.junit.jupiter.MockitoExtension import org.mockito.junit.jupiter.MockitoSettings +import org.mockito.kotlin.mock import org.mockito.quality.Strictness import java.io.File @@ -33,7 +34,7 @@ import java.io.File @ForgeConfiguration(Configurator::class) internal class SingleFileOrchestratorTest { - lateinit var testedOrchestrator: SingleFileOrchestrator + private lateinit var testedOrchestrator: SingleFileOrchestrator @Mock lateinit var mockInternalLogger: InternalLogger @@ -156,6 +157,32 @@ internal class SingleFileOrchestratorTest { // endregion + // region getRootDirName + + @Test + fun `M return file parent dirname W getRootDirName()`() { + // When + val result = testedOrchestrator.getRootDirName() + + // Then + assertThat(result).isEqualTo(fakeParentDirName) + } + + @Test + fun `M return null W getRootDirName() { parent dir is null }`() { + // Given + val fakeInvalidFile: File = mock() + testedOrchestrator = SingleFileOrchestrator(fakeInvalidFile, mockInternalLogger) + + // When + val result = testedOrchestrator.getRootDirName() + + // Then + assertThat(result).isNull() + } + + // endregion + // region getMetadataFile @Test diff --git a/dd-sdk-android-core/src/test/kotlin/com/datadog/android/core/internal/persistence/file/batch/BatchFileOrchestratorTest.kt b/dd-sdk-android-core/src/test/kotlin/com/datadog/android/core/internal/persistence/file/batch/BatchFileOrchestratorTest.kt index 58e09bf10a..3ebcb9656b 100644 --- a/dd-sdk-android-core/src/test/kotlin/com/datadog/android/core/internal/persistence/file/batch/BatchFileOrchestratorTest.kt +++ b/dd-sdk-android-core/src/test/kotlin/com/datadog/android/core/internal/persistence/file/batch/BatchFileOrchestratorTest.kt @@ -1223,6 +1223,19 @@ internal class BatchFileOrchestratorTest { // endregion + // region getRootDirName + + @Test + fun `M return rootDirName W getRootDirName()`() { + // When + val result = testedOrchestrator.getRootDirName() + + // Then + assertThat(result).isEqualTo(fakeRootDir.nameWithoutExtension) + } + + // endregion + // region getMetadataFile @Test diff --git a/dd-sdk-android-core/src/test/kotlin/com/datadog/android/core/internal/utils/ByteArrayExtTest.kt b/dd-sdk-android-core/src/test/kotlin/com/datadog/android/core/internal/utils/ByteArrayExtTest.kt index 2a7533633f..15b5aec3a6 100644 --- a/dd-sdk-android-core/src/test/kotlin/com/datadog/android/core/internal/utils/ByteArrayExtTest.kt +++ b/dd-sdk-android-core/src/test/kotlin/com/datadog/android/core/internal/utils/ByteArrayExtTest.kt @@ -8,7 +8,10 @@ package com.datadog.android.core.internal.utils import com.datadog.android.api.InternalLogger import fr.xgouchet.elmyr.Forge +import fr.xgouchet.elmyr.annotation.IntForgery +import fr.xgouchet.elmyr.annotation.LongForgery import fr.xgouchet.elmyr.annotation.StringForgery +import fr.xgouchet.elmyr.annotation.StringForgeryType import fr.xgouchet.elmyr.junit5.ForgeExtension import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.Test @@ -16,6 +19,8 @@ import org.junit.jupiter.api.extension.ExtendWith import org.junit.jupiter.api.extension.Extensions import org.mockito.Mock import org.mockito.junit.jupiter.MockitoExtension +import org.mockito.kotlin.verifyNoInteractions +import kotlin.math.max @Extensions( ExtendWith(MockitoExtension::class), @@ -26,70 +31,91 @@ internal class ByteArrayExtTest { @Mock lateinit var mockInternalLogger: InternalLogger - // region split + // region split() + @Test - fun `splits a byteArray with 0 separator`(forge: Forge) { - val separationChar = forge.anAlphabeticalChar() - val rawString = forge.aNumericalString() + fun `M splits a byteArray W split() {0 separator}`( + @StringForgery(StringForgeryType.ALPHABETICAL, size = 1) separator: String, + @StringForgery(StringForgeryType.NUMERICAL) rawString: String + ) { + // Given val byteArray = rawString.toByteArray(Charsets.UTF_8) - val subs = byteArray.split(separationChar.code.toByte(), mockInternalLogger) + // When + val subs = byteArray.split(separator.first().code.toByte(), mockInternalLogger) + // Then assertThat(subs).hasSize(1) assertThat(subs[0]).isEqualTo(byteArray) } @Test - fun `splits a byteArray with 1 separator`(forge: Forge) { - val separationChar = forge.anAlphabeticalChar() - val part0 = forge.aNumericalString() - val part1 = forge.aNumericalString() - val rawString = part0 + separationChar + part1 + fun `M splits a byteArray W split() {1 separator}`( + @StringForgery(StringForgeryType.ALPHABETICAL, size = 1) separator: String, + @StringForgery(StringForgeryType.NUMERICAL) part0: String, + @StringForgery(StringForgeryType.NUMERICAL) part1: String + ) { + // Given + val rawString = part0 + separator + part1 val byteArray = rawString.toByteArray(Charsets.UTF_8) - val subs = byteArray.split(separationChar.code.toByte(), mockInternalLogger) + // When + val subs = byteArray.split(separator.first().code.toByte(), mockInternalLogger) + // Then assertThat(subs).hasSize(2) assertThat(String(subs[0])).isEqualTo(part0) assertThat(String(subs[1])).isEqualTo(part1) } @Test - fun `splits a byteArray with trailing separator`(forge: Forge) { - val separationChar = forge.anAlphabeticalChar() - val part0 = forge.aNumericalString() - val rawString = part0 + separationChar + fun `M splits a byteArray W split() {trailing separator}`( + @StringForgery(StringForgeryType.ALPHABETICAL, size = 1) separator: String, + @StringForgery(StringForgeryType.NUMERICAL) part0: String + ) { + // Given + val rawString = part0 + separator val byteArray = rawString.toByteArray(Charsets.UTF_8) - val subs = byteArray.split(separationChar.code.toByte(), mockInternalLogger) + // When + val subs = byteArray.split(separator.first().code.toByte(), mockInternalLogger) + // Then assertThat(subs).hasSize(1) assertThat(String(subs[0])).isEqualTo(part0) } @Test - fun `splits a byteArray with leading separator`(forge: Forge) { - val separationChar = forge.anAlphabeticalChar() - val part0 = forge.aNumericalString() - val rawString = separationChar + part0 + fun `M splits a byteArray W split() {leading separator}`( + @StringForgery(StringForgeryType.ALPHABETICAL, size = 1) separator: String, + @StringForgery(StringForgeryType.NUMERICAL) part0: String + ) { + // Given + val rawString = separator + part0 val byteArray = rawString.toByteArray(Charsets.UTF_8) - val subs = byteArray.split(separationChar.code.toByte(), mockInternalLogger) + // When + val subs = byteArray.split(separator.first().code.toByte(), mockInternalLogger) + // Then assertThat(subs).hasSize(1) assertThat(String(subs[0])).isEqualTo(part0) } @Test - fun `splits a byteArray with consecutive separators`(forge: Forge) { - val separationChar = forge.anAlphabeticalChar() - val part0 = forge.aNumericalString() - val part1 = forge.aNumericalString() - val rawString = part0 + separationChar + separationChar + part1 + fun `M splits a byteArray W split() {consecutive separators}`( + @StringForgery(StringForgeryType.ALPHABETICAL, size = 1) separator: String, + @StringForgery(StringForgeryType.NUMERICAL) part0: String, + @StringForgery(StringForgeryType.NUMERICAL) part1: String + ) { + // Given + val rawString = part0 + separator + separator + part1 val byteArray = rawString.toByteArray(Charsets.UTF_8) - val subs = byteArray.split(separationChar.code.toByte(), mockInternalLogger) + // When + val subs = byteArray.split(separator.first().code.toByte(), mockInternalLogger) + // Then assertThat(subs).hasSize(2) assertThat(String(subs[0])).isEqualTo(part0) assertThat(String(subs[1])).isEqualTo(part1) @@ -97,56 +123,94 @@ internal class ByteArrayExtTest { // endregion - // region indexOf + // region indexOf() @Test - fun `returns -1 when byte not found`(forge: Forge) { - val rawString = forge.aNumericalString() + fun `M returns -1 W indexOf() {invalid start}`( + @StringForgery(StringForgeryType.ALPHABETICAL, size = 1) searchedChar: String, + @StringForgery(StringForgeryType.ALPHABETICAL) rawString: String + ) { + // Given val byteArray = rawString.toByteArray(Charsets.UTF_8) - val index = byteArray.indexOf(forge.anAlphabeticalChar().code.toByte(), 0) + // When + val index = byteArray.indexOf(searchedChar.first().code.toByte(), -1) + // Then assertThat(index).isEqualTo(-1) } @Test - fun `finds index of byte`(forge: Forge) { - val rawString = forge.anAsciiString() - val char = rawString[forge.anInt(0, rawString.length)] - val expectedIndex = rawString.indexOf(char) + fun `M returns -1 W indexOf() {byte not found}`( + @StringForgery(StringForgeryType.ALPHABETICAL, size = 1) searchedChar: String, + @StringForgery(StringForgeryType.NUMERICAL) rawString: String + ) { + // Given + val byteArray = rawString.toByteArray(Charsets.UTF_8) + + // When + val index = byteArray.indexOf(searchedChar.first().code.toByte(), 0) + + // Then + assertThat(index).isEqualTo(-1) + } + @Test + fun `M return index W indexOf() {byte found}`( + @StringForgery(StringForgeryType.ALPHABETICAL, size = 1) searchedChar: String, + @StringForgery(StringForgeryType.NUMERICAL) part0: String, + @StringForgery(StringForgeryType.NUMERICAL) part1: String + ) { + // Given + val rawString = part0 + searchedChar + part1 val byteArray = rawString.toByteArray(Charsets.UTF_8) - val index = byteArray.indexOf(char.code.toByte(), 0) + val expectedIndex = part0.toByteArray(Charsets.UTF_8).size + // When + val index = byteArray.indexOf(searchedChar.first().code.toByte(), 0) + + // Then assertThat(index).isEqualTo(expectedIndex) } @Test - fun `finds all indexes of byte`(forge: Forge) { - val rawString = forge.aNumericalString(64) - val char = rawString[forge.anInt(0, rawString.length)] + fun `M find all indexes W indexOf()`( + @StringForgery(StringForgeryType.ALPHABETICAL, size = 1) searchedChar: String, + @StringForgery(StringForgeryType.NUMERICAL) parts: List + ) { + // Given + val rawString = parts.joinToString(searchedChar) val expectedIndexes = mutableListOf() - var nextExpectedIndex = rawString.indexOf(char, 0) - while (nextExpectedIndex != -1) { - expectedIndexes.add(nextExpectedIndex) - nextExpectedIndex = rawString.indexOf(char, nextExpectedIndex + 1) + var prevExpectedIndex = 0 + parts.forEachIndexed { index, s -> + if (index > 0) { + expectedIndexes.add(prevExpectedIndex) + prevExpectedIndex++ + } + prevExpectedIndex += s.toByteArray().size } - val byteArray = rawString.toByteArray(Charsets.UTF_8) + + // When val foundIndexes = mutableListOf() - var nextIndex = byteArray.indexOf(char.code.toByte(), 0) - while (nextIndex != -1) { - foundIndexes.add(nextIndex) - nextIndex = byteArray.indexOf(char.code.toByte(), nextIndex + 1) - } + var prevFoundIndex = 0 + do { + val index = byteArray.indexOf(searchedChar.first().code.toByte(), prevFoundIndex) + if (index >= 0) { + foundIndexes.add(index) + prevFoundIndex = index + 1 + } else { + prevFoundIndex = -1 + } + } while (prevFoundIndex >= 0) - assertThat(foundIndexes) - .containsAll(expectedIndexes) + // Then + assertThat(foundIndexes).containsAll(expectedIndexes) } // endregion - // region join + // region join() @Test fun `M join items W join() { no prefix }`( @@ -233,13 +297,10 @@ internal class ByteArrayExtTest { fun `M join items W join() { empty separator }`( @StringForgery prefix: String, @StringForgery suffix: String, - forge: Forge + @StringForgery data: List ) { // Given - val dataBytes = forge.aList { - forge.aString().toByteArray() - } - + val dataBytes = data.map { it.toByteArray() } val prefixBytes = prefix.toByteArray() val suffixBytes = suffix.toByteArray() @@ -264,13 +325,10 @@ internal class ByteArrayExtTest { @StringForgery separator: String, @StringForgery prefix: String, @StringForgery suffix: String, - forge: Forge + @StringForgery data: List ) { // Given - val dataBytes = forge.aList { - forge.aString().toByteArray() - } - + val dataBytes = data.map { it.toByteArray() } val separatorBytes = separator.toByteArray() val prefixBytes = prefix.toByteArray() val suffixBytes = suffix.toByteArray() @@ -325,15 +383,13 @@ internal class ByteArrayExtTest { @StringForgery separator: String, @StringForgery prefix: String, @StringForgery suffix: String, - forge: Forge + @StringForgery data: String ) { // Given - val dataBytes = listOf(forge.aString().toByteArray()) - + val dataBytes = listOf(data.toByteArray()) val separatorBytes = separator.toByteArray() val prefixBytes = prefix.toByteArray() val suffixBytes = suffix.toByteArray() - val expected = prefixBytes + dataBytes[0] + suffixBytes // When @@ -365,4 +421,203 @@ internal class ByteArrayExtTest { } // endregion + + // region copyTo() + + @Test + fun `M copy data W copyTo() {copy entire content}`( + @StringForgery(StringForgeryType.NUMERICAL) rawString: String + ) { + // Given + val byteArray = rawString.toByteArray(Charsets.UTF_8) + val destination = ByteArray(byteArray.size) + + // When + val result = byteArray.copyTo(0, destination, 0, byteArray.size, mockInternalLogger) + + // Then + assertThat(result).isTrue() + assertThat(byteArray).isEqualTo(destination) + verifyNoInteractions(mockInternalLogger) + } + + @Test + fun `M copy data W copyTo() {copy partial content}`( + @StringForgery(StringForgeryType.NUMERICAL, size = 64) rawString: String, + @IntForgery(min = 0, max = 32) startIndex: Int, + @IntForgery(min = 33, max = 64) endIndex: Int + ) { + // Given + val byteArray = rawString.toByteArray(Charsets.UTF_8) + val copySize = endIndex - startIndex + val destination = ByteArray(copySize) + + // When + val result = byteArray.copyTo(startIndex, destination, 0, copySize, mockInternalLogger) + + // Then + assertThat(result).isTrue() + for (i in 0 until copySize) { + assertThat(byteArray[startIndex + i]).isEqualTo(destination[i]) + } + verifyNoInteractions(mockInternalLogger) + } + + @Test + fun `M return false W copyTo() {invalid source size}`( + @StringForgery(StringForgeryType.NUMERICAL) rawString: String, + @IntForgery(min = 1, max = 128) overflow: Int + ) { + // Given + val byteArray = rawString.toByteArray(Charsets.UTF_8) + val destination = ByteArray(byteArray.size + overflow + 1) + + // When + val result = byteArray.copyTo(0, destination, 0, byteArray.size + overflow, mockInternalLogger) + + // Then + assertThat(result).isFalse() + } + + @Test + fun `M return false W copyTo() {invalid destination size}`( + @StringForgery(StringForgeryType.NUMERICAL) rawString: String, + @IntForgery(min = 1, max = 128) underflow: Int + ) { + // Given + val byteArray = rawString.toByteArray(Charsets.UTF_8) + val destination = ByteArray(max(byteArray.size - underflow, 0)) + + // When + val result = byteArray.copyTo(0, destination, 0, byteArray.size, mockInternalLogger) + + // Then + assertThat(result).isFalse() + } + + // endregion + + // region data I/O + + @Test + fun `M retrieve a short stored in a byte array W toByteArray() + toShort()`( + @IntForgery(min = 0, max = Short.MAX_VALUE.toInt()) i: Int + ) { + // Given + val s = i.toShort() + val byteArray = s.toByteArray() + + // When + val result = byteArray.toShort() + + // Then + assertThat(result).isEqualTo(s) + } + + @Test + fun `M retrieve an int stored in a byte array W toByteArray() + toInt()`( + @IntForgery i: Int + ) { + // Given + val byteArray = i.toByteArray() + + // When + val result = byteArray.toInt() + + // Then + assertThat(result).isEqualTo(i) + } + + @Test + fun `M retrieve a long stored in a byte array W toByteArray() + toLong()`( + @LongForgery l: Long + ) { + // Given + val byteArray = l.toByteArray() + + // When + val result = byteArray.toLong() + + // Then + assertThat(result).isEqualTo(l) + } + + @Test + fun `M return whole byte array W copyOfRangeSafe()`( + @StringForgery(size = 32) data: String + ) { + // Given + val byteArray = data.toByteArray() + + // When + val result = byteArray.copyOfRangeSafe(0, byteArray.size) + + // Then + assertThat(result).isEqualTo(byteArray) + } + + @Test + fun `M return subset byte array W copyOfRangeSafe()`( + @StringForgery(size = 32) prefix: String, + @StringForgery(size = 32) data: String, + @StringForgery(size = 32) postfix: String + ) { + // Given + val prefixByteArray = prefix.toByteArray() + val dataByteArray = data.toByteArray() + val postfixByteArray = postfix.toByteArray() + val byteArray = prefixByteArray + dataByteArray + postfixByteArray + + // When + val result = byteArray.copyOfRangeSafe(prefixByteArray.size, prefixByteArray.size + dataByteArray.size) + + // Then + assertThat(result).isEqualTo(dataByteArray) + } + + @Test + fun `M return an empty byte array W copyOfRangeSafe() { negative index }`( + @StringForgery(size = 32) data: String, + @IntForgery(min = -512, max = 0) negativeIndex: Int + ) { + // Given + val byteArray = data.toByteArray() + + // When + val result = byteArray.copyOfRangeSafe(negativeIndex, 1) + + // Then + assertThat(result).isEmpty() + } + + @Test + fun `M return an empty byte array W copyOfRangeSafe() { out of bounds index }`( + @StringForgery(size = 32) data: String, + @IntForgery(min = 1, max = 512) positiveIndex: Int + ) { + // Given + val byteArray = data.toByteArray() + + // When + val result = byteArray.copyOfRangeSafe(0, byteArray.size + positiveIndex) + + // Then + assertThat(result).isEmpty() + } + + @Test + fun `M return an empty byte array W copyOfRangeSafe() { illegal index }`( + @StringForgery(size = 32) data: String + ) { + // Given + val byteArray = data.toByteArray() + + // When + val result = byteArray.copyOfRangeSafe(byteArray.lastIndex, 0) + + // Then + assertThat(result).isEmpty() + } + + // endregion } diff --git a/dd-sdk-android-core/src/test/kotlin/com/datadog/android/core/internal/utils/MiscUtilsTest.kt b/dd-sdk-android-core/src/test/kotlin/com/datadog/android/core/internal/utils/MiscUtilsTest.kt index 6521d56e5d..e05d1b0b1d 100644 --- a/dd-sdk-android-core/src/test/kotlin/com/datadog/android/core/internal/utils/MiscUtilsTest.kt +++ b/dd-sdk-android-core/src/test/kotlin/com/datadog/android/core/internal/utils/MiscUtilsTest.kt @@ -17,7 +17,16 @@ import com.google.gson.JsonArray import com.google.gson.JsonElement import com.google.gson.JsonNull import com.google.gson.JsonObject +import com.google.gson.JsonPrimitive import fr.xgouchet.elmyr.Forge +import fr.xgouchet.elmyr.annotation.AdvancedForgery +import fr.xgouchet.elmyr.annotation.BoolForgery +import fr.xgouchet.elmyr.annotation.FloatForgery +import fr.xgouchet.elmyr.annotation.Forgery +import fr.xgouchet.elmyr.annotation.IntForgery +import fr.xgouchet.elmyr.annotation.MapForgery +import fr.xgouchet.elmyr.annotation.StringForgery +import fr.xgouchet.elmyr.annotation.StringForgeryType import fr.xgouchet.elmyr.junit5.ForgeConfiguration import fr.xgouchet.elmyr.junit5.ForgeExtension import org.assertj.core.api.Assertions.assertThat @@ -52,36 +61,34 @@ internal class MiscUtilsTest { @Mock lateinit var mockInternalLogger: InternalLogger - // region UnitTests - @Test fun `M repeat max N times W retryWithDelay { success = false }`(forge: Forge) { - // GIVEN + // Given val fakeTimes = forge.anInt(min = 1, max = 10) val fakeDelay = TimeUnit.SECONDS.toNanos(forge.aLong(min = 0, max = 2)) val mockedBlock: () -> Boolean = mock() whenever(mockedBlock.invoke()).thenReturn(false) - // WHEN + // When val wasSuccessful = retryWithDelay(mockedBlock, fakeTimes, fakeDelay, mockInternalLogger) - // THEN + // Then assertThat(wasSuccessful).isFalse() verify(mockedBlock, times(fakeTimes)).invoke() } @Test fun `M execute the block in a delayed loop W retryWithDelay`(forge: Forge) { - // GIVEN + // Given val fakeTimes = forge.anInt(min = 1, max = 4) val fakeDelay = TimeUnit.SECONDS.toNanos(forge.aLong(min = 0, max = 2)) val mockedBlock: () -> Boolean = mock() whenever(mockedBlock.invoke()).thenReturn(false) - // WHEN + // When val executionTime = measureNanoTime { retryWithDelay(mockedBlock, fakeTimes, fakeDelay, mockInternalLogger) } - // THEN + // Then assertThat(executionTime).isCloseTo( fakeTimes * fakeDelay, Offset.offset(TimeUnit.SECONDS.toNanos(1)) @@ -90,40 +97,58 @@ internal class MiscUtilsTest { @Test fun `M do nothing W retryWithDelay { times less or equal than 0 }`(forge: Forge) { - // GIVEN + // Given val fakeDelay = TimeUnit.SECONDS.toNanos(forge.aLong(min = 0, max = 2)) val mockedBlock: () -> Boolean = mock() - // WHEN + // When retryWithDelay(mockedBlock, forge.anInt(Int.MIN_VALUE, 1), fakeDelay, mockInternalLogger) - // THEN + // Then verifyNoInteractions(mockedBlock) } @Test - fun `M repeat until success W retryWithDelay`(forge: Forge) { - // GIVEN + fun `M repeat until success W retryWithDelay { result false }`(forge: Forge) { + // Given val fakeDelay = TimeUnit.SECONDS.toNanos(forge.aLong(min = 0, max = 2)) val mockedBlock: () -> Boolean = mock() whenever(mockedBlock.invoke()).thenReturn(false).thenReturn(true) - // WHEN + // When + val wasSuccessful = retryWithDelay(mockedBlock, 3, fakeDelay, mockInternalLogger) + + // Then + assertThat(wasSuccessful).isTrue() + verify(mockedBlock, times(2)).invoke() + } + + @Test + fun `M repeat until success W retryWithDelay { exception }`( + @Forgery exception: Exception, + forge: Forge + ) { + // Given + val fakeDelay = TimeUnit.SECONDS.toNanos(forge.aLong(min = 0, max = 2)) + val mockedBlock: () -> Boolean = mock() + whenever(mockedBlock.invoke()).thenThrow(exception).thenReturn(true) + + // When val wasSuccessful = retryWithDelay(mockedBlock, 3, fakeDelay, mockInternalLogger) - // THEN + // Then assertThat(wasSuccessful).isTrue() verify(mockedBlock, times(2)).invoke() } @Test fun `M provide the relevant JsonElement W toJsonElement { on Kotlin object }`(forge: Forge) { - // GIVEN + // Given val attributes = forge.exhaustiveAttributes().toMutableMap() attributes[forge.aString()] = NULL_MAP_VALUE attributes[forge.aString()] = JsonNull.INSTANCE - // WHEN + // When attributes.forEach { // be careful here, we shouldn't pass `it`, because it has Map.Entry type, so will fall // always into `else` branch of underlying assertion @@ -134,7 +159,7 @@ internal class MiscUtilsTest { @Test fun `M map values to JSON without throwing W safeMapValuesToJson()`(forge: Forge) { - // GIVEN + // Given val attributes = forge.exhaustiveAttributes().toMutableMap() val fakeException = forge.anException() val faultyKey = forge.anAlphabeticalString() @@ -145,11 +170,11 @@ internal class MiscUtilsTest { } val mockInternalLogger = mock() - // WHEN + // When val mapped = attributes.apply { this += faultyKey to faultyItem } .safeMapValuesToJson(mockInternalLogger) - // THEN + // Then assertThat(mapped).hasSize(attributes.size - 1) assertThat(mapped.values).doesNotContainNull() assertThat(mapped).doesNotContainKey(faultyKey) @@ -163,7 +188,93 @@ internal class MiscUtilsTest { ) } - // endregion + @Test + fun `M return null W fromJsonElement() {JsonNull}`() { + // Given + val json = JsonNull.INSTANCE + + // When + val mapped = json.fromJsonElement() + + // Then + assertThat(mapped).isNull() + } + + @Test + fun `M return value W fromJsonElement() {JsonPrimitive boolean}`( + @BoolForgery value: Boolean + ) { + // Given + val json = JsonPrimitive(value) + + // When + val mapped = json.fromJsonElement() + + // Then + assertThat(mapped).isEqualTo(value) + } + + @Test + fun `M return value W fromJsonElement() {JsonPrimitive int}`( + @IntForgery value: Int + ) { + // Given + val json = JsonPrimitive(value) + + // When + val mapped = json.fromJsonElement() + + // Then + assertThat(mapped).isEqualTo(value) + } + + @Test + fun `M return value W fromJsonElement() {JsonPrimitive float}`( + @FloatForgery value: Float + ) { + // Given + val json = JsonPrimitive(value) + + // When + val mapped = json.fromJsonElement() + + // Then + assertThat(mapped).isEqualTo(value) + } + + @Test + fun `M return value W fromJsonElement() {JsonPrimitive String}`( + @StringForgery value: String + ) { + // Given + val json = JsonPrimitive(value) + + // When + val mapped = json.fromJsonElement() + + // Then + assertThat(mapped).isEqualTo(value) + } + + @Test + fun `M return value W fromJsonElement() {Json Object}`( + @MapForgery( + key = AdvancedForgery(string = [StringForgery(StringForgeryType.ALPHABETICAL)]), + value = AdvancedForgery(string = [StringForgery()]) + ) value: Map + ) { + // Given + val json = JsonObject() + value.forEach { (k, v) -> + json.addProperty(k, v) + } + + // When + val mapped = json.fromJsonElement() + + // Then + assertThat(mapped).isEqualTo(value) + } // region Internal @@ -184,6 +295,7 @@ internal class MiscUtilsTest { is Iterable<*> -> assertThat(jsonElement.asJsonArray).containsExactlyElementsOf( kotlinObject.map { JsonSerializer.toJsonElement(it) } ) + is Map<*, *> -> assertThat(jsonElement.asJsonObject).satisfies { assertThat(kotlinObject.keys.map { key -> key.toString() }) .containsExactlyElementsOf(it.keySet()) @@ -191,10 +303,13 @@ internal class MiscUtilsTest { assertJsonElement(entry.value, it[entry.key.toString()]) } } + is JSONArray -> assertThat(jsonElement.asJsonArray.toString()) .isEqualTo(kotlinObject.toString()) + is JSONObject -> assertThat(jsonElement.asJsonObject.toString()) .isEqualTo(kotlinObject.toString()) + else -> assertThat(jsonElement.asString).isEqualTo(kotlinObject.toString()) } } diff --git a/dd-sdk-android-core/src/test/kotlin/com/datadog/android/core/internal/utils/ThreadExtTest.kt b/dd-sdk-android-core/src/test/kotlin/com/datadog/android/core/internal/utils/ThreadExtTest.kt index 1593af0b05..f6b75a6e6d 100644 --- a/dd-sdk-android-core/src/test/kotlin/com/datadog/android/core/internal/utils/ThreadExtTest.kt +++ b/dd-sdk-android-core/src/test/kotlin/com/datadog/android/core/internal/utils/ThreadExtTest.kt @@ -11,6 +11,7 @@ import fr.xgouchet.elmyr.annotation.Forgery import fr.xgouchet.elmyr.junit5.ForgeConfiguration import fr.xgouchet.elmyr.junit5.ForgeExtension import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.RepeatedTest import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith import org.junit.jupiter.api.extension.Extensions @@ -23,7 +24,7 @@ import org.mockito.junit.jupiter.MockitoExtension @ForgeConfiguration(Configurator::class) internal class ThreadExtTest { - @Test + @RepeatedTest(16) fun `M return name W Thread#State#asString()`( @Forgery state: Thread.State ) { diff --git a/dd-sdk-android-core/src/test/kotlin/com/datadog/android/core/internal/utils/WorkManagerUtilsTest.kt b/dd-sdk-android-core/src/test/kotlin/com/datadog/android/core/internal/utils/WorkManagerUtilsTest.kt index 05c01364fd..a9c43c2aef 100644 --- a/dd-sdk-android-core/src/test/kotlin/com/datadog/android/core/internal/utils/WorkManagerUtilsTest.kt +++ b/dd-sdk-android-core/src/test/kotlin/com/datadog/android/core/internal/utils/WorkManagerUtilsTest.kt @@ -8,7 +8,6 @@ package com.datadog.android.core.internal.utils import android.app.Application import androidx.work.ExistingWorkPolicy -import androidx.work.NetworkType import androidx.work.OneTimeWorkRequest import androidx.work.impl.WorkManagerImpl import com.datadog.android.api.InternalLogger @@ -20,8 +19,10 @@ import com.datadog.tools.unit.annotations.TestConfigurationsProvider import com.datadog.tools.unit.extensions.TestConfigurationExtension import com.datadog.tools.unit.extensions.config.TestConfiguration import com.datadog.tools.unit.setStaticValue +import fr.xgouchet.elmyr.annotation.StringForgery import fr.xgouchet.elmyr.junit5.ForgeConfiguration import fr.xgouchet.elmyr.junit5.ForgeExtension +import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test @@ -32,7 +33,7 @@ import org.mockito.Mock import org.mockito.junit.jupiter.MockitoExtension import org.mockito.junit.jupiter.MockitoSettings import org.mockito.kotlin.any -import org.mockito.kotlin.argThat +import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.doReturn import org.mockito.kotlin.eq import org.mockito.kotlin.mock @@ -56,6 +57,9 @@ internal class WorkManagerUtilsTest { @Mock lateinit var mockInternalLogger: InternalLogger + @StringForgery + lateinit var fakeInstanceName: String + @BeforeEach fun `set up`() { CoreFeature.disableKronosBackgroundSync = true @@ -81,16 +85,16 @@ internal class WorkManagerUtilsTest { WorkManagerImpl::class.java.setStaticValue("sDefaultInstance", mockWorkManager) // When - cancelUploadWorker(appContext.mockInstance, mockInternalLogger) + cancelUploadWorker(appContext.mockInstance, fakeInstanceName, mockInternalLogger) // Then - verify(mockWorkManager).cancelAllWorkByTag(eq(TAG_DATADOG_UPLOAD)) + verify(mockWorkManager).cancelAllWorkByTag("$TAG_DATADOG_UPLOAD/$fakeInstanceName") } @Test fun `it will handle the cancel exception if WorkManager was not correctly instantiated`() { // When - cancelUploadWorker(appContext.mockInstance, mockInternalLogger) + cancelUploadWorker(appContext.mockInstance, fakeInstanceName, mockInternalLogger) // Then verifyNoInteractions(mockWorkManager) @@ -102,24 +106,26 @@ internal class WorkManagerUtilsTest { WorkManagerImpl::class.java.setStaticValue("sDefaultInstance", mockWorkManager) // When - triggerUploadWorker(appContext.mockInstance, mockInternalLogger) + triggerUploadWorker(appContext.mockInstance, fakeInstanceName, mockInternalLogger) // Then - verify(mockWorkManager).enqueueUniqueWork( - eq(UPLOAD_WORKER_NAME), - eq(ExistingWorkPolicy.REPLACE), - argThat { - this.workSpec.workerClassName == UploadWorker::class.java.canonicalName && - this.tags.contains(TAG_DATADOG_UPLOAD) && - this.workSpec.constraints.requiredNetworkType == NetworkType.NOT_ROAMING - } - ) + argumentCaptor { + verify(mockWorkManager).enqueueUniqueWork( + eq(UPLOAD_WORKER_NAME), + eq(ExistingWorkPolicy.REPLACE), + capture() + ) + val workSpec = lastValue.workSpec + assertThat(workSpec.workerClassName).isEqualTo(UploadWorker::class.java.canonicalName) + assertThat(workSpec.input.getString(UploadWorker.DATADOG_INSTANCE_NAME)).isEqualTo(fakeInstanceName) + assertThat(lastValue.tags).contains("$TAG_DATADOG_UPLOAD/$fakeInstanceName") + } } @Test fun `it will handle the trigger exception if WorkManager was not correctly instantiated`() { // When - triggerUploadWorker(appContext.mockInstance, mockInternalLogger) + triggerUploadWorker(appContext.mockInstance, fakeInstanceName, mockInternalLogger) // Then verifyNoInteractions(mockWorkManager) diff --git a/dd-sdk-android-core/src/test/kotlin/com/datadog/android/error/internal/DatadogExceptionHandlerTest.kt b/dd-sdk-android-core/src/test/kotlin/com/datadog/android/error/internal/DatadogExceptionHandlerTest.kt index e5fecf2830..04389d028f 100644 --- a/dd-sdk-android-core/src/test/kotlin/com/datadog/android/error/internal/DatadogExceptionHandlerTest.kt +++ b/dd-sdk-android-core/src/test/kotlin/com/datadog/android/error/internal/DatadogExceptionHandlerTest.kt @@ -22,6 +22,7 @@ import com.datadog.android.core.internal.thread.waitToIdle import com.datadog.android.core.internal.utils.TAG_DATADOG_UPLOAD import com.datadog.android.core.internal.utils.UPLOAD_WORKER_NAME import com.datadog.android.core.internal.utils.loggableStackTrace +import com.datadog.android.internal.utils.loggableStackTrace import com.datadog.android.utils.config.ApplicationContextTestConfiguration import com.datadog.android.utils.forge.Configurator import com.datadog.android.utils.verifyLog @@ -31,6 +32,7 @@ import com.datadog.tools.unit.extensions.config.TestConfiguration import com.datadog.tools.unit.setStaticValue import fr.xgouchet.elmyr.Forge import fr.xgouchet.elmyr.annotation.Forgery +import fr.xgouchet.elmyr.annotation.StringForgery import fr.xgouchet.elmyr.junit5.ForgeConfiguration import fr.xgouchet.elmyr.junit5.ForgeExtension import org.assertj.core.api.Assertions.assertThat @@ -46,7 +48,6 @@ import org.mockito.Mock import org.mockito.junit.jupiter.MockitoExtension import org.mockito.junit.jupiter.MockitoSettings import org.mockito.kotlin.any -import org.mockito.kotlin.argThat import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.doReturn import org.mockito.kotlin.eq @@ -96,11 +97,15 @@ internal class DatadogExceptionHandlerTest { @Forgery lateinit var fakeThrowable: Throwable + @StringForgery + lateinit var fakeInstanceName: String + @BeforeEach fun `set up`() { whenever(mockSdkCore.getFeature(Feature.LOGS_FEATURE_NAME)) doReturn mockLogsFeatureScope whenever(mockSdkCore.getFeature(Feature.RUM_FEATURE_NAME)) doReturn mockRumFeatureScope whenever(mockSdkCore.internalLogger) doReturn mockInternalLogger + whenever(mockSdkCore.name) doReturn fakeInstanceName CoreFeature.disableKronosBackgroundSync = true @@ -448,15 +453,17 @@ internal class DatadogExceptionHandlerTest { testedHandler.uncaughtException(currentThread, fakeThrowable) // Then - verify(mockWorkManager) - .enqueueUniqueWork( + argumentCaptor { + verify(mockWorkManager).enqueueUniqueWork( eq(UPLOAD_WORKER_NAME), eq(ExistingWorkPolicy.REPLACE), - argThat { - this.workSpec.workerClassName == UploadWorker::class.java.canonicalName && - this.tags.contains(TAG_DATADOG_UPLOAD) - } + capture() ) + val workSpec = lastValue.workSpec + assertThat(workSpec.workerClassName).isEqualTo(UploadWorker::class.java.canonicalName) + assertThat(workSpec.input.getString(UploadWorker.DATADOG_INSTANCE_NAME)).isEqualTo(fakeInstanceName) + assertThat(lastValue.tags).contains("$TAG_DATADOG_UPLOAD/$fakeInstanceName") + } } // region Forward to RUM diff --git a/dd-sdk-android-core/src/test/kotlin/com/datadog/android/utils/forge/BatchIdForgeryFactory.kt b/dd-sdk-android-core/src/test/kotlin/com/datadog/android/utils/forge/BatchIdForgeryFactory.kt new file mode 100644 index 0000000000..f8ad80d1b1 --- /dev/null +++ b/dd-sdk-android-core/src/test/kotlin/com/datadog/android/utils/forge/BatchIdForgeryFactory.kt @@ -0,0 +1,17 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.datadog.android.utils.forge + +import com.datadog.android.core.internal.persistence.BatchId +import fr.xgouchet.elmyr.Forge +import fr.xgouchet.elmyr.ForgeryFactory + +internal class BatchIdForgeryFactory : ForgeryFactory { + override fun getForgery(forge: Forge): BatchId { + return BatchId(id = forge.anAlphaNumericalString()) + } +} diff --git a/dd-sdk-android-core/src/test/kotlin/com/datadog/android/utils/forge/Configurator.kt b/dd-sdk-android-core/src/test/kotlin/com/datadog/android/utils/forge/Configurator.kt index eea665baff..b0c721c569 100644 --- a/dd-sdk-android-core/src/test/kotlin/com/datadog/android/utils/forge/Configurator.kt +++ b/dd-sdk-android-core/src/test/kotlin/com/datadog/android/utils/forge/Configurator.kt @@ -6,6 +6,7 @@ package com.datadog.android.utils.forge +import com.datadog.android.internal.tests.elmyr.InternalTelemetryApiUsageForgeryFactory import com.datadog.android.test.elmyr.PersistenceStrategyBatchForgeryFactory import com.datadog.android.tests.elmyr.useCoreFactories import com.datadog.tools.unit.forge.BaseConfigurator @@ -27,6 +28,7 @@ internal class Configurator : forge.addFactory(AndroidInfoProviderForgeryFactory()) forge.addFactory(FeatureStorageConfigurationForgeryFactory()) forge.addFactory(BatchDataForgeryFactory()) + forge.addFactory(BatchIdForgeryFactory()) // IO forge.addFactory(BatchForgeryFactory()) @@ -70,5 +72,8 @@ internal class Configurator : forge.addFactory(PersistenceStrategyBatchForgeryFactory()) forge.useJvmFactories() + + // telemetry + forge.addFactory(InternalTelemetryApiUsageForgeryFactory()) } } diff --git a/dd-sdk-android-core/src/testFixtures/kotlin/com/datadog/android/tests/elmyr/ForgeExt.kt b/dd-sdk-android-core/src/testFixtures/kotlin/com/datadog/android/tests/elmyr/ForgeExt.kt index 4dd934ec79..5d2e43d4ee 100644 --- a/dd-sdk-android-core/src/testFixtures/kotlin/com/datadog/android/tests/elmyr/ForgeExt.kt +++ b/dd-sdk-android-core/src/testFixtures/kotlin/com/datadog/android/tests/elmyr/ForgeExt.kt @@ -48,6 +48,7 @@ fun T.useCoreFactories(): T { addFactory(UserInfoForgeryFactory()) addFactory(RawBatchEventForgeryFactory()) addFactory(ThreadDumpForgeryFactory()) + addFactory(RequestExecutionContextForgeryFactory()) return this } diff --git a/dd-sdk-android-core/src/testFixtures/kotlin/com/datadog/android/tests/elmyr/RequestExecutionContextForgeryFactory.kt b/dd-sdk-android-core/src/testFixtures/kotlin/com/datadog/android/tests/elmyr/RequestExecutionContextForgeryFactory.kt new file mode 100644 index 0000000000..57a15a89a1 --- /dev/null +++ b/dd-sdk-android-core/src/testFixtures/kotlin/com/datadog/android/tests/elmyr/RequestExecutionContextForgeryFactory.kt @@ -0,0 +1,21 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.datadog.android.tests.elmyr + +import com.datadog.android.api.net.RequestExecutionContext +import fr.xgouchet.elmyr.Forge +import fr.xgouchet.elmyr.ForgeryFactory + +class RequestExecutionContextForgeryFactory : ForgeryFactory { + + override fun getForgery(forge: Forge): RequestExecutionContext { + return RequestExecutionContext( + attemptNumber = forge.aPositiveInt(), + previousResponseCode = forge.aNullable { aPositiveInt() } + ) + } +} diff --git a/dd-sdk-android-internal/api/apiSurface b/dd-sdk-android-internal/api/apiSurface index 6fb18f4f0d..f80629999a 100644 --- a/dd-sdk-android-internal/api/apiSurface +++ b/dd-sdk-android-internal/api/apiSurface @@ -4,11 +4,31 @@ interface com.datadog.android.internal.profiler.BenchmarkSpan fun stop() interface com.datadog.android.internal.profiler.BenchmarkSpanBuilder fun startSpan(): BenchmarkSpan -fun withinBenchmarkSpan(String, BenchmarkSpan.() -> T): T +fun withinBenchmarkSpan(String, Map = emptyMap(), BenchmarkSpan.() -> T): T interface com.datadog.android.internal.profiler.BenchmarkTracer - fun spanBuilder(String): BenchmarkSpanBuilder + fun spanBuilder(String, Map = emptyMap()): BenchmarkSpanBuilder object com.datadog.android.internal.profiler.GlobalBenchmark fun register(BenchmarkProfiler) fun get(): BenchmarkProfiler +sealed class com.datadog.android.internal.telemetry.InternalTelemetryEvent + sealed class Log : InternalTelemetryEvent + constructor(String, Map?) + class Debug : Log + constructor(String, Map?) + class Error : Log + constructor(String, Map? = null, Throwable? = null, String? = null, String? = null) + fun resolveKind(): String? + fun resolveStacktrace(): String? + data class Configuration : InternalTelemetryEvent + constructor(Boolean, Long, Long, Boolean, Boolean, Int) + data class Metric : InternalTelemetryEvent + constructor(String, Map?) + sealed class ApiUsage : InternalTelemetryEvent + constructor(MutableMap = mutableMapOf()) + class AddViewLoadingTime : ApiUsage + constructor(Boolean, Boolean, Boolean, MutableMap = mutableMapOf()) + object InterceptorInstantiated : InternalTelemetryEvent +fun ByteArray.toHexString(): String +fun Throwable.loggableStackTrace(): String annotation com.datadog.tools.annotation.NoOpImplementation constructor(Boolean = false) diff --git a/dd-sdk-android-internal/api/dd-sdk-android-internal.api b/dd-sdk-android-internal/api/dd-sdk-android-internal.api index 4e08d9b272..a6ae5b9963 100644 --- a/dd-sdk-android-internal/api/dd-sdk-android-internal.api +++ b/dd-sdk-android-internal/api/dd-sdk-android-internal.api @@ -11,11 +11,16 @@ public abstract interface class com/datadog/android/internal/profiler/BenchmarkS } public final class com/datadog/android/internal/profiler/BenchmarkSpanExtKt { - public static final fun withinBenchmarkSpan (Ljava/lang/String;Lkotlin/jvm/functions/Function1;)Ljava/lang/Object; + public static final fun withinBenchmarkSpan (Ljava/lang/String;Ljava/util/Map;Lkotlin/jvm/functions/Function1;)Ljava/lang/Object; + public static synthetic fun withinBenchmarkSpan$default (Ljava/lang/String;Ljava/util/Map;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Ljava/lang/Object; } public abstract interface class com/datadog/android/internal/profiler/BenchmarkTracer { - public abstract fun spanBuilder (Ljava/lang/String;)Lcom/datadog/android/internal/profiler/BenchmarkSpanBuilder; + public abstract fun spanBuilder (Ljava/lang/String;Ljava/util/Map;)Lcom/datadog/android/internal/profiler/BenchmarkSpanBuilder; +} + +public final class com/datadog/android/internal/profiler/BenchmarkTracer$DefaultImpls { + public static synthetic fun spanBuilder$default (Lcom/datadog/android/internal/profiler/BenchmarkTracer;Ljava/lang/String;Ljava/util/Map;ILjava/lang/Object;)Lcom/datadog/android/internal/profiler/BenchmarkSpanBuilder; } public final class com/datadog/android/internal/profiler/GlobalBenchmark { @@ -24,6 +29,89 @@ public final class com/datadog/android/internal/profiler/GlobalBenchmark { public final fun register (Lcom/datadog/android/internal/profiler/BenchmarkProfiler;)V } +public abstract class com/datadog/android/internal/telemetry/InternalTelemetryEvent { +} + +public abstract class com/datadog/android/internal/telemetry/InternalTelemetryEvent$ApiUsage : com/datadog/android/internal/telemetry/InternalTelemetryEvent { + public synthetic fun (Ljava/util/Map;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public synthetic fun (Ljava/util/Map;Lkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun getAdditionalProperties ()Ljava/util/Map; +} + +public final class com/datadog/android/internal/telemetry/InternalTelemetryEvent$ApiUsage$AddViewLoadingTime : com/datadog/android/internal/telemetry/InternalTelemetryEvent$ApiUsage { + public fun (ZZZLjava/util/Map;)V + public synthetic fun (ZZZLjava/util/Map;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun getNoActiveView ()Z + public final fun getNoView ()Z + public final fun getOverwrite ()Z +} + +public final class com/datadog/android/internal/telemetry/InternalTelemetryEvent$Configuration : com/datadog/android/internal/telemetry/InternalTelemetryEvent { + public fun (ZJJZZI)V + public final fun component1 ()Z + public final fun component2 ()J + public final fun component3 ()J + public final fun component4 ()Z + public final fun component5 ()Z + public final fun component6 ()I + public final fun copy (ZJJZZI)Lcom/datadog/android/internal/telemetry/InternalTelemetryEvent$Configuration; + public static synthetic fun copy$default (Lcom/datadog/android/internal/telemetry/InternalTelemetryEvent$Configuration;ZJJZZIILjava/lang/Object;)Lcom/datadog/android/internal/telemetry/InternalTelemetryEvent$Configuration; + public fun equals (Ljava/lang/Object;)Z + public final fun getBatchProcessingLevel ()I + public final fun getBatchSize ()J + public final fun getBatchUploadFrequency ()J + public final fun getTrackErrors ()Z + public final fun getUseLocalEncryption ()Z + public final fun getUseProxy ()Z + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class com/datadog/android/internal/telemetry/InternalTelemetryEvent$InterceptorInstantiated : com/datadog/android/internal/telemetry/InternalTelemetryEvent { + public static final field INSTANCE Lcom/datadog/android/internal/telemetry/InternalTelemetryEvent$InterceptorInstantiated; +} + +public abstract class com/datadog/android/internal/telemetry/InternalTelemetryEvent$Log : com/datadog/android/internal/telemetry/InternalTelemetryEvent { + public synthetic fun (Ljava/lang/String;Ljava/util/Map;Lkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun getAdditionalProperties ()Ljava/util/Map; + public final fun getMessage ()Ljava/lang/String; +} + +public final class com/datadog/android/internal/telemetry/InternalTelemetryEvent$Log$Debug : com/datadog/android/internal/telemetry/InternalTelemetryEvent$Log { + public fun (Ljava/lang/String;Ljava/util/Map;)V +} + +public final class com/datadog/android/internal/telemetry/InternalTelemetryEvent$Log$Error : com/datadog/android/internal/telemetry/InternalTelemetryEvent$Log { + public fun (Ljava/lang/String;Ljava/util/Map;Ljava/lang/Throwable;Ljava/lang/String;Ljava/lang/String;)V + public synthetic fun (Ljava/lang/String;Ljava/util/Map;Ljava/lang/Throwable;Ljava/lang/String;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun getError ()Ljava/lang/Throwable; + public final fun getKind ()Ljava/lang/String; + public final fun getStacktrace ()Ljava/lang/String; + public final fun resolveKind ()Ljava/lang/String; + public final fun resolveStacktrace ()Ljava/lang/String; +} + +public final class com/datadog/android/internal/telemetry/InternalTelemetryEvent$Metric : com/datadog/android/internal/telemetry/InternalTelemetryEvent { + public fun (Ljava/lang/String;Ljava/util/Map;)V + public final fun component1 ()Ljava/lang/String; + public final fun component2 ()Ljava/util/Map; + public final fun copy (Ljava/lang/String;Ljava/util/Map;)Lcom/datadog/android/internal/telemetry/InternalTelemetryEvent$Metric; + public static synthetic fun copy$default (Lcom/datadog/android/internal/telemetry/InternalTelemetryEvent$Metric;Ljava/lang/String;Ljava/util/Map;ILjava/lang/Object;)Lcom/datadog/android/internal/telemetry/InternalTelemetryEvent$Metric; + public fun equals (Ljava/lang/Object;)Z + public final fun getAdditionalProperties ()Ljava/util/Map; + public final fun getMessage ()Ljava/lang/String; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class com/datadog/android/internal/utils/ByteArrayExtKt { + public static final fun toHexString ([B)Ljava/lang/String; +} + +public final class com/datadog/android/internal/utils/ThrowableExtKt { + public static final fun loggableStackTrace (Ljava/lang/Throwable;)Ljava/lang/String; +} + public abstract interface annotation class com/datadog/tools/annotation/NoOpImplementation : java/lang/annotation/Annotation { public abstract fun publicNoOpImplementation ()Z } diff --git a/dd-sdk-android-internal/build.gradle.kts b/dd-sdk-android-internal/build.gradle.kts index d8e22c12c9..75077fe996 100644 --- a/dd-sdk-android-internal/build.gradle.kts +++ b/dd-sdk-android-internal/build.gradle.kts @@ -1,6 +1,7 @@ import com.datadog.gradle.config.androidLibraryConfig import com.datadog.gradle.config.dependencyUpdateConfig -import com.datadog.gradle.config.java17 +import com.datadog.gradle.config.detektCustomConfig +import com.datadog.gradle.config.java11 import com.datadog.gradle.config.javadocConfig import com.datadog.gradle.config.junitConfig import com.datadog.gradle.config.kotlinConfig @@ -33,7 +34,11 @@ plugins { android { namespace = "com.datadog.android.internal" compileOptions { - java17() + java11() + } + + testFixtures { + enable = true } } @@ -42,6 +47,27 @@ dependencies { // Generate NoOp implementations ksp(project(":tools:noopfactory")) + testImplementation(project(":tools:unit")) { + attributes { + attribute( + com.android.build.api.attributes.ProductFlavorAttr.of("platform"), + objects.named("jvm") + ) + } + } + testImplementation(libs.bundles.jUnit5) + testImplementation(libs.bundles.testTools) + testFixturesImplementation(libs.kotlin) + testFixturesImplementation(libs.bundles.jUnit5) + testFixturesImplementation(libs.bundles.testTools) + testFixturesImplementation(project(":tools:unit")) { + attributes { + attribute( + com.android.build.api.attributes.ProductFlavorAttr.of("platform"), + objects.named("jvm") + ) + } + } } kotlinConfig(jvmBytecodeTarget = JvmTarget.JVM_11) @@ -52,3 +78,4 @@ dependencyUpdateConfig() publishingConfig( "Internal library to be used by the Datadog SDK modules." ) +detektCustomConfig() diff --git a/dd-sdk-android-internal/src/main/java/com/datadog/android/internal/profiler/BenchmarkSpanExt.kt b/dd-sdk-android-internal/src/main/java/com/datadog/android/internal/profiler/BenchmarkSpanExt.kt index 6ce10b67b1..dd831d10c9 100644 --- a/dd-sdk-android-internal/src/main/java/com/datadog/android/internal/profiler/BenchmarkSpanExt.kt +++ b/dd-sdk-android-internal/src/main/java/com/datadog/android/internal/profiler/BenchmarkSpanExt.kt @@ -11,16 +11,21 @@ package com.datadog.android.internal.profiler * @param T the type returned by the lambda * @param operationName the name of the [BenchmarkSpan] created around the lambda * (default is `true`) + * @param additionalProperties Additional properties for this span. * @param block the lambda function traced by this newly created [BenchmarkSpan] * */ inline fun withinBenchmarkSpan( operationName: String, + additionalProperties: Map = emptyMap(), block: BenchmarkSpan.() -> T ): T { val tracer = GlobalBenchmark.get().getTracer("dd-sdk-android") - val spanBuilder = tracer.spanBuilder(operationName) + val spanBuilder = tracer.spanBuilder( + operationName, + additionalProperties + ) val span = spanBuilder.startSpan() diff --git a/dd-sdk-android-internal/src/main/java/com/datadog/android/internal/profiler/BenchmarkTracer.kt b/dd-sdk-android-internal/src/main/java/com/datadog/android/internal/profiler/BenchmarkTracer.kt index 07ad124579..b940819d62 100644 --- a/dd-sdk-android-internal/src/main/java/com/datadog/android/internal/profiler/BenchmarkTracer.kt +++ b/dd-sdk-android-internal/src/main/java/com/datadog/android/internal/profiler/BenchmarkTracer.kt @@ -19,7 +19,11 @@ interface BenchmarkTracer { * Returns a new [BenchmarkSpanBuilder]. * * @param spanName The name of the returned span. + * @param additionalProperties Additional properties for this span. * @return a new [BenchmarkSpanBuilder]. */ - fun spanBuilder(spanName: String): BenchmarkSpanBuilder + fun spanBuilder( + spanName: String, + additionalProperties: Map = emptyMap() + ): BenchmarkSpanBuilder } diff --git a/dd-sdk-android-internal/src/main/java/com/datadog/android/internal/telemetry/InternalTelemetryEvent.kt b/dd-sdk-android-internal/src/main/java/com/datadog/android/internal/telemetry/InternalTelemetryEvent.kt new file mode 100644 index 0000000000..4388770d76 --- /dev/null +++ b/dd-sdk-android-internal/src/main/java/com/datadog/android/internal/telemetry/InternalTelemetryEvent.kt @@ -0,0 +1,59 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.datadog.android.internal.telemetry + +import com.datadog.android.internal.utils.loggableStackTrace + +@Suppress("UndocumentedPublicClass", "UndocumentedPublicFunction", "UndocumentedPublicProperty") +sealed class InternalTelemetryEvent { + + sealed class Log(val message: String, val additionalProperties: Map?) : InternalTelemetryEvent() { + class Debug(message: String, additionalProperties: Map?) : Log(message, additionalProperties) + + class Error( + message: String, + additionalProperties: Map? = null, + val error: Throwable? = null, + val stacktrace: String? = null, + val kind: String? = null + ) : Log(message, additionalProperties) { + fun resolveKind(): String? { + return kind ?: error?.javaClass?.canonicalName ?: error?.javaClass?.simpleName + } + + fun resolveStacktrace(): String? { + return stacktrace ?: error?.loggableStackTrace() + } + } + } + + data class Configuration( + val trackErrors: Boolean, + val batchSize: Long, + val batchUploadFrequency: Long, + val useProxy: Boolean, + val useLocalEncryption: Boolean, + val batchProcessingLevel: Int + ) : InternalTelemetryEvent() + + data class Metric( + val message: String, + val additionalProperties: Map? + ) : InternalTelemetryEvent() + + sealed class ApiUsage(val additionalProperties: MutableMap = mutableMapOf()) : + InternalTelemetryEvent() { + class AddViewLoadingTime( + val overwrite: Boolean, + val noView: Boolean, + val noActiveView: Boolean, + additionalProperties: MutableMap = mutableMapOf() + ) : ApiUsage(additionalProperties) + } + + object InterceptorInstantiated : InternalTelemetryEvent() +} diff --git a/dd-sdk-android-internal/src/main/java/com/datadog/android/internal/utils/ByteArrayExt.kt b/dd-sdk-android-internal/src/main/java/com/datadog/android/internal/utils/ByteArrayExt.kt new file mode 100644 index 0000000000..204f60bf2e --- /dev/null +++ b/dd-sdk-android-internal/src/main/java/com/datadog/android/internal/utils/ByteArrayExt.kt @@ -0,0 +1,36 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.datadog.android.internal.utils + +private const val BYTE_MASK = 0xff +private const val HEX_SHIFT = 4 +private const val LOWER_NIBBLE_MASK = 0x0f +private const val HEX_CHARS = "0123456789abcdef" + +/** + * Converts a ByteArray to its corresponding hexadecimal String representation. + * + * Each byte in the array is converted into two hexadecimal characters. + * For example, the byte array `[0xA, 0x1F]` will be converted to the string `"0a1f"`. + * + * This method avoids performance overhead by using bitwise operations and + * minimizing object allocations compared to alternatives like `joinToString`. + * + * @receiver ByteArray The byte array to be converted. + * @return A hexadecimal [String] representation of the byte array. + * + */ +fun ByteArray.toHexString(): String { + @Suppress("UnsafeThirdPartyFunctionCall") // byte array size is always positive. + val result = StringBuilder(size * 2) + for (byte in this) { + val intVal = byte.toInt() and BYTE_MASK + result.append(HEX_CHARS[intVal ushr HEX_SHIFT]) // Append first half of byte + result.append(HEX_CHARS[intVal and LOWER_NIBBLE_MASK]) // Append second half of byte + } + return result.toString() +} diff --git a/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/internal/utils/ThrowableExt.kt b/dd-sdk-android-internal/src/main/java/com/datadog/android/internal/utils/ThrowableExt.kt similarity index 85% rename from dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/internal/utils/ThrowableExt.kt rename to dd-sdk-android-internal/src/main/java/com/datadog/android/internal/utils/ThrowableExt.kt index 21ec894918..78e28b213d 100644 --- a/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/internal/utils/ThrowableExt.kt +++ b/dd-sdk-android-internal/src/main/java/com/datadog/android/internal/utils/ThrowableExt.kt @@ -4,16 +4,14 @@ * Copyright 2016-Present Datadog, Inc. */ -package com.datadog.android.core.internal.utils +package com.datadog.android.internal.utils -import com.datadog.android.lint.InternalApi import java.io.PrintWriter import java.io.StringWriter /** * Converts stacktrace to string format. */ -@InternalApi fun Throwable.loggableStackTrace(): String { val stringWriter = StringWriter() @Suppress("UnsafeThirdPartyFunctionCall") // NPE cannot happen here diff --git a/dd-sdk-android-internal/src/test/java/com/datadog/internal/forge/Configurator.kt b/dd-sdk-android-internal/src/test/java/com/datadog/internal/forge/Configurator.kt new file mode 100644 index 0000000000..6bedd793f8 --- /dev/null +++ b/dd-sdk-android-internal/src/test/java/com/datadog/internal/forge/Configurator.kt @@ -0,0 +1,19 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.datadog.internal.forge + +import com.datadog.android.internal.tests.elmyr.InternalTelemetryErrorLogForgeryFactory +import com.datadog.tools.unit.forge.BaseConfigurator +import fr.xgouchet.elmyr.Forge + +internal class Configurator : + BaseConfigurator() { + override fun configure(forge: Forge) { + super.configure(forge) + forge.addFactory(InternalTelemetryErrorLogForgeryFactory()) + } +} diff --git a/dd-sdk-android-internal/src/test/java/com/datadog/internal/telemetry/InternalTelemetryErrorEventTest.kt b/dd-sdk-android-internal/src/test/java/com/datadog/internal/telemetry/InternalTelemetryErrorEventTest.kt new file mode 100644 index 0000000000..e205c9015c --- /dev/null +++ b/dd-sdk-android-internal/src/test/java/com/datadog/internal/telemetry/InternalTelemetryErrorEventTest.kt @@ -0,0 +1,198 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.datadog.internal.telemetry + +import com.datadog.android.internal.telemetry.InternalTelemetryEvent +import com.datadog.android.internal.utils.loggableStackTrace +import com.datadog.tools.unit.forge.aThrowable +import fr.xgouchet.elmyr.Forge +import fr.xgouchet.elmyr.junit5.ForgeExtension +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.junit.jupiter.api.extension.Extensions +import org.mockito.junit.jupiter.MockitoExtension +import org.mockito.junit.jupiter.MockitoSettings +import org.mockito.quality.Strictness + +@Extensions( + ExtendWith(MockitoExtension::class), + ExtendWith(ForgeExtension::class) +) +@MockitoSettings(strictness = Strictness.LENIENT) +internal class InternalTelemetryErrorEventTest { + + @Test + fun `M resolve the given stacktrace W resolveStacktrace { stacktrace explicitly provided }`(forge: Forge) { + // Given + val expectedStackTrace = forge.aString() + val errorEvent = InternalTelemetryEvent.Log.Error( + message = forge.aString(), + additionalProperties = forge.aMap { aString() to aString() }, + error = null, + stacktrace = expectedStackTrace, + kind = forge.aNullable { aString() } + ) + + // When + val resolvedStackTrace = errorEvent.resolveStacktrace() + assertThat(resolvedStackTrace).isEqualTo(expectedStackTrace) + } + + @Test + fun `M resolve the given stacktrace W resolveStacktrace { stacktrace and throwable explicitly provided }`( + forge: Forge + ) { + // Given + val expectedStackTrace = forge.aString() + val errorEvent = InternalTelemetryEvent.Log.Error( + message = forge.aString(), + additionalProperties = forge.aMap { aString() to aString() }, + error = forge.aThrowable(), + stacktrace = expectedStackTrace, + kind = forge.aNullable { aString() } + ) + + // When + val resolvedStackTrace = errorEvent.resolveStacktrace() + assertThat(resolvedStackTrace).isEqualTo(expectedStackTrace) + } + + @Test + fun `M resolve throwable stacktrace W resolveStacktrace { only throwable explicitly provided }`( + forge: Forge + ) { + // Given + val fakeThrowable = forge.aThrowable() + val expectedStackTrace = fakeThrowable.loggableStackTrace() + val errorEvent = InternalTelemetryEvent.Log.Error( + message = forge.aString(), + additionalProperties = forge.aMap { aString() to aString() }, + error = fakeThrowable, + stacktrace = null, + kind = forge.aNullable { aString() } + ) + + // When + val resolvedStackTrace = errorEvent.resolveStacktrace() + assertThat(resolvedStackTrace).isEqualTo(expectedStackTrace) + } + + @Test + fun `M resolve null W resolveStacktrace { stacktrace nor throwable provided }`( + forge: Forge + ) { + // Given + val errorEvent = InternalTelemetryEvent.Log.Error( + message = forge.aString(), + additionalProperties = forge.aMap { aString() to aString() }, + error = null, + stacktrace = null, + kind = forge.aNullable { aString() } + ) + + // When + val resolvedStackTrace = errorEvent.resolveStacktrace() + assertThat(resolvedStackTrace).isNull() + } + + @Test + fun `M resolve the given kind W resolveKind { kind explicitly provided }`( + forge: Forge + ) { + // Given + val expectedKind = forge.aString() + val errorEvent = InternalTelemetryEvent.Log.Error( + message = forge.aString(), + additionalProperties = forge.aMap { aString() to aString() }, + error = null, + stacktrace = forge.aNullable { aString() }, + kind = expectedKind + ) + + // When + val resolvedKind = errorEvent.resolveKind() + assertThat(resolvedKind).isEqualTo(expectedKind) + } + + @Test + fun `M resolve the given kind W resolveKind { kind and throwable explicitly provided }`( + forge: Forge + ) { + // Given + val expectedKind = forge.aString() + val errorEvent = InternalTelemetryEvent.Log.Error( + message = forge.aString(), + additionalProperties = forge.aMap { aString() to aString() }, + error = forge.aThrowable(), + stacktrace = forge.aNullable { aString() }, + kind = expectedKind + ) + + // When + val resolvedKind = errorEvent.resolveKind() + assertThat(resolvedKind).isEqualTo(expectedKind) + } + + @Test + fun `M resolve throwable kind W resolveKind { only throwable explicitly provided }`( + forge: Forge + ) { + // Given + val fakeThrowable = forge.aThrowable() + val expectedKind = fakeThrowable.javaClass.canonicalName + val errorEvent = InternalTelemetryEvent.Log.Error( + message = forge.aString(), + additionalProperties = forge.aMap { aString() to aString() }, + error = fakeThrowable, + stacktrace = forge.aNullable { aString() }, + kind = null + ) + + // When + val resolvedKind = errorEvent.resolveKind() + assertThat(resolvedKind).isEqualTo(expectedKind) + } + + @Test + fun `M resolve throwable kind W resolveKind { only throwable explicitly provided, anonymous class }`( + forge: Forge + ) { + // Given + val fakeThrowable = object : Throwable() {} + val expectedKind = fakeThrowable.javaClass.simpleName + val errorEvent = InternalTelemetryEvent.Log.Error( + message = forge.aString(), + additionalProperties = forge.aMap { aString() to aString() }, + error = fakeThrowable, + stacktrace = forge.aNullable { aString() }, + kind = null + ) + + // When + val resolvedKind = errorEvent.resolveKind() + assertThat(resolvedKind).isEqualTo(expectedKind) + } + + @Test + fun `M resolve null W resolveKind { kind nor throwable provided }`( + forge: Forge + ) { + // Given + val errorEvent = InternalTelemetryEvent.Log.Error( + message = forge.aString(), + additionalProperties = forge.aMap { aString() to aString() }, + error = null, + stacktrace = forge.aNullable { aString() }, + kind = null + ) + + // When + val resolvedKind = errorEvent.resolveKind() + assertThat(resolvedKind).isNull() + } +} diff --git a/dd-sdk-android-internal/src/test/java/com/datadog/internal/utils/ByteArrayExtTest.kt b/dd-sdk-android-internal/src/test/java/com/datadog/internal/utils/ByteArrayExtTest.kt new file mode 100644 index 0000000000..59e9ae7392 --- /dev/null +++ b/dd-sdk-android-internal/src/test/java/com/datadog/internal/utils/ByteArrayExtTest.kt @@ -0,0 +1,66 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.datadog.internal.utils + +import com.datadog.android.internal.utils.toHexString +import fr.xgouchet.elmyr.annotation.StringForgery +import fr.xgouchet.elmyr.junit5.ForgeExtension +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.junit.jupiter.api.extension.Extensions +import org.mockito.junit.jupiter.MockitoExtension +import org.mockito.junit.jupiter.MockitoSettings +import org.mockito.quality.Strictness +import java.util.Locale + +@Extensions( + ExtendWith(MockitoExtension::class), + ExtendWith(ForgeExtension::class) +) +@MockitoSettings(strictness = Strictness.LENIENT) +class ByteArrayExtTest { + + @Test + fun `M return correct hex string W convert {0x00, 0x00}`() { + // Given + val byteArray = byteArrayOf(0x00, 0x00) // 0xA is 10 in decimal + + // When + val result = byteArray.toHexString() + + // Then + val expectedHex = "0000" + assertThat(result).isEqualTo(expectedHex) + } + + @Test + fun `M return correct hex string W convert {0xFF, 0xFF}`() { + // Given + val byteArray = byteArrayOf(0xFF.toByte(), 0xFF.toByte()) // 0xA is 10 in decimal + + // When + val result = byteArray.toHexString() + + // Then + val expectedHex = "ffff" + assertThat(result).isEqualTo(expectedHex) + } + + @Test + fun `M return correct hex string W call toHexString()`(@StringForgery fakeInput: String) { + // Given + val fakeByteArray = fakeInput.toByteArray() + + // When + val result = fakeByteArray.toHexString() + + // Then + val expected = fakeByteArray.joinToString(separator = "") { "%02x".format(Locale.US, it) } + assertThat(result).isEqualTo(expected) + } +} diff --git a/dd-sdk-android-core/src/test/kotlin/com/datadog/android/core/internal/utils/ThrowableExtTest.kt b/dd-sdk-android-internal/src/test/java/com/datadog/internal/utils/ThrowableExtTest.kt similarity index 95% rename from dd-sdk-android-core/src/test/kotlin/com/datadog/android/core/internal/utils/ThrowableExtTest.kt rename to dd-sdk-android-internal/src/test/java/com/datadog/internal/utils/ThrowableExtTest.kt index a287095538..e582a09d9d 100644 --- a/dd-sdk-android-core/src/test/kotlin/com/datadog/android/core/internal/utils/ThrowableExtTest.kt +++ b/dd-sdk-android-internal/src/test/java/com/datadog/internal/utils/ThrowableExtTest.kt @@ -4,9 +4,10 @@ * Copyright 2016-Present Datadog, Inc. */ -package com.datadog.android.core.internal.utils +package com.datadog.internal.utils -import com.datadog.android.utils.forge.Configurator +import com.datadog.android.internal.utils.loggableStackTrace +import com.datadog.internal.forge.Configurator import fr.xgouchet.elmyr.annotation.Forgery import fr.xgouchet.elmyr.annotation.StringForgery import fr.xgouchet.elmyr.junit5.ForgeConfiguration diff --git a/dd-sdk-android-internal/src/testFixtures/kotlin/com/datadog/android/internal/tests/elmyr/InternalTelemetryApiUsageForgeryFactory.kt b/dd-sdk-android-internal/src/testFixtures/kotlin/com/datadog/android/internal/tests/elmyr/InternalTelemetryApiUsageForgeryFactory.kt new file mode 100644 index 0000000000..346645aa0a --- /dev/null +++ b/dd-sdk-android-internal/src/testFixtures/kotlin/com/datadog/android/internal/tests/elmyr/InternalTelemetryApiUsageForgeryFactory.kt @@ -0,0 +1,23 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.datadog.android.internal.tests.elmyr + +import com.datadog.android.internal.telemetry.InternalTelemetryEvent +import fr.xgouchet.elmyr.Forge +import fr.xgouchet.elmyr.ForgeryFactory + +class InternalTelemetryApiUsageForgeryFactory : ForgeryFactory { + + override fun getForgery(forge: Forge): InternalTelemetryEvent.ApiUsage { + return InternalTelemetryEvent.ApiUsage.AddViewLoadingTime( + overwrite = forge.aBool(), + noView = forge.aBool(), + noActiveView = forge.aBool(), + additionalProperties = forge.aMap { aString() to aString() }.toMutableMap() + ) + } +} diff --git a/dd-sdk-android-internal/src/testFixtures/kotlin/com/datadog/android/internal/tests/elmyr/InternalTelemetryConfigurationForgeryFactory.kt b/dd-sdk-android-internal/src/testFixtures/kotlin/com/datadog/android/internal/tests/elmyr/InternalTelemetryConfigurationForgeryFactory.kt new file mode 100644 index 0000000000..657864a1c3 --- /dev/null +++ b/dd-sdk-android-internal/src/testFixtures/kotlin/com/datadog/android/internal/tests/elmyr/InternalTelemetryConfigurationForgeryFactory.kt @@ -0,0 +1,25 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.datadog.android.internal.tests.elmyr + +import com.datadog.android.internal.telemetry.InternalTelemetryEvent +import fr.xgouchet.elmyr.Forge +import fr.xgouchet.elmyr.ForgeryFactory + +class InternalTelemetryConfigurationForgeryFactory : ForgeryFactory { + + override fun getForgery(forge: Forge): InternalTelemetryEvent.Configuration { + return InternalTelemetryEvent.Configuration( + trackErrors = forge.aBool(), + batchSize = forge.aLong(), + batchProcessingLevel = forge.anInt(), + batchUploadFrequency = forge.aLong(), + useProxy = forge.aBool(), + useLocalEncryption = forge.aBool() + ) + } +} diff --git a/dd-sdk-android-internal/src/testFixtures/kotlin/com/datadog/android/internal/tests/elmyr/InternalTelemetryDebugLogForgeryFactory.kt b/dd-sdk-android-internal/src/testFixtures/kotlin/com/datadog/android/internal/tests/elmyr/InternalTelemetryDebugLogForgeryFactory.kt new file mode 100644 index 0000000000..3dbf817c33 --- /dev/null +++ b/dd-sdk-android-internal/src/testFixtures/kotlin/com/datadog/android/internal/tests/elmyr/InternalTelemetryDebugLogForgeryFactory.kt @@ -0,0 +1,21 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.datadog.android.internal.tests.elmyr + +import com.datadog.android.internal.telemetry.InternalTelemetryEvent +import fr.xgouchet.elmyr.Forge +import fr.xgouchet.elmyr.ForgeryFactory + +class InternalTelemetryDebugLogForgeryFactory : ForgeryFactory { + + override fun getForgery(forge: Forge): InternalTelemetryEvent.Log.Debug { + return InternalTelemetryEvent.Log.Debug( + message = forge.aString(), + additionalProperties = forge.aMap { aString() to aString() } + ) + } +} diff --git a/dd-sdk-android-internal/src/testFixtures/kotlin/com/datadog/android/internal/tests/elmyr/InternalTelemetryErrorLogForgeryFactory.kt b/dd-sdk-android-internal/src/testFixtures/kotlin/com/datadog/android/internal/tests/elmyr/InternalTelemetryErrorLogForgeryFactory.kt new file mode 100644 index 0000000000..843bcc581c --- /dev/null +++ b/dd-sdk-android-internal/src/testFixtures/kotlin/com/datadog/android/internal/tests/elmyr/InternalTelemetryErrorLogForgeryFactory.kt @@ -0,0 +1,25 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.datadog.android.internal.tests.elmyr + +import com.datadog.android.internal.telemetry.InternalTelemetryEvent +import com.datadog.tools.unit.forge.aThrowable +import fr.xgouchet.elmyr.Forge +import fr.xgouchet.elmyr.ForgeryFactory + +class InternalTelemetryErrorLogForgeryFactory : ForgeryFactory { + + override fun getForgery(forge: Forge): InternalTelemetryEvent.Log.Error { + return InternalTelemetryEvent.Log.Error( + message = forge.aString(), + additionalProperties = forge.aMap { aString() to aString() }, + error = forge.aNullable { aThrowable() }, + stacktrace = forge.aNullable { aString() }, + kind = forge.aNullable { aString() } + ) + } +} diff --git a/dd-sdk-android-internal/src/testFixtures/kotlin/com/datadog/android/internal/tests/elmyr/InternalTelemetryEventForgeryFactory.kt b/dd-sdk-android-internal/src/testFixtures/kotlin/com/datadog/android/internal/tests/elmyr/InternalTelemetryEventForgeryFactory.kt new file mode 100644 index 0000000000..6ffc07009b --- /dev/null +++ b/dd-sdk-android-internal/src/testFixtures/kotlin/com/datadog/android/internal/tests/elmyr/InternalTelemetryEventForgeryFactory.kt @@ -0,0 +1,28 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.datadog.android.internal.tests.elmyr + +import com.datadog.android.internal.telemetry.InternalTelemetryEvent +import fr.xgouchet.elmyr.Forge +import fr.xgouchet.elmyr.ForgeryFactory + +class InternalTelemetryEventForgeryFactory : ForgeryFactory { + + override fun getForgery(forge: Forge): InternalTelemetryEvent { + val random = forge.anInt(min = 0, max = 6) + return when (random) { + 0 -> forge.getForgery() + + 1 -> forge.getForgery() + + 2 -> forge.getForgery() + 3 -> InternalTelemetryEvent.InterceptorInstantiated + 4 -> forge.getForgery() + else -> forge.getForgery() + } + } +} diff --git a/dd-sdk-android-internal/src/testFixtures/kotlin/com/datadog/android/internal/tests/elmyr/InternalTelemetryMetricForgeryFactory.kt b/dd-sdk-android-internal/src/testFixtures/kotlin/com/datadog/android/internal/tests/elmyr/InternalTelemetryMetricForgeryFactory.kt new file mode 100644 index 0000000000..e37a205900 --- /dev/null +++ b/dd-sdk-android-internal/src/testFixtures/kotlin/com/datadog/android/internal/tests/elmyr/InternalTelemetryMetricForgeryFactory.kt @@ -0,0 +1,21 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.datadog.android.internal.tests.elmyr + +import com.datadog.android.internal.telemetry.InternalTelemetryEvent +import fr.xgouchet.elmyr.Forge +import fr.xgouchet.elmyr.ForgeryFactory + +class InternalTelemetryMetricForgeryFactory : ForgeryFactory { + + override fun getForgery(forge: Forge): InternalTelemetryEvent.Metric { + return InternalTelemetryEvent.Metric( + message = forge.aString(), + additionalProperties = forge.aMap { aString() to aString() } + ) + } +} diff --git a/detekt_custom.yml b/detekt_custom.yml index 884ba0be00..c6d73ad385 100644 --- a/detekt_custom.yml +++ b/detekt_custom.yml @@ -154,6 +154,7 @@ datadog: - "androidx.metrics.performance.JankStats.createAndTrack(android.view.Window, androidx.metrics.performance.JankStats.OnFrameListener):java.lang.IllegalStateException" - "androidx.navigation.Navigation.findNavController(android.app.Activity, kotlin.Int):java.lang.IllegalStateException" - "androidx.work.WorkManager.enqueueUniqueWork(kotlin.String, androidx.work.ExistingWorkPolicy, androidx.work.OneTimeWorkRequest):java.util.concurrent.RejectedExecutionException,java.lang.NullPointerException" + - "androidx.work.Data.Builder.build():java.lang.IllegalStateException" # endregion # region Java File - "java.io.ByteArrayOutputStream.write(kotlin.ByteArray, kotlin.Int, kotlin.Int):java.lang.IndexOutOfBoundsException" @@ -240,6 +241,7 @@ datadog: - "java.math.BigInteger.constructor(kotlin.String?, kotlin.Int):java.lang.NumberFormatException,java.lang.ArithmeticException" - "java.math.BigInteger.shiftRight(kotlin.Int):java.lang.NumberFormatException,java.lang.ArithmeticException" - "java.net.URL.constructor(kotlin.String?):java.net.MalformedURLException" + - "java.security.MessageDigest.digest(kotlin.ByteArray?):java.security.DigestException,java.lang.NullPointerException,java.lang.IllegalArgumentException" - "java.security.MessageDigest.getInstance(kotlin.String?):java.security.NoSuchAlgorithmException" - "java.text.SimpleDateFormat.constructor(kotlin.String, java.util.Locale):java.lang.NullPointerException" - "java.text.SimpleDateFormat.format(java.util.Date):java.lang.NullPointerException" @@ -396,6 +398,7 @@ datadog: - "android.os.SystemClock.elapsedRealtime()" - "android.os.SystemClock.elapsedRealtimeNanos()" - "android.os.StrictMode.allowThreadDiskReads()" + - "android.os.StrictMode.allowThreadDiskWrites()" - "android.os.StrictMode.setThreadPolicy(android.os.StrictMode.ThreadPolicy?)" - "android.os.SystemClock.elapsedRealtime()" - "android.util.Log.e(kotlin.String?, kotlin.String)" @@ -482,6 +485,7 @@ datadog: - "android.graphics.drawable.RippleDrawable.findIndexByLayerId(kotlin.Int)" - "android.graphics.drawable.DrawableContainer.DrawableContainerState.getChild(kotlin.Int)" - "android.graphics.Point.constructor()" + - "android.graphics.Point.constructor(kotlin.Int, kotlin.Int)" - "android.graphics.Rect.centerX()" - "android.graphics.Rect.centerY()" - "android.graphics.Rect.constructor()" @@ -490,6 +494,8 @@ datadog: - "android.graphics.Rect.width()" # endregion # region Androidx APIs + - "androidx.appcompat.widget.DatadogActionBarContainerAccessor.constructor(androidx.appcompat.widget.ActionBarContainer)" + - "androidx.appcompat.widget.DatadogActionBarContainerAccessor.getBackgroundDrawable()" - "androidx.compose.foundation.interaction.DragInteraction.Start.constructor()" - "androidx.compose.foundation.shape.CornerSize.toPx(androidx.compose.ui.geometry.Size, androidx.compose.ui.unit.Density)" - "androidx.compose.runtime.DisposableEffect(kotlin.Any?, kotlin.Any?, kotlin.Function1)" @@ -534,6 +540,8 @@ datadog: - "androidx.work.Constraints.Builder.build()" - "androidx.work.Constraints.Builder.constructor()" - "androidx.work.Constraints.Builder.setRequiredNetworkType(androidx.work.NetworkType)" + - "androidx.work.Data.Builder.constructor()" + - "androidx.work.Data.Builder.putString(kotlin.String, kotlin.String?)" - "androidx.work.Data.getString(kotlin.String)" - "androidx.work.ListenableWorker.Result.success()" - "androidx.work.OneTimeWorkRequest.Builder(java.lang.Class)" @@ -542,11 +550,10 @@ datadog: - "androidx.work.OneTimeWorkRequest.Builder.constructor(java.lang.Class)" - "androidx.work.OneTimeWorkRequest.Builder.setConstraints(androidx.work.Constraints)" - "androidx.work.OneTimeWorkRequest.Builder.setInitialDelay(kotlin.Long, java.util.concurrent.TimeUnit)" + - "androidx.work.OneTimeWorkRequest.Builder.setInputData(androidx.work.Data)" - "androidx.work.WorkManager.cancelAllWorkByTag(kotlin.String)" - "androidx.work.WorkManager.getInstance(android.content.Context)" - "androidx.work.WorkManager.isInitialized()" - - "androidx.appcompat.widget.DatadogActionBarContainerAccessor.constructor(androidx.appcompat.widget.ActionBarContainer)" - - "androidx.appcompat.widget.DatadogActionBarContainerAccessor.getBackgroundDrawable()" # endregion # region Google Material - "com.google.android.material.tabs.TabLayout.TabView.getChildAt(kotlin.Int)" @@ -730,6 +737,7 @@ datadog: - "java.lang.StringBuilder.append(kotlin.CharArray?)" - "java.lang.StringBuilder.append(kotlin.String?)" - "java.lang.StringBuilder.constructor()" + - "java.lang.StringBuilder.isNotEmpty()" - "java.lang.System.currentTimeMillis()" - "java.lang.System.getProperty(kotlin.String?)" - "java.lang.System.identityHashCode(kotlin.Any?)" @@ -740,6 +748,7 @@ datadog: - "java.lang.ref.WeakReference.constructor(android.content.Context?)" - "java.lang.ref.WeakReference.constructor(android.view.View?)" - "java.lang.ref.WeakReference.constructor(android.view.Window?)" + - "java.lang.ref.WeakReference.constructor(com.datadog.android.api.SdkCore?)" - "java.lang.ref.WeakReference.constructor(kotlin.Any?)" - "java.lang.ref.WeakReference.constructor(kotlin.Nothing?)" - "java.lang.ref.WeakReference.constructor(kotlin.String?)" @@ -759,6 +768,7 @@ datadog: - "java.security.SecureRandom.nextFloat()" - "java.security.SecureRandom.nextInt()" - "java.security.SecureRandom.nextLong()" + - "java.util.HashMap.clear()" - "java.util.HashSet.addAll(kotlin.collections.Collection)" - "java.util.HashSet.find(kotlin.Function1)" - "java.util.LinkedList.addFirst(com.datadog.android.webview.internal.rum.domain.WebViewNativeRumViewsCache.ViewEntry?)" @@ -839,6 +849,7 @@ datadog: - "kotlin.collections.Iterable.toMap(kotlin.collections.MutableMap)" - "kotlin.collections.LinkedHashMap()" - "kotlin.collections.LinkedHashMap(kotlin.collections.MutableMap?)" + - "kotlin.collections.List.all(kotlin.Function1)" - "kotlin.collections.List.any(kotlin.Function1)" - "kotlin.collections.List.asSequence()" - "kotlin.collections.List.associate(kotlin.Function1)" @@ -925,6 +936,7 @@ datadog: - "kotlin.collections.MutableList.add(com.datadog.android.rum.model.ActionEvent.Type)" - "kotlin.collections.MutableList.add(com.datadog.android.sessionreplay.compose.internal.data.Parameter)" - "kotlin.collections.MutableList.add(com.datadog.android.sessionreplay.compose.internal.utils.BackgroundInfo)" + - "kotlin.collections.MutableList.add(com.datadog.android.sessionreplay.internal.prerequisite.SystemRequirementChecker)" - "kotlin.collections.MutableList.add(com.datadog.android.sessionreplay.internal.processor.MutationResolver.Entry)" - "kotlin.collections.MutableList.add(com.datadog.android.sessionreplay.model.MobileSegment.Add)" - "kotlin.collections.MutableList.add(com.datadog.android.sessionreplay.model.MobileSegment.MobileRecord)" @@ -1042,6 +1054,7 @@ datadog: - "kotlin.collections.listOf(com.datadog.android.rum.model.ViewEvent.Interface)" - "kotlin.collections.listOf(com.datadog.android.sessionreplay.MapperTypeWrapper)" - "kotlin.collections.listOf(com.datadog.android.sessionreplay.internal.recorder.DefaultOptionSelectorDetector)" + - "kotlin.collections.listOf(com.datadog.android.sessionreplay.material.internal.MaterialDrawableToColorMapper)" - "kotlin.collections.listOf(com.datadog.android.sessionreplay.material.internal.MaterialOptionSelectorDetector)" - "kotlin.collections.listOf(com.datadog.android.sessionreplay.model.MobileSegment.MobileRecord.ViewEndRecord)" - "kotlin.collections.listOf(com.datadog.android.sessionreplay.model.MobileSegment.Wireframe)" @@ -1192,6 +1205,7 @@ datadog: - "kotlin.String.split(kotlin.Array, kotlin.Boolean, kotlin.Int)" - "kotlin.String.split(kotlin.Array, kotlin.Boolean, kotlin.Int)" - "kotlin.String.split(kotlin.CharArray, kotlin.Boolean, kotlin.Int)" + - "kotlin.String.split(kotlin.text.Regex, kotlin.Int)" - "kotlin.String.startsWith(kotlin.String, kotlin.Boolean)" - "kotlin.String.substringAfter(kotlin.Char, kotlin.String)" - "kotlin.String.substringAfterLast(kotlin.Char, kotlin.String)" @@ -1302,6 +1316,7 @@ datadog: - "okhttp3.Response.code()" - "okhttp3.Response.header(kotlin.String, kotlin.String?)" - "okhttp3.ResponseBody.contentLength()" + - "okhttp3.ResponseBody.contentType()" - "okio.Buffer.constructor()" # endregion # region org.json diff --git a/detekt_test_pyramid.yml b/detekt_test_pyramid.yml index 3ea20126ef..8241211efd 100644 --- a/detekt_test_pyramid.yml +++ b/detekt_test_pyramid.yml @@ -82,9 +82,27 @@ datadog-test-pyramid: active: true ApiUsage: active: true - includes: ['**/reliability/**'] + includes: [ '**/reliability/**' ] + internalPackagePrefix: 'com.datadog' ApiSurface: active: true - includes: ['**/dd-sdk-android-*/**'] - excludes: ['**/build/**', '**/test/**', '**/testDebug/**','**/testRelease/**', '**/androidTest/**', '**/testFixtures/**', '**/buildSrc/**', '**/*.kts', '**/instrumented/**', '**/sample/**', '**/tools/**'] - + includes: [ '**/dd-sdk-android-*/**' ] + excludes: + - '**/build/**' + - '**/test/**' + - '**/testDebug/**' + - '**/testRelease/**' + - '**/androidTest/**' + - '**/testFixtures/**' + - '**/buildSrc/**' + - '**/*.kts' + - '**/instrumented/**' + - '**/sample/**' + - '**/tools/**' + - '**/dd-sdk-android-internal/**' + internalPackagePrefix: 'com.datadog' + ignoredAnnotations: + - "com.datadog.android.lint.InternalApi" + ignoredClasses: + - "com.datadog.android._InternalProxy" + - "com.datadog.android.rum._RumInternalProxy" diff --git a/features/dd-sdk-android-logs/build.gradle.kts b/features/dd-sdk-android-logs/build.gradle.kts index ecb8e5caca..95312d0d72 100644 --- a/features/dd-sdk-android-logs/build.gradle.kts +++ b/features/dd-sdk-android-logs/build.gradle.kts @@ -3,9 +3,11 @@ * This product includes software developed at Datadog (https://www.datadoghq.com/). * Copyright 2016-Present Datadog, Inc. */ +@file:Suppress("StringLiteralDuplication") import com.datadog.gradle.config.androidLibraryConfig import com.datadog.gradle.config.dependencyUpdateConfig +import com.datadog.gradle.config.detektCustomConfig import com.datadog.gradle.config.javadocConfig import com.datadog.gradle.config.junitConfig import com.datadog.gradle.config.kotlinConfig @@ -86,3 +88,4 @@ publishingConfig( "The Logs feature to use with the Datadog monitoring " + "library for Android applications." ) +detektCustomConfig(":dd-sdk-android-core", ":dd-sdk-android-internal") diff --git a/features/dd-sdk-android-logs/src/main/kotlin/com/datadog/android/log/internal/net/LogsRequestFactory.kt b/features/dd-sdk-android-logs/src/main/kotlin/com/datadog/android/log/internal/net/LogsRequestFactory.kt index f321dd1475..236af4f81c 100644 --- a/features/dd-sdk-android-logs/src/main/kotlin/com/datadog/android/log/internal/net/LogsRequestFactory.kt +++ b/features/dd-sdk-android-logs/src/main/kotlin/com/datadog/android/log/internal/net/LogsRequestFactory.kt @@ -9,6 +9,7 @@ package com.datadog.android.log.internal.net import com.datadog.android.api.InternalLogger import com.datadog.android.api.context.DatadogContext import com.datadog.android.api.net.Request +import com.datadog.android.api.net.RequestExecutionContext import com.datadog.android.api.net.RequestFactory import com.datadog.android.api.storage.RawBatchEvent import com.datadog.android.core.internal.utils.join @@ -28,6 +29,7 @@ internal class LogsRequestFactory( /** @inheritdoc */ override fun create( context: DatadogContext, + executionContext: RequestExecutionContext, batchData: List, batchMetadata: ByteArray? ): Request? { diff --git a/features/dd-sdk-android-logs/src/test/kotlin/com/datadog/android/log/LoggerBuilderTest.kt b/features/dd-sdk-android-logs/src/test/kotlin/com/datadog/android/log/LoggerBuilderTest.kt index fb6a47606e..1b6a33dfed 100644 --- a/features/dd-sdk-android-logs/src/test/kotlin/com/datadog/android/log/LoggerBuilderTest.kt +++ b/features/dd-sdk-android-logs/src/test/kotlin/com/datadog/android/log/LoggerBuilderTest.kt @@ -232,6 +232,16 @@ internal class LoggerBuilderTest { assertThat(handler.bundleWithTraces).isFalse } + @Test + fun `builder can disable the bundle with rum feature`() { + val logger = Logger.Builder(mockSdkCore) + .setBundleWithRumEnabled(false) + .build() + + val handler: DatadogLogHandler = logger.handler as DatadogLogHandler + assertThat(handler.bundleWithRum).isFalse + } + @Test fun `builder can set a sample rate`(@Forgery forge: Forge) { val expectedSampleRate = forge.aFloat(min = 0.0f, max = 100.0f) diff --git a/features/dd-sdk-android-logs/src/test/kotlin/com/datadog/android/log/internal/logger/CombinedLogHandlerTest.kt b/features/dd-sdk-android-logs/src/test/kotlin/com/datadog/android/log/internal/logger/CombinedLogHandlerTest.kt index 07f166f64e..91f4f4f312 100644 --- a/features/dd-sdk-android-logs/src/test/kotlin/com/datadog/android/log/internal/logger/CombinedLogHandlerTest.kt +++ b/features/dd-sdk-android-logs/src/test/kotlin/com/datadog/android/log/internal/logger/CombinedLogHandlerTest.kt @@ -6,10 +6,13 @@ package com.datadog.android.log.internal.logger +import com.datadog.android.tests.elmyr.exhaustiveAttributes import com.datadog.android.utils.forge.Configurator import fr.xgouchet.elmyr.Forge import fr.xgouchet.elmyr.annotation.Forgery +import fr.xgouchet.elmyr.annotation.IntForgery import fr.xgouchet.elmyr.annotation.StringForgery +import fr.xgouchet.elmyr.annotation.StringForgeryType import fr.xgouchet.elmyr.junit5.ForgeConfiguration import fr.xgouchet.elmyr.junit5.ForgeExtension import org.junit.jupiter.api.BeforeEach @@ -34,34 +37,42 @@ internal class CombinedLogHandlerTest { lateinit var testedHandler: LogHandler - lateinit var mockDevLogHandlers: Array + lateinit var mockDelegateLogHandlers: Array - lateinit var fakeServiceName: String - lateinit var fakeLoggerName: String + @StringForgery lateinit var fakeMessage: String + + @StringForgery(StringForgeryType.ALPHABETICAL) lateinit var fakeTags: Set + lateinit var fakeAttributes: Map + @IntForgery(min = 2, max = 8) var fakeLevel: Int = 0 @Forgery lateinit var fakeThrowable: Throwable + @StringForgery + lateinit var fakeErrorKind: String + + @StringForgery + lateinit var fakeErrorMessage: String + + @StringForgery + lateinit var fakeErrorStackTrace: String + @BeforeEach fun `set up`(forge: Forge) { - mockDevLogHandlers = forge.aList { mock() }.toTypedArray() - fakeServiceName = forge.anAlphabeticalString() - fakeLoggerName = forge.anAlphabeticalString() - fakeMessage = forge.anAlphabeticalString() - fakeLevel = forge.anInt(2, 8) - fakeAttributes = forge.aMap { anAlphabeticalString() to anInt() } - fakeTags = forge.aList { anAlphabeticalString() }.toSet() - - testedHandler = CombinedLogHandler(*mockDevLogHandlers) + mockDelegateLogHandlers = forge.aList { mock() }.toTypedArray() + fakeAttributes = forge.exhaustiveAttributes() + + testedHandler = CombinedLogHandler(*mockDelegateLogHandlers) } @Test - fun `forwards log`() { + fun `M forward log to all delegates W handleLog {throwable}`() { + // When testedHandler.handleLog( fakeLevel, fakeMessage, @@ -70,14 +81,17 @@ internal class CombinedLogHandlerTest { fakeTags ) - mockDevLogHandlers.forEach { + // Then + mockDelegateLogHandlers.forEach { verify(it).handleLog(fakeLevel, fakeMessage, fakeThrowable, fakeAttributes, fakeTags) } } @Test - fun `forwards log on background thread`(forge: Forge) { - val threadName = forge.anAlphabeticalString() + fun `M forward log to all delegates W handleLog {throwable, background thread}`( + @StringForgery(StringForgeryType.ALPHABETICAL) threadName: String + ) { + // Given val countDownLatch = CountDownLatch(1) val thread = Thread( { @@ -93,16 +107,19 @@ internal class CombinedLogHandlerTest { threadName ) + // When thread.start() countDownLatch.await(1, TimeUnit.SECONDS) - mockDevLogHandlers.forEach { + // Then + mockDelegateLogHandlers.forEach { verify(it).handleLog(fakeLevel, fakeMessage, fakeThrowable, fakeAttributes, fakeTags) } } @Test - fun `forwards minimal log`() { + fun `M forward log to all delegates W handleLog {null throwable}`() { + // When testedHandler.handleLog( fakeLevel, fakeMessage, @@ -111,34 +128,33 @@ internal class CombinedLogHandlerTest { emptySet() ) - mockDevLogHandlers.forEach { + // Then + mockDelegateLogHandlers.forEach { verify(it).handleLog(fakeLevel, fakeMessage, null, emptyMap(), emptySet()) } } @Test - fun `forwards log with error strings`( - @StringForgery errorKind: String, - @StringForgery errorMessage: String, - @StringForgery errorStack: String - ) { + fun `M forward log to all delegates W handleLog {stacktrace}`() { + // When testedHandler.handleLog( fakeLevel, fakeMessage, - errorKind, - errorMessage, - errorStack, + fakeErrorKind, + fakeErrorMessage, + fakeErrorStackTrace, fakeAttributes, fakeTags ) - mockDevLogHandlers.forEach { + // Then + mockDelegateLogHandlers.forEach { verify(it).handleLog( fakeLevel, fakeMessage, - errorKind, - errorMessage, - errorStack, + fakeErrorKind, + fakeErrorMessage, + fakeErrorStackTrace, fakeAttributes, fakeTags ) @@ -146,22 +162,19 @@ internal class CombinedLogHandlerTest { } @Test - fun `forwards log with error strings on background thread`( - @StringForgery errorKind: String, - @StringForgery errorMessage: String, - @StringForgery errorStack: String, - forge: Forge + fun `M forward log to all delegates W handleLog {stacktrace, background thread}`( + @StringForgery(StringForgeryType.ALPHABETICAL) threadName: String ) { - val threadName = forge.anAlphabeticalString() + // Given val countDownLatch = CountDownLatch(1) val thread = Thread( { testedHandler.handleLog( fakeLevel, fakeMessage, - errorKind, - errorMessage, - errorStack, + fakeErrorKind, + fakeErrorMessage, + fakeErrorStackTrace, fakeAttributes, fakeTags ) @@ -170,19 +183,40 @@ internal class CombinedLogHandlerTest { threadName ) + // When thread.start() countDownLatch.await(1, TimeUnit.SECONDS) - mockDevLogHandlers.forEach { + // Then + mockDelegateLogHandlers.forEach { verify(it).handleLog( fakeLevel, fakeMessage, - errorKind, - errorMessage, - errorStack, + fakeErrorKind, + fakeErrorMessage, + fakeErrorStackTrace, fakeAttributes, fakeTags ) } } + + @Test + fun `M forward log to all delegates W handleLog {null stacktrace}`() { + // When + testedHandler.handleLog( + fakeLevel, + fakeMessage, + null, + null, + null, + emptyMap(), + emptySet() + ) + + // Then + mockDelegateLogHandlers.forEach { + verify(it).handleLog(fakeLevel, fakeMessage, null, null, null, emptyMap(), emptySet()) + } + } } diff --git a/features/dd-sdk-android-logs/src/test/kotlin/com/datadog/android/log/internal/logger/ConditionalLogHandlerTest.kt b/features/dd-sdk-android-logs/src/test/kotlin/com/datadog/android/log/internal/logger/ConditionalLogHandlerTest.kt index 73333f3ff0..7b46450f89 100644 --- a/features/dd-sdk-android-logs/src/test/kotlin/com/datadog/android/log/internal/logger/ConditionalLogHandlerTest.kt +++ b/features/dd-sdk-android-logs/src/test/kotlin/com/datadog/android/log/internal/logger/ConditionalLogHandlerTest.kt @@ -6,9 +6,13 @@ package com.datadog.android.log.internal.logger +import com.datadog.android.tests.elmyr.exhaustiveAttributes import com.datadog.android.utils.forge.Configurator import fr.xgouchet.elmyr.Forge import fr.xgouchet.elmyr.annotation.Forgery +import fr.xgouchet.elmyr.annotation.IntForgery +import fr.xgouchet.elmyr.annotation.StringForgery +import fr.xgouchet.elmyr.annotation.StringForgeryType import fr.xgouchet.elmyr.junit5.ForgeConfiguration import fr.xgouchet.elmyr.junit5.ForgeExtension import org.junit.jupiter.api.BeforeEach @@ -35,14 +39,17 @@ internal class ConditionalLogHandlerTest { lateinit var testedHandler: LogHandler @Mock - lateinit var mockDevLogHandler: LogHandler + lateinit var mockDelegateLogHandler: LogHandler - lateinit var fakeServiceName: String - lateinit var fakeLoggerName: String + @StringForgery lateinit var fakeMessage: String + + @StringForgery(StringForgeryType.ALPHABETICAL) lateinit var fakeTags: Set + lateinit var fakeAttributes: Map + @IntForgery(min = 2, max = 8) var fakeLevel: Int = 0 var fakeCondition = false @@ -50,24 +57,30 @@ internal class ConditionalLogHandlerTest { @Forgery lateinit var fakeThrowable: Throwable + @StringForgery + lateinit var fakeErrorKind: String + + @StringForgery + lateinit var fakeErrorMessage: String + + @StringForgery + lateinit var fakeErrorStackTrace: String + @BeforeEach fun `set up`(forge: Forge) { - fakeServiceName = forge.anAlphabeticalString() - fakeLoggerName = forge.anAlphabeticalString() - fakeMessage = forge.anAlphabeticalString() - fakeLevel = forge.anInt(2, 8) - fakeAttributes = forge.aMap { anAlphabeticalString() to anInt() } - fakeTags = forge.aList { anAlphabeticalString() }.toSet() - - testedHandler = ConditionalLogHandler(mockDevLogHandler) { _, _ -> + fakeAttributes = forge.exhaustiveAttributes() + + testedHandler = ConditionalLogHandler(mockDelegateLogHandler) { _, _ -> fakeCondition } } @Test - fun `forwards log (condition true)`() { + fun `M forward log W handleLog (throwable, condition true)`() { + // Given fakeCondition = true + // When testedHandler.handleLog( fakeLevel, fakeMessage, @@ -76,7 +89,8 @@ internal class ConditionalLogHandlerTest { fakeTags ) - verify(mockDevLogHandler).handleLog( + // Then + verify(mockDelegateLogHandler).handleLog( fakeLevel, fakeMessage, fakeThrowable, @@ -86,9 +100,11 @@ internal class ConditionalLogHandlerTest { } @Test - fun `forwards log on background thread (condition true)`(forge: Forge) { + fun `M forward log on background thread W handleLog (throwable, condition true)`( + @StringForgery threadName: String + ) { + // Given fakeCondition = true - val threadName = forge.anAlphabeticalString() val countDownLatch = CountDownLatch(1) val thread = Thread( { @@ -104,10 +120,12 @@ internal class ConditionalLogHandlerTest { threadName ) + // When thread.start() countDownLatch.await(1, TimeUnit.SECONDS) - verify(mockDevLogHandler).handleLog( + // Then + verify(mockDelegateLogHandler).handleLog( fakeLevel, fakeMessage, fakeThrowable, @@ -117,9 +135,11 @@ internal class ConditionalLogHandlerTest { } @Test - fun `forwards minimal log (condition true)`() { + fun `M forward minimal log W handleLog (null throwable, condition true)`() { + // Given fakeCondition = true + // When testedHandler.handleLog( fakeLevel, fakeMessage, @@ -128,7 +148,8 @@ internal class ConditionalLogHandlerTest { emptySet() ) - verify(mockDevLogHandler).handleLog( + // Then + verify(mockDelegateLogHandler).handleLog( fakeLevel, fakeMessage, null, @@ -138,9 +159,11 @@ internal class ConditionalLogHandlerTest { } @Test - fun `forwards log (condition false)`() { + fun `M not forward log W handleLog (throwable, condition false)`() { + // Given fakeCondition = false + // When testedHandler.handleLog( fakeLevel, fakeMessage, @@ -149,13 +172,16 @@ internal class ConditionalLogHandlerTest { fakeTags ) - verifyNoInteractions(mockDevLogHandler) + // Then + verifyNoInteractions(mockDelegateLogHandler) } @Test - fun `forwards log on background thread (condition false)`(forge: Forge) { + fun `M not forward log on background thread W handleLog (throwable, condition false)`( + @StringForgery threadName: String + ) { + // Given fakeCondition = false - val threadName = forge.anAlphabeticalString() val countDownLatch = CountDownLatch(1) val thread = Thread( { @@ -171,24 +197,195 @@ internal class ConditionalLogHandlerTest { threadName ) + // When + thread.start() + countDownLatch.await(1, TimeUnit.SECONDS) + + // Then + verifyNoInteractions(mockDelegateLogHandler) + } + + @Test + fun `M not forward minimal log W handleLog (null throwable, condition false)`() { + // Given + fakeCondition = false + + // When + testedHandler.handleLog( + fakeLevel, + fakeMessage, + null, + emptyMap(), + emptySet() + ) + + // Then + verifyNoInteractions(mockDelegateLogHandler) + } + + @Test + fun `M forward log W handleLog (stacktrace, condition true)`() { + // Given + fakeCondition = true + + // When + testedHandler.handleLog( + fakeLevel, + fakeMessage, + fakeErrorKind, + fakeErrorMessage, + fakeErrorStackTrace, + fakeAttributes, + fakeTags + ) + + // Then + verify(mockDelegateLogHandler).handleLog( + fakeLevel, + fakeMessage, + fakeErrorKind, + fakeErrorMessage, + fakeErrorStackTrace, + fakeAttributes, + fakeTags + ) + } + + @Test + fun `M forward log on background thread W handleLog (stacktrace, condition true)`( + @StringForgery threadName: String + ) { + // Given + fakeCondition = true + val countDownLatch = CountDownLatch(1) + val thread = Thread( + { + testedHandler.handleLog( + fakeLevel, + fakeMessage, + fakeErrorKind, + fakeErrorMessage, + fakeErrorStackTrace, + fakeAttributes, + fakeTags + ) + countDownLatch.countDown() + }, + threadName + ) + + // When + thread.start() + countDownLatch.await(1, TimeUnit.SECONDS) + + // Then + verify(mockDelegateLogHandler).handleLog( + fakeLevel, + fakeMessage, + fakeErrorKind, + fakeErrorMessage, + fakeErrorStackTrace, + fakeAttributes, + fakeTags + ) + } + + @Test + fun `M forward minimal log W handleLog (null stacktrace, condition true)`() { + // Given + fakeCondition = true + + // When + testedHandler.handleLog( + fakeLevel, + fakeMessage, + null, + null, + null, + emptyMap(), + emptySet() + ) + + // Then + verify(mockDelegateLogHandler).handleLog( + fakeLevel, + fakeMessage, + null, + null, + null, + emptyMap(), + emptySet() + ) + } + + @Test + fun `M not forward log W handleLog (stacktrace, condition false)`() { + // Given + fakeCondition = false + + // When + testedHandler.handleLog( + fakeLevel, + fakeMessage, + fakeErrorKind, + fakeErrorMessage, + fakeErrorStackTrace, + fakeAttributes, + fakeTags + ) + + // Then + verifyNoInteractions(mockDelegateLogHandler) + } + + @Test + fun `M not forward log on background thread W handleLog (stacktrace, condition false)`( + @StringForgery threadName: String + ) { + // Given + fakeCondition = false + val countDownLatch = CountDownLatch(1) + val thread = Thread( + { + testedHandler.handleLog( + fakeLevel, + fakeMessage, + fakeErrorKind, + fakeErrorMessage, + fakeErrorStackTrace, + fakeAttributes, + fakeTags + ) + countDownLatch.countDown() + }, + threadName + ) + + // When thread.start() countDownLatch.await(1, TimeUnit.SECONDS) - verifyNoInteractions(mockDevLogHandler) + // Then + verifyNoInteractions(mockDelegateLogHandler) } @Test - fun `forwards minimal log (condition false)`() { + fun `M not forward minimal log W handleLog (null stacktrace, condition false)`() { + // Given fakeCondition = false + // When testedHandler.handleLog( fakeLevel, fakeMessage, null, + null, + null, emptyMap(), emptySet() ) - verifyNoInteractions(mockDevLogHandler) + // Then + verifyNoInteractions(mockDelegateLogHandler) } } diff --git a/features/dd-sdk-android-logs/src/test/kotlin/com/datadog/android/log/internal/net/LogsRequestFactoryTest.kt b/features/dd-sdk-android-logs/src/test/kotlin/com/datadog/android/log/internal/net/LogsRequestFactoryTest.kt index af6cb0717a..472a6c85be 100644 --- a/features/dd-sdk-android-logs/src/test/kotlin/com/datadog/android/log/internal/net/LogsRequestFactoryTest.kt +++ b/features/dd-sdk-android-logs/src/test/kotlin/com/datadog/android/log/internal/net/LogsRequestFactoryTest.kt @@ -8,6 +8,7 @@ package com.datadog.android.log.internal.net import com.datadog.android.api.InternalLogger import com.datadog.android.api.context.DatadogContext +import com.datadog.android.api.net.RequestExecutionContext import com.datadog.android.api.net.RequestFactory import com.datadog.android.api.storage.RawBatchEvent import com.datadog.android.core.internal.utils.join @@ -51,6 +52,7 @@ internal class LogsRequestFactoryTest { @Test fun `M create a proper request W create()`( @Forgery batchData: List, + @Forgery executionContext: RequestExecutionContext, @StringForgery batchMetadata: String, forge: Forge ) { @@ -58,7 +60,7 @@ internal class LogsRequestFactoryTest { val batchMetadata = forge.aNullable { batchMetadata.toByteArray() } // When - val request = testedFactory.create(fakeDatadogContext, batchData, batchMetadata) + val request = testedFactory.create(fakeDatadogContext, executionContext, batchData, batchMetadata) // Then requireNotNull(request) @@ -94,6 +96,7 @@ internal class LogsRequestFactoryTest { @StringForgery(regex = "https://[a-z]+\\.com") fakeEndpoint: String, @Forgery batchData: List, @StringForgery batchMetadata: String, + @Forgery executionContext: RequestExecutionContext, forge: Forge ) { // Given @@ -104,7 +107,7 @@ internal class LogsRequestFactoryTest { val batchMetadata = forge.aNullable { batchMetadata.toByteArray() } // When - val request = testedFactory.create(fakeDatadogContext, batchData, batchMetadata) + val request = testedFactory.create(fakeDatadogContext, executionContext, batchData, batchMetadata) // Then requireNotNull(request) diff --git a/features/dd-sdk-android-ndk/build.gradle.kts b/features/dd-sdk-android-ndk/build.gradle.kts index 45358199f8..07721a769c 100644 --- a/features/dd-sdk-android-ndk/build.gradle.kts +++ b/features/dd-sdk-android-ndk/build.gradle.kts @@ -8,6 +8,7 @@ import com.datadog.gradle.Dependencies import com.datadog.gradle.config.AndroidConfig import com.datadog.gradle.config.androidLibraryConfig import com.datadog.gradle.config.dependencyUpdateConfig +import com.datadog.gradle.config.detektCustomConfig import com.datadog.gradle.config.javadocConfig import com.datadog.gradle.config.junitConfig import com.datadog.gradle.config.kotlinConfig @@ -108,3 +109,4 @@ dependencyUpdateConfig() publishingConfig( "An NDK integration to use with the Datadog monitoring library for Android applications." ) +detektCustomConfig(":dd-sdk-android-core") diff --git a/features/dd-sdk-android-ndk/src/main/kotlin/com/datadog/android/ndk/internal/NdkCrashReportsFeature.kt b/features/dd-sdk-android-ndk/src/main/kotlin/com/datadog/android/ndk/internal/NdkCrashReportsFeature.kt index 63223a91c2..e286abb6d8 100644 --- a/features/dd-sdk-android-ndk/src/main/kotlin/com/datadog/android/ndk/internal/NdkCrashReportsFeature.kt +++ b/features/dd-sdk-android-ndk/src/main/kotlin/com/datadog/android/ndk/internal/NdkCrashReportsFeature.kt @@ -12,6 +12,7 @@ import com.datadog.android.api.feature.Feature import com.datadog.android.api.feature.FeatureSdkCore import com.datadog.android.core.InternalSdkCore import com.datadog.android.core.allowThreadDiskReads +import com.datadog.android.core.allowThreadDiskWrites import com.datadog.android.privacy.TrackingConsent import com.datadog.android.privacy.TrackingConsentProviderCallback import java.io.File @@ -51,7 +52,7 @@ internal class NdkCrashReportsFeature( NDK_CRASH_REPORTS_FOLDER ) try { - allowThreadDiskReads { + allowThreadDiskWrites { ndkCrashesDirs.mkdirs() } } catch (e: SecurityException) { diff --git a/features/dd-sdk-android-rum/api/apiSurface b/features/dd-sdk-android-rum/api/apiSurface index 417417fc22..3b1d07bb39 100644 --- a/features/dd-sdk-android-rum/api/apiSurface +++ b/features/dd-sdk-android-rum/api/apiSurface @@ -6,6 +6,7 @@ class com.datadog.android.rum.DdRumContentProvider : android.content.ContentProv override fun insert(android.net.Uri, android.content.ContentValues?): android.net.Uri? override fun delete(android.net.Uri, String?, Array?): Int override fun update(android.net.Uri, android.content.ContentValues?, String?, Array?): Int +annotation com.datadog.android.rum.ExperimentalRumApi typealias GlobalRum = GlobalRumMonitor object com.datadog.android.rum.GlobalRumMonitor fun isRegistered(com.datadog.android.api.SdkCore = Datadog.getInstance()): Boolean @@ -111,6 +112,7 @@ interface com.datadog.android.rum.RumMonitor fun getAttributes(): Map fun clearAttributes() fun stopSession() + fun addViewLoadingTime(Boolean) var debug: Boolean fun _getInternal(): _RumInternalProxy? enum com.datadog.android.rum.RumPerformanceMetric @@ -164,7 +166,7 @@ interface com.datadog.android.rum.event.ViewEventMapper : com.datadog.android.ev override fun map(com.datadog.android.rum.model.ViewEvent): com.datadog.android.rum.model.ViewEvent data class com.datadog.android.rum.internal.domain.event.ResourceTiming constructor(Long = 0L, Long = 0L, Long = 0L, Long = 0L, Long = 0L, Long = 0L, Long = 0L, Long = 0L, Long = 0L, Long = 0L) -interface com.datadog.android.rum.internal.monitor.AdvancedNetworkRumMonitor +interface com.datadog.android.rum.internal.monitor.AdvancedNetworkRumMonitor : com.datadog.android.rum.RumMonitor fun waitForResourceTiming(Any) fun addResourceTiming(Any, com.datadog.android.rum.internal.domain.event.ResourceTiming) fun notifyInterceptorInstantiated() @@ -221,8 +223,6 @@ abstract class com.datadog.android.rum.tracking.ActivityLifecycleTrackingStrateg override fun onActivityStopped(android.app.Activity) override fun onActivityCreated(android.app.Activity, android.os.Bundle?) override fun onActivityResumed(android.app.Activity) - protected fun convertToRumAttributes(android.content.Intent?): Map - protected fun convertToRumAttributes(android.os.Bundle?): Map protected fun withSdkCore((com.datadog.android.api.feature.FeatureSdkCore) -> T): T? class com.datadog.android.rum.tracking.ActivityViewTrackingStrategy : ActivityLifecycleTrackingStrategy, ViewTrackingStrategy constructor(Boolean, ComponentPredicate = AcceptAllActivities()) @@ -230,6 +230,7 @@ class com.datadog.android.rum.tracking.ActivityViewTrackingStrategy : ActivityLi override fun onActivityStopped(android.app.Activity) override fun equals(Any?): Boolean override fun hashCode(): Int +fun android.os.Bundle?.convertToRumViewAttributes(): Map interface com.datadog.android.rum.tracking.ComponentPredicate fun accept(T): Boolean fun getViewName(T): String? @@ -791,6 +792,8 @@ data class com.datadog.android.rum.model.ErrorEvent - ANR - APP_HANG - EXCEPTION + - WATCHDOG_TERMINATION + - MEMORY_WARNING fun toJson(): com.google.gson.JsonElement companion object fun fromJson(kotlin.String): Category @@ -1965,3 +1968,165 @@ data class com.datadog.android.telemetry.model.TelemetryErrorEvent fun toJson(): com.google.gson.JsonElement companion object fun fromJson(kotlin.String): Source +data class com.datadog.android.telemetry.model.TelemetryUsageEvent + constructor(Dd, kotlin.Long, kotlin.String, Source, kotlin.String, Application? = null, Session? = null, View? = null, Action? = null, kotlin.collections.List? = null, Telemetry) + val type: kotlin.String + fun toJson(): com.google.gson.JsonElement + companion object + fun fromJson(kotlin.String): TelemetryUsageEvent + fun fromJsonObject(com.google.gson.JsonObject): TelemetryUsageEvent + class Dd + val formatVersion: kotlin.Long + fun toJson(): com.google.gson.JsonElement + companion object + fun fromJson(kotlin.String): Dd + fun fromJsonObject(com.google.gson.JsonObject): Dd + data class Application + constructor(kotlin.String) + fun toJson(): com.google.gson.JsonElement + companion object + fun fromJson(kotlin.String): Application + fun fromJsonObject(com.google.gson.JsonObject): Application + data class Session + constructor(kotlin.String) + fun toJson(): com.google.gson.JsonElement + companion object + fun fromJson(kotlin.String): Session + fun fromJsonObject(com.google.gson.JsonObject): Session + data class View + constructor(kotlin.String) + fun toJson(): com.google.gson.JsonElement + companion object + fun fromJson(kotlin.String): View + fun fromJsonObject(com.google.gson.JsonObject): View + data class Action + constructor(kotlin.String) + fun toJson(): com.google.gson.JsonElement + companion object + fun fromJson(kotlin.String): Action + fun fromJsonObject(com.google.gson.JsonObject): Action + data class Telemetry + constructor(Device? = null, Os? = null, Usage, kotlin.collections.MutableMap = mutableMapOf()) + val type: kotlin.String + fun toJson(): com.google.gson.JsonElement + companion object + fun fromJson(kotlin.String): Telemetry + fun fromJsonObject(com.google.gson.JsonObject): Telemetry + data class Device + constructor(kotlin.String? = null, kotlin.String? = null, kotlin.String? = null) + fun toJson(): com.google.gson.JsonElement + companion object + fun fromJson(kotlin.String): Device + fun fromJsonObject(com.google.gson.JsonObject): Device + data class Os + constructor(kotlin.String? = null, kotlin.String? = null, kotlin.String? = null) + fun toJson(): com.google.gson.JsonElement + companion object + fun fromJson(kotlin.String): Os + fun fromJsonObject(com.google.gson.JsonObject): Os + sealed class Usage + abstract fun toJson(): com.google.gson.JsonElement + data class SetTrackingConsent : Usage + constructor(TrackingConsent) + val feature: kotlin.String + override fun toJson(): com.google.gson.JsonElement + companion object + fun fromJson(kotlin.String): SetTrackingConsent + fun fromJsonObject(com.google.gson.JsonObject): SetTrackingConsent + class StopSession : Usage + val feature: kotlin.String + override fun toJson(): com.google.gson.JsonElement + companion object + fun fromJson(kotlin.String): StopSession + fun fromJsonObject(com.google.gson.JsonObject): StopSession + class StartView : Usage + val feature: kotlin.String + override fun toJson(): com.google.gson.JsonElement + companion object + fun fromJson(kotlin.String): StartView + fun fromJsonObject(com.google.gson.JsonObject): StartView + class AddAction : Usage + val feature: kotlin.String + override fun toJson(): com.google.gson.JsonElement + companion object + fun fromJson(kotlin.String): AddAction + fun fromJsonObject(com.google.gson.JsonObject): AddAction + class AddError : Usage + val feature: kotlin.String + override fun toJson(): com.google.gson.JsonElement + companion object + fun fromJson(kotlin.String): AddError + fun fromJsonObject(com.google.gson.JsonObject): AddError + class SetGlobalContext : Usage + val feature: kotlin.String + override fun toJson(): com.google.gson.JsonElement + companion object + fun fromJson(kotlin.String): SetGlobalContext + fun fromJsonObject(com.google.gson.JsonObject): SetGlobalContext + class SetUser : Usage + val feature: kotlin.String + override fun toJson(): com.google.gson.JsonElement + companion object + fun fromJson(kotlin.String): SetUser + fun fromJsonObject(com.google.gson.JsonObject): SetUser + class AddFeatureFlagEvaluation : Usage + val feature: kotlin.String + override fun toJson(): com.google.gson.JsonElement + companion object + fun fromJson(kotlin.String): AddFeatureFlagEvaluation + fun fromJsonObject(com.google.gson.JsonObject): AddFeatureFlagEvaluation + data class TelemetryBrowserFeaturesUsage_0 : Usage + constructor(kotlin.Boolean? = null) + val feature: kotlin.String + override fun toJson(): com.google.gson.JsonElement + companion object + fun fromJson(kotlin.String): TelemetryBrowserFeaturesUsage_0 + fun fromJsonObject(com.google.gson.JsonObject): TelemetryBrowserFeaturesUsage_0 + class TelemetryBrowserFeaturesUsage_1 : Usage + val feature: kotlin.String + override fun toJson(): com.google.gson.JsonElement + companion object + fun fromJson(kotlin.String): TelemetryBrowserFeaturesUsage_1 + fun fromJsonObject(com.google.gson.JsonObject): TelemetryBrowserFeaturesUsage_1 + class TelemetryBrowserFeaturesUsage_2 : Usage + val feature: kotlin.String + override fun toJson(): com.google.gson.JsonElement + companion object + fun fromJson(kotlin.String): TelemetryBrowserFeaturesUsage_2 + fun fromJsonObject(com.google.gson.JsonObject): TelemetryBrowserFeaturesUsage_2 + class TelemetryBrowserFeaturesUsage_3 : Usage + val feature: kotlin.String + override fun toJson(): com.google.gson.JsonElement + companion object + fun fromJson(kotlin.String): TelemetryBrowserFeaturesUsage_3 + fun fromJsonObject(com.google.gson.JsonObject): TelemetryBrowserFeaturesUsage_3 + data class AddViewLoadingTime : Usage + constructor(kotlin.Boolean, kotlin.Boolean, kotlin.Boolean) + val feature: kotlin.String + override fun toJson(): com.google.gson.JsonElement + companion object + fun fromJson(kotlin.String): AddViewLoadingTime + fun fromJsonObject(com.google.gson.JsonObject): AddViewLoadingTime + companion object + fun fromJson(kotlin.String): Usage + fun fromJsonObject(com.google.gson.JsonObject): Usage + enum Source + constructor(kotlin.String) + - ANDROID + - IOS + - BROWSER + - FLUTTER + - REACT_NATIVE + - UNITY + - KOTLIN_MULTIPLATFORM + fun toJson(): com.google.gson.JsonElement + companion object + fun fromJson(kotlin.String): Source + enum TrackingConsent + constructor(kotlin.String) + - GRANTED + - NOT_GRANTED + - PENDING + fun toJson(): com.google.gson.JsonElement + companion object + fun fromJson(kotlin.String): TrackingConsent diff --git a/features/dd-sdk-android-rum/api/dd-sdk-android-rum.api b/features/dd-sdk-android-rum/api/dd-sdk-android-rum.api index 7dfe4e03b7..63bfc6cc69 100644 --- a/features/dd-sdk-android-rum/api/dd-sdk-android-rum.api +++ b/features/dd-sdk-android-rum/api/dd-sdk-android-rum.api @@ -13,6 +13,9 @@ public final class com/datadog/android/rum/DdRumContentProvider : android/conten public fun update (Landroid/net/Uri;Landroid/content/ContentValues;Ljava/lang/String;[Ljava/lang/String;)I } +public abstract interface annotation class com/datadog/android/rum/ExperimentalRumApi : java/lang/annotation/Annotation { +} + public final class com/datadog/android/rum/GlobalRumMonitor { public static final field INSTANCE Lcom/datadog/android/rum/GlobalRumMonitor; public static final fun get ()Lcom/datadog/android/rum/RumMonitor; @@ -141,6 +144,7 @@ public abstract interface class com/datadog/android/rum/RumMonitor { public abstract fun addFeatureFlagEvaluation (Ljava/lang/String;Ljava/lang/Object;)V public abstract fun addFeatureFlagEvaluations (Ljava/util/Map;)V public abstract fun addTiming (Ljava/lang/String;)V + public abstract fun addViewLoadingTime (Z)V public abstract fun clearAttributes ()V public abstract fun getAttributes ()Ljava/util/Map; public abstract fun getCurrentSessionId (Lkotlin/jvm/functions/Function1;)V @@ -283,7 +287,7 @@ public abstract class com/datadog/android/rum/internal/instrumentation/gestures/ public fun onScroll (Landroid/view/MotionEvent;Landroid/view/MotionEvent;FF)Z } -public abstract interface class com/datadog/android/rum/internal/monitor/AdvancedNetworkRumMonitor { +public abstract interface class com/datadog/android/rum/internal/monitor/AdvancedNetworkRumMonitor : com/datadog/android/rum/RumMonitor { public abstract fun addResourceTiming (Ljava/lang/Object;Lcom/datadog/android/rum/internal/domain/event/ResourceTiming;)V public abstract fun notifyInterceptorInstantiated ()V public abstract fun startResource (Lcom/datadog/android/rum/resource/ResourceId;Lcom/datadog/android/rum/RumResourceMethod;Ljava/lang/String;Ljava/util/Map;)V @@ -1340,6 +1344,8 @@ public final class com/datadog/android/rum/model/ErrorEvent$Category : java/lang public static final field APP_HANG Lcom/datadog/android/rum/model/ErrorEvent$Category; public static final field Companion Lcom/datadog/android/rum/model/ErrorEvent$Category$Companion; public static final field EXCEPTION Lcom/datadog/android/rum/model/ErrorEvent$Category; + public static final field MEMORY_WARNING Lcom/datadog/android/rum/model/ErrorEvent$Category; + public static final field WATCHDOG_TERMINATION Lcom/datadog/android/rum/model/ErrorEvent$Category; public static final fun fromJson (Ljava/lang/String;)Lcom/datadog/android/rum/model/ErrorEvent$Category; public final fun toJson ()Lcom/google/gson/JsonElement; public static fun valueOf (Ljava/lang/String;)Lcom/datadog/android/rum/model/ErrorEvent$Category; @@ -5056,8 +5062,6 @@ public class com/datadog/android/rum/tracking/AcceptAllSupportFragments : com/da public abstract class com/datadog/android/rum/tracking/ActivityLifecycleTrackingStrategy : android/app/Application$ActivityLifecycleCallbacks, com/datadog/android/rum/tracking/TrackingStrategy { protected field sdkCore Lcom/datadog/android/api/feature/FeatureSdkCore; public fun ()V - protected final fun convertToRumAttributes (Landroid/content/Intent;)Ljava/util/Map; - protected final fun convertToRumAttributes (Landroid/os/Bundle;)Ljava/util/Map; protected final fun getSdkCore ()Lcom/datadog/android/api/feature/FeatureSdkCore; public fun onActivityCreated (Landroid/app/Activity;Landroid/os/Bundle;)V public fun onActivityDestroyed (Landroid/app/Activity;)V @@ -5082,6 +5086,10 @@ public final class com/datadog/android/rum/tracking/ActivityViewTrackingStrategy public fun onActivityStopped (Landroid/app/Activity;)V } +public final class com/datadog/android/rum/tracking/BundleExtKt { + public static final fun convertToRumViewAttributes (Landroid/os/Bundle;)Ljava/util/Map; +} + public abstract interface class com/datadog/android/rum/tracking/ComponentPredicate { public abstract fun accept (Ljava/lang/Object;)Z public abstract fun getViewName (Ljava/lang/Object;)Ljava/lang/String; @@ -6158,3 +6166,474 @@ public final class com/datadog/android/telemetry/model/TelemetryErrorEvent$View$ public final fun fromJsonObject (Lcom/google/gson/JsonObject;)Lcom/datadog/android/telemetry/model/TelemetryErrorEvent$View; } +public final class com/datadog/android/telemetry/model/TelemetryUsageEvent { + public static final field Companion Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Companion; + public fun (Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Dd;JLjava/lang/String;Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Source;Ljava/lang/String;Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Application;Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Session;Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$View;Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Action;Ljava/util/List;Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Telemetry;)V + public synthetic fun (Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Dd;JLjava/lang/String;Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Source;Ljava/lang/String;Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Application;Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Session;Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$View;Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Action;Ljava/util/List;Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Telemetry;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Dd; + public final fun component10 ()Ljava/util/List; + public final fun component11 ()Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Telemetry; + public final fun component2 ()J + public final fun component3 ()Ljava/lang/String; + public final fun component4 ()Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Source; + public final fun component5 ()Ljava/lang/String; + public final fun component6 ()Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Application; + public final fun component7 ()Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Session; + public final fun component8 ()Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$View; + public final fun component9 ()Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Action; + public final fun copy (Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Dd;JLjava/lang/String;Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Source;Ljava/lang/String;Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Application;Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Session;Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$View;Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Action;Ljava/util/List;Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Telemetry;)Lcom/datadog/android/telemetry/model/TelemetryUsageEvent; + public static synthetic fun copy$default (Lcom/datadog/android/telemetry/model/TelemetryUsageEvent;Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Dd;JLjava/lang/String;Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Source;Ljava/lang/String;Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Application;Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Session;Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$View;Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Action;Ljava/util/List;Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Telemetry;ILjava/lang/Object;)Lcom/datadog/android/telemetry/model/TelemetryUsageEvent; + public fun equals (Ljava/lang/Object;)Z + public static final fun fromJson (Ljava/lang/String;)Lcom/datadog/android/telemetry/model/TelemetryUsageEvent; + public static final fun fromJsonObject (Lcom/google/gson/JsonObject;)Lcom/datadog/android/telemetry/model/TelemetryUsageEvent; + public final fun getAction ()Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Action; + public final fun getApplication ()Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Application; + public final fun getDate ()J + public final fun getDd ()Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Dd; + public final fun getExperimentalFeatures ()Ljava/util/List; + public final fun getService ()Ljava/lang/String; + public final fun getSession ()Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Session; + public final fun getSource ()Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Source; + public final fun getTelemetry ()Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Telemetry; + public final fun getType ()Ljava/lang/String; + public final fun getVersion ()Ljava/lang/String; + public final fun getView ()Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$View; + public fun hashCode ()I + public final fun toJson ()Lcom/google/gson/JsonElement; + public fun toString ()Ljava/lang/String; +} + +public final class com/datadog/android/telemetry/model/TelemetryUsageEvent$Action { + public static final field Companion Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Action$Companion; + public fun (Ljava/lang/String;)V + public final fun component1 ()Ljava/lang/String; + public final fun copy (Ljava/lang/String;)Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Action; + public static synthetic fun copy$default (Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Action;Ljava/lang/String;ILjava/lang/Object;)Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Action; + public fun equals (Ljava/lang/Object;)Z + public static final fun fromJson (Ljava/lang/String;)Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Action; + public static final fun fromJsonObject (Lcom/google/gson/JsonObject;)Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Action; + public final fun getId ()Ljava/lang/String; + public fun hashCode ()I + public final fun toJson ()Lcom/google/gson/JsonElement; + public fun toString ()Ljava/lang/String; +} + +public final class com/datadog/android/telemetry/model/TelemetryUsageEvent$Action$Companion { + public final fun fromJson (Ljava/lang/String;)Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Action; + public final fun fromJsonObject (Lcom/google/gson/JsonObject;)Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Action; +} + +public final class com/datadog/android/telemetry/model/TelemetryUsageEvent$Application { + public static final field Companion Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Application$Companion; + public fun (Ljava/lang/String;)V + public final fun component1 ()Ljava/lang/String; + public final fun copy (Ljava/lang/String;)Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Application; + public static synthetic fun copy$default (Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Application;Ljava/lang/String;ILjava/lang/Object;)Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Application; + public fun equals (Ljava/lang/Object;)Z + public static final fun fromJson (Ljava/lang/String;)Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Application; + public static final fun fromJsonObject (Lcom/google/gson/JsonObject;)Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Application; + public final fun getId ()Ljava/lang/String; + public fun hashCode ()I + public final fun toJson ()Lcom/google/gson/JsonElement; + public fun toString ()Ljava/lang/String; +} + +public final class com/datadog/android/telemetry/model/TelemetryUsageEvent$Application$Companion { + public final fun fromJson (Ljava/lang/String;)Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Application; + public final fun fromJsonObject (Lcom/google/gson/JsonObject;)Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Application; +} + +public final class com/datadog/android/telemetry/model/TelemetryUsageEvent$Companion { + public final fun fromJson (Ljava/lang/String;)Lcom/datadog/android/telemetry/model/TelemetryUsageEvent; + public final fun fromJsonObject (Lcom/google/gson/JsonObject;)Lcom/datadog/android/telemetry/model/TelemetryUsageEvent; +} + +public final class com/datadog/android/telemetry/model/TelemetryUsageEvent$Dd { + public static final field Companion Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Dd$Companion; + public fun ()V + public static final fun fromJson (Ljava/lang/String;)Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Dd; + public static final fun fromJsonObject (Lcom/google/gson/JsonObject;)Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Dd; + public final fun getFormatVersion ()J + public final fun toJson ()Lcom/google/gson/JsonElement; +} + +public final class com/datadog/android/telemetry/model/TelemetryUsageEvent$Dd$Companion { + public final fun fromJson (Ljava/lang/String;)Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Dd; + public final fun fromJsonObject (Lcom/google/gson/JsonObject;)Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Dd; +} + +public final class com/datadog/android/telemetry/model/TelemetryUsageEvent$Device { + public static final field Companion Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Device$Companion; + public fun ()V + public fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V + public synthetic fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Ljava/lang/String; + public final fun component2 ()Ljava/lang/String; + public final fun component3 ()Ljava/lang/String; + public final fun copy (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Device; + public static synthetic fun copy$default (Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Device;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Device; + public fun equals (Ljava/lang/Object;)Z + public static final fun fromJson (Ljava/lang/String;)Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Device; + public static final fun fromJsonObject (Lcom/google/gson/JsonObject;)Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Device; + public final fun getArchitecture ()Ljava/lang/String; + public final fun getBrand ()Ljava/lang/String; + public final fun getModel ()Ljava/lang/String; + public fun hashCode ()I + public final fun toJson ()Lcom/google/gson/JsonElement; + public fun toString ()Ljava/lang/String; +} + +public final class com/datadog/android/telemetry/model/TelemetryUsageEvent$Device$Companion { + public final fun fromJson (Ljava/lang/String;)Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Device; + public final fun fromJsonObject (Lcom/google/gson/JsonObject;)Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Device; +} + +public final class com/datadog/android/telemetry/model/TelemetryUsageEvent$Os { + public static final field Companion Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Os$Companion; + public fun ()V + public fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V + public synthetic fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Ljava/lang/String; + public final fun component2 ()Ljava/lang/String; + public final fun component3 ()Ljava/lang/String; + public final fun copy (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Os; + public static synthetic fun copy$default (Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Os;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Os; + public fun equals (Ljava/lang/Object;)Z + public static final fun fromJson (Ljava/lang/String;)Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Os; + public static final fun fromJsonObject (Lcom/google/gson/JsonObject;)Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Os; + public final fun getBuild ()Ljava/lang/String; + public final fun getName ()Ljava/lang/String; + public final fun getVersion ()Ljava/lang/String; + public fun hashCode ()I + public final fun toJson ()Lcom/google/gson/JsonElement; + public fun toString ()Ljava/lang/String; +} + +public final class com/datadog/android/telemetry/model/TelemetryUsageEvent$Os$Companion { + public final fun fromJson (Ljava/lang/String;)Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Os; + public final fun fromJsonObject (Lcom/google/gson/JsonObject;)Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Os; +} + +public final class com/datadog/android/telemetry/model/TelemetryUsageEvent$Session { + public static final field Companion Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Session$Companion; + public fun (Ljava/lang/String;)V + public final fun component1 ()Ljava/lang/String; + public final fun copy (Ljava/lang/String;)Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Session; + public static synthetic fun copy$default (Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Session;Ljava/lang/String;ILjava/lang/Object;)Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Session; + public fun equals (Ljava/lang/Object;)Z + public static final fun fromJson (Ljava/lang/String;)Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Session; + public static final fun fromJsonObject (Lcom/google/gson/JsonObject;)Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Session; + public final fun getId ()Ljava/lang/String; + public fun hashCode ()I + public final fun toJson ()Lcom/google/gson/JsonElement; + public fun toString ()Ljava/lang/String; +} + +public final class com/datadog/android/telemetry/model/TelemetryUsageEvent$Session$Companion { + public final fun fromJson (Ljava/lang/String;)Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Session; + public final fun fromJsonObject (Lcom/google/gson/JsonObject;)Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Session; +} + +public final class com/datadog/android/telemetry/model/TelemetryUsageEvent$Source : java/lang/Enum { + public static final field ANDROID Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Source; + public static final field BROWSER Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Source; + public static final field Companion Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Source$Companion; + public static final field FLUTTER Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Source; + public static final field IOS Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Source; + public static final field KOTLIN_MULTIPLATFORM Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Source; + public static final field REACT_NATIVE Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Source; + public static final field UNITY Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Source; + public static final fun fromJson (Ljava/lang/String;)Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Source; + public final fun toJson ()Lcom/google/gson/JsonElement; + public static fun valueOf (Ljava/lang/String;)Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Source; + public static fun values ()[Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Source; +} + +public final class com/datadog/android/telemetry/model/TelemetryUsageEvent$Source$Companion { + public final fun fromJson (Ljava/lang/String;)Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Source; +} + +public final class com/datadog/android/telemetry/model/TelemetryUsageEvent$Telemetry { + public static final field Companion Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Telemetry$Companion; + public fun (Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Device;Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Os;Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Usage;Ljava/util/Map;)V + public synthetic fun (Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Device;Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Os;Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Usage;Ljava/util/Map;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Device; + public final fun component2 ()Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Os; + public final fun component3 ()Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Usage; + public final fun component4 ()Ljava/util/Map; + public final fun copy (Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Device;Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Os;Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Usage;Ljava/util/Map;)Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Telemetry; + public static synthetic fun copy$default (Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Telemetry;Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Device;Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Os;Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Usage;Ljava/util/Map;ILjava/lang/Object;)Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Telemetry; + public fun equals (Ljava/lang/Object;)Z + public static final fun fromJson (Ljava/lang/String;)Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Telemetry; + public static final fun fromJsonObject (Lcom/google/gson/JsonObject;)Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Telemetry; + public final fun getAdditionalProperties ()Ljava/util/Map; + public final fun getDevice ()Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Device; + public final fun getOs ()Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Os; + public final fun getType ()Ljava/lang/String; + public final fun getUsage ()Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Usage; + public fun hashCode ()I + public final fun toJson ()Lcom/google/gson/JsonElement; + public fun toString ()Ljava/lang/String; +} + +public final class com/datadog/android/telemetry/model/TelemetryUsageEvent$Telemetry$Companion { + public final fun fromJson (Ljava/lang/String;)Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Telemetry; + public final fun fromJsonObject (Lcom/google/gson/JsonObject;)Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Telemetry; +} + +public final class com/datadog/android/telemetry/model/TelemetryUsageEvent$TrackingConsent : java/lang/Enum { + public static final field Companion Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$TrackingConsent$Companion; + public static final field GRANTED Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$TrackingConsent; + public static final field NOT_GRANTED Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$TrackingConsent; + public static final field PENDING Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$TrackingConsent; + public static final fun fromJson (Ljava/lang/String;)Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$TrackingConsent; + public final fun toJson ()Lcom/google/gson/JsonElement; + public static fun valueOf (Ljava/lang/String;)Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$TrackingConsent; + public static fun values ()[Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$TrackingConsent; +} + +public final class com/datadog/android/telemetry/model/TelemetryUsageEvent$TrackingConsent$Companion { + public final fun fromJson (Ljava/lang/String;)Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$TrackingConsent; +} + +public abstract class com/datadog/android/telemetry/model/TelemetryUsageEvent$Usage { + public static final field Companion Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Usage$Companion; + public static final fun fromJson (Ljava/lang/String;)Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Usage; + public static final fun fromJsonObject (Lcom/google/gson/JsonObject;)Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Usage; + public abstract fun toJson ()Lcom/google/gson/JsonElement; +} + +public final class com/datadog/android/telemetry/model/TelemetryUsageEvent$Usage$AddAction : com/datadog/android/telemetry/model/TelemetryUsageEvent$Usage { + public static final field Companion Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Usage$AddAction$Companion; + public fun ()V + public static final fun fromJson (Ljava/lang/String;)Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Usage$AddAction; + public static final fun fromJsonObject (Lcom/google/gson/JsonObject;)Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Usage$AddAction; + public final fun getFeature ()Ljava/lang/String; + public fun toJson ()Lcom/google/gson/JsonElement; +} + +public final class com/datadog/android/telemetry/model/TelemetryUsageEvent$Usage$AddAction$Companion { + public final fun fromJson (Ljava/lang/String;)Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Usage$AddAction; + public final fun fromJsonObject (Lcom/google/gson/JsonObject;)Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Usage$AddAction; +} + +public final class com/datadog/android/telemetry/model/TelemetryUsageEvent$Usage$AddError : com/datadog/android/telemetry/model/TelemetryUsageEvent$Usage { + public static final field Companion Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Usage$AddError$Companion; + public fun ()V + public static final fun fromJson (Ljava/lang/String;)Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Usage$AddError; + public static final fun fromJsonObject (Lcom/google/gson/JsonObject;)Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Usage$AddError; + public final fun getFeature ()Ljava/lang/String; + public fun toJson ()Lcom/google/gson/JsonElement; +} + +public final class com/datadog/android/telemetry/model/TelemetryUsageEvent$Usage$AddError$Companion { + public final fun fromJson (Ljava/lang/String;)Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Usage$AddError; + public final fun fromJsonObject (Lcom/google/gson/JsonObject;)Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Usage$AddError; +} + +public final class com/datadog/android/telemetry/model/TelemetryUsageEvent$Usage$AddFeatureFlagEvaluation : com/datadog/android/telemetry/model/TelemetryUsageEvent$Usage { + public static final field Companion Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Usage$AddFeatureFlagEvaluation$Companion; + public fun ()V + public static final fun fromJson (Ljava/lang/String;)Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Usage$AddFeatureFlagEvaluation; + public static final fun fromJsonObject (Lcom/google/gson/JsonObject;)Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Usage$AddFeatureFlagEvaluation; + public final fun getFeature ()Ljava/lang/String; + public fun toJson ()Lcom/google/gson/JsonElement; +} + +public final class com/datadog/android/telemetry/model/TelemetryUsageEvent$Usage$AddFeatureFlagEvaluation$Companion { + public final fun fromJson (Ljava/lang/String;)Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Usage$AddFeatureFlagEvaluation; + public final fun fromJsonObject (Lcom/google/gson/JsonObject;)Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Usage$AddFeatureFlagEvaluation; +} + +public final class com/datadog/android/telemetry/model/TelemetryUsageEvent$Usage$AddViewLoadingTime : com/datadog/android/telemetry/model/TelemetryUsageEvent$Usage { + public static final field Companion Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Usage$AddViewLoadingTime$Companion; + public fun (ZZZ)V + public final fun component1 ()Z + public final fun component2 ()Z + public final fun component3 ()Z + public final fun copy (ZZZ)Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Usage$AddViewLoadingTime; + public static synthetic fun copy$default (Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Usage$AddViewLoadingTime;ZZZILjava/lang/Object;)Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Usage$AddViewLoadingTime; + public fun equals (Ljava/lang/Object;)Z + public static final fun fromJson (Ljava/lang/String;)Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Usage$AddViewLoadingTime; + public static final fun fromJsonObject (Lcom/google/gson/JsonObject;)Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Usage$AddViewLoadingTime; + public final fun getFeature ()Ljava/lang/String; + public final fun getNoActiveView ()Z + public final fun getNoView ()Z + public final fun getOverwritten ()Z + public fun hashCode ()I + public fun toJson ()Lcom/google/gson/JsonElement; + public fun toString ()Ljava/lang/String; +} + +public final class com/datadog/android/telemetry/model/TelemetryUsageEvent$Usage$AddViewLoadingTime$Companion { + public final fun fromJson (Ljava/lang/String;)Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Usage$AddViewLoadingTime; + public final fun fromJsonObject (Lcom/google/gson/JsonObject;)Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Usage$AddViewLoadingTime; +} + +public final class com/datadog/android/telemetry/model/TelemetryUsageEvent$Usage$Companion { + public final fun fromJson (Ljava/lang/String;)Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Usage; + public final fun fromJsonObject (Lcom/google/gson/JsonObject;)Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Usage; +} + +public final class com/datadog/android/telemetry/model/TelemetryUsageEvent$Usage$SetGlobalContext : com/datadog/android/telemetry/model/TelemetryUsageEvent$Usage { + public static final field Companion Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Usage$SetGlobalContext$Companion; + public fun ()V + public static final fun fromJson (Ljava/lang/String;)Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Usage$SetGlobalContext; + public static final fun fromJsonObject (Lcom/google/gson/JsonObject;)Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Usage$SetGlobalContext; + public final fun getFeature ()Ljava/lang/String; + public fun toJson ()Lcom/google/gson/JsonElement; +} + +public final class com/datadog/android/telemetry/model/TelemetryUsageEvent$Usage$SetGlobalContext$Companion { + public final fun fromJson (Ljava/lang/String;)Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Usage$SetGlobalContext; + public final fun fromJsonObject (Lcom/google/gson/JsonObject;)Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Usage$SetGlobalContext; +} + +public final class com/datadog/android/telemetry/model/TelemetryUsageEvent$Usage$SetTrackingConsent : com/datadog/android/telemetry/model/TelemetryUsageEvent$Usage { + public static final field Companion Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Usage$SetTrackingConsent$Companion; + public fun (Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$TrackingConsent;)V + public final fun component1 ()Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$TrackingConsent; + public final fun copy (Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$TrackingConsent;)Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Usage$SetTrackingConsent; + public static synthetic fun copy$default (Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Usage$SetTrackingConsent;Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$TrackingConsent;ILjava/lang/Object;)Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Usage$SetTrackingConsent; + public fun equals (Ljava/lang/Object;)Z + public static final fun fromJson (Ljava/lang/String;)Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Usage$SetTrackingConsent; + public static final fun fromJsonObject (Lcom/google/gson/JsonObject;)Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Usage$SetTrackingConsent; + public final fun getFeature ()Ljava/lang/String; + public final fun getTrackingConsent ()Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$TrackingConsent; + public fun hashCode ()I + public fun toJson ()Lcom/google/gson/JsonElement; + public fun toString ()Ljava/lang/String; +} + +public final class com/datadog/android/telemetry/model/TelemetryUsageEvent$Usage$SetTrackingConsent$Companion { + public final fun fromJson (Ljava/lang/String;)Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Usage$SetTrackingConsent; + public final fun fromJsonObject (Lcom/google/gson/JsonObject;)Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Usage$SetTrackingConsent; +} + +public final class com/datadog/android/telemetry/model/TelemetryUsageEvent$Usage$SetUser : com/datadog/android/telemetry/model/TelemetryUsageEvent$Usage { + public static final field Companion Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Usage$SetUser$Companion; + public fun ()V + public static final fun fromJson (Ljava/lang/String;)Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Usage$SetUser; + public static final fun fromJsonObject (Lcom/google/gson/JsonObject;)Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Usage$SetUser; + public final fun getFeature ()Ljava/lang/String; + public fun toJson ()Lcom/google/gson/JsonElement; +} + +public final class com/datadog/android/telemetry/model/TelemetryUsageEvent$Usage$SetUser$Companion { + public final fun fromJson (Ljava/lang/String;)Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Usage$SetUser; + public final fun fromJsonObject (Lcom/google/gson/JsonObject;)Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Usage$SetUser; +} + +public final class com/datadog/android/telemetry/model/TelemetryUsageEvent$Usage$StartView : com/datadog/android/telemetry/model/TelemetryUsageEvent$Usage { + public static final field Companion Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Usage$StartView$Companion; + public fun ()V + public static final fun fromJson (Ljava/lang/String;)Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Usage$StartView; + public static final fun fromJsonObject (Lcom/google/gson/JsonObject;)Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Usage$StartView; + public final fun getFeature ()Ljava/lang/String; + public fun toJson ()Lcom/google/gson/JsonElement; +} + +public final class com/datadog/android/telemetry/model/TelemetryUsageEvent$Usage$StartView$Companion { + public final fun fromJson (Ljava/lang/String;)Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Usage$StartView; + public final fun fromJsonObject (Lcom/google/gson/JsonObject;)Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Usage$StartView; +} + +public final class com/datadog/android/telemetry/model/TelemetryUsageEvent$Usage$StopSession : com/datadog/android/telemetry/model/TelemetryUsageEvent$Usage { + public static final field Companion Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Usage$StopSession$Companion; + public fun ()V + public static final fun fromJson (Ljava/lang/String;)Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Usage$StopSession; + public static final fun fromJsonObject (Lcom/google/gson/JsonObject;)Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Usage$StopSession; + public final fun getFeature ()Ljava/lang/String; + public fun toJson ()Lcom/google/gson/JsonElement; +} + +public final class com/datadog/android/telemetry/model/TelemetryUsageEvent$Usage$StopSession$Companion { + public final fun fromJson (Ljava/lang/String;)Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Usage$StopSession; + public final fun fromJsonObject (Lcom/google/gson/JsonObject;)Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Usage$StopSession; +} + +public final class com/datadog/android/telemetry/model/TelemetryUsageEvent$Usage$TelemetryBrowserFeaturesUsage_0 : com/datadog/android/telemetry/model/TelemetryUsageEvent$Usage { + public static final field Companion Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Usage$TelemetryBrowserFeaturesUsage_0$Companion; + public fun ()V + public fun (Ljava/lang/Boolean;)V + public synthetic fun (Ljava/lang/Boolean;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Ljava/lang/Boolean; + public final fun copy (Ljava/lang/Boolean;)Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Usage$TelemetryBrowserFeaturesUsage_0; + public static synthetic fun copy$default (Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Usage$TelemetryBrowserFeaturesUsage_0;Ljava/lang/Boolean;ILjava/lang/Object;)Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Usage$TelemetryBrowserFeaturesUsage_0; + public fun equals (Ljava/lang/Object;)Z + public static final fun fromJson (Ljava/lang/String;)Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Usage$TelemetryBrowserFeaturesUsage_0; + public static final fun fromJsonObject (Lcom/google/gson/JsonObject;)Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Usage$TelemetryBrowserFeaturesUsage_0; + public final fun getFeature ()Ljava/lang/String; + public fun hashCode ()I + public final fun isForced ()Ljava/lang/Boolean; + public fun toJson ()Lcom/google/gson/JsonElement; + public fun toString ()Ljava/lang/String; +} + +public final class com/datadog/android/telemetry/model/TelemetryUsageEvent$Usage$TelemetryBrowserFeaturesUsage_0$Companion { + public final fun fromJson (Ljava/lang/String;)Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Usage$TelemetryBrowserFeaturesUsage_0; + public final fun fromJsonObject (Lcom/google/gson/JsonObject;)Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Usage$TelemetryBrowserFeaturesUsage_0; +} + +public final class com/datadog/android/telemetry/model/TelemetryUsageEvent$Usage$TelemetryBrowserFeaturesUsage_1 : com/datadog/android/telemetry/model/TelemetryUsageEvent$Usage { + public static final field Companion Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Usage$TelemetryBrowserFeaturesUsage_1$Companion; + public fun ()V + public static final fun fromJson (Ljava/lang/String;)Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Usage$TelemetryBrowserFeaturesUsage_1; + public static final fun fromJsonObject (Lcom/google/gson/JsonObject;)Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Usage$TelemetryBrowserFeaturesUsage_1; + public final fun getFeature ()Ljava/lang/String; + public fun toJson ()Lcom/google/gson/JsonElement; +} + +public final class com/datadog/android/telemetry/model/TelemetryUsageEvent$Usage$TelemetryBrowserFeaturesUsage_1$Companion { + public final fun fromJson (Ljava/lang/String;)Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Usage$TelemetryBrowserFeaturesUsage_1; + public final fun fromJsonObject (Lcom/google/gson/JsonObject;)Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Usage$TelemetryBrowserFeaturesUsage_1; +} + +public final class com/datadog/android/telemetry/model/TelemetryUsageEvent$Usage$TelemetryBrowserFeaturesUsage_2 : com/datadog/android/telemetry/model/TelemetryUsageEvent$Usage { + public static final field Companion Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Usage$TelemetryBrowserFeaturesUsage_2$Companion; + public fun ()V + public static final fun fromJson (Ljava/lang/String;)Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Usage$TelemetryBrowserFeaturesUsage_2; + public static final fun fromJsonObject (Lcom/google/gson/JsonObject;)Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Usage$TelemetryBrowserFeaturesUsage_2; + public final fun getFeature ()Ljava/lang/String; + public fun toJson ()Lcom/google/gson/JsonElement; +} + +public final class com/datadog/android/telemetry/model/TelemetryUsageEvent$Usage$TelemetryBrowserFeaturesUsage_2$Companion { + public final fun fromJson (Ljava/lang/String;)Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Usage$TelemetryBrowserFeaturesUsage_2; + public final fun fromJsonObject (Lcom/google/gson/JsonObject;)Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Usage$TelemetryBrowserFeaturesUsage_2; +} + +public final class com/datadog/android/telemetry/model/TelemetryUsageEvent$Usage$TelemetryBrowserFeaturesUsage_3 : com/datadog/android/telemetry/model/TelemetryUsageEvent$Usage { + public static final field Companion Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Usage$TelemetryBrowserFeaturesUsage_3$Companion; + public fun ()V + public static final fun fromJson (Ljava/lang/String;)Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Usage$TelemetryBrowserFeaturesUsage_3; + public static final fun fromJsonObject (Lcom/google/gson/JsonObject;)Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Usage$TelemetryBrowserFeaturesUsage_3; + public final fun getFeature ()Ljava/lang/String; + public fun toJson ()Lcom/google/gson/JsonElement; +} + +public final class com/datadog/android/telemetry/model/TelemetryUsageEvent$Usage$TelemetryBrowserFeaturesUsage_3$Companion { + public final fun fromJson (Ljava/lang/String;)Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Usage$TelemetryBrowserFeaturesUsage_3; + public final fun fromJsonObject (Lcom/google/gson/JsonObject;)Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$Usage$TelemetryBrowserFeaturesUsage_3; +} + +public final class com/datadog/android/telemetry/model/TelemetryUsageEvent$View { + public static final field Companion Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$View$Companion; + public fun (Ljava/lang/String;)V + public final fun component1 ()Ljava/lang/String; + public final fun copy (Ljava/lang/String;)Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$View; + public static synthetic fun copy$default (Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$View;Ljava/lang/String;ILjava/lang/Object;)Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$View; + public fun equals (Ljava/lang/Object;)Z + public static final fun fromJson (Ljava/lang/String;)Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$View; + public static final fun fromJsonObject (Lcom/google/gson/JsonObject;)Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$View; + public final fun getId ()Ljava/lang/String; + public fun hashCode ()I + public final fun toJson ()Lcom/google/gson/JsonElement; + public fun toString ()Ljava/lang/String; +} + +public final class com/datadog/android/telemetry/model/TelemetryUsageEvent$View$Companion { + public final fun fromJson (Ljava/lang/String;)Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$View; + public final fun fromJsonObject (Lcom/google/gson/JsonObject;)Lcom/datadog/android/telemetry/model/TelemetryUsageEvent$View; +} + diff --git a/features/dd-sdk-android-rum/build.gradle.kts b/features/dd-sdk-android-rum/build.gradle.kts index 7aeb9c2760..4d8722bb93 100644 --- a/features/dd-sdk-android-rum/build.gradle.kts +++ b/features/dd-sdk-android-rum/build.gradle.kts @@ -3,9 +3,11 @@ * This product includes software developed at Datadog (https://www.datadoghq.com/). * Copyright 2016-Present Datadog, Inc. */ +@file:Suppress("StringLiteralDuplication") import com.datadog.gradle.config.androidLibraryConfig import com.datadog.gradle.config.dependencyUpdateConfig +import com.datadog.gradle.config.detektCustomConfig import com.datadog.gradle.config.javadocConfig import com.datadog.gradle.config.junitConfig import com.datadog.gradle.config.kotlinConfig @@ -71,6 +73,7 @@ dependencies { } } testImplementation(testFixtures(project(":dd-sdk-android-core"))) + testImplementation(testFixtures(project(":dd-sdk-android-internal"))) testImplementation(libs.bundles.jUnit5) testImplementation(libs.bundles.testTools) testImplementation(libs.okHttp) @@ -114,3 +117,4 @@ publishingConfig( "The RUM feature to use with the Datadog monitoring " + "library for Android applications." ) +detektCustomConfig(":dd-sdk-android-core", ":dd-sdk-android-internal") diff --git a/features/dd-sdk-android-rum/generate_telemetry_models.gradle.kts b/features/dd-sdk-android-rum/generate_telemetry_models.gradle.kts index 26bdaf10b1..db2d658af6 100644 --- a/features/dd-sdk-android-rum/generate_telemetry_models.gradle.kts +++ b/features/dd-sdk-android-rum/generate_telemetry_models.gradle.kts @@ -16,8 +16,7 @@ tasks.register( inputDirPath = "src/main/json/telemetry" targetPackageName = "com.datadog.android.telemetry.model" ignoredFiles = arrayOf( - "_common-schema.json", - "usage-schema.json" + "_common-schema.json" ) inputNameMapping = mapOf( "debug-schema.json" to "TelemetryDebugEvent", diff --git a/features/dd-sdk-android-rum/src/main/json/rum/error-schema.json b/features/dd-sdk-android-rum/src/main/json/rum/error-schema.json index 96934b2dba..3350fdb70e 100644 --- a/features/dd-sdk-android-rum/src/main/json/rum/error-schema.json +++ b/features/dd-sdk-android-rum/src/main/json/rum/error-schema.json @@ -101,7 +101,7 @@ "category": { "type": "string", "description": "The specific category of the error. It provides a high-level grouping for different types of errors.", - "enum": ["ANR", "App Hang", "Exception"], + "enum": ["ANR", "App Hang", "Exception", "Watchdog Termination", "Memory Warning"], "readOnly": true }, "handling": { diff --git a/features/dd-sdk-android-rum/src/main/json/telemetry/usage-schema.json b/features/dd-sdk-android-rum/src/main/json/telemetry/usage-schema.json index 5b82fc045d..91d9f8387e 100644 --- a/features/dd-sdk-android-rum/src/main/json/telemetry/usage-schema.json +++ b/features/dd-sdk-android-rum/src/main/json/telemetry/usage-schema.json @@ -28,6 +28,9 @@ }, { "$ref": "usage/browser-features-schema.json" + }, + { + "$ref": "usage/mobile-features-schema.json" } ] } diff --git a/features/dd-sdk-android-rum/src/main/json/telemetry/usage/common-features-schema.json b/features/dd-sdk-android-rum/src/main/json/telemetry/usage/common-features-schema.json index c1e8328fd7..b66c784b5e 100644 --- a/features/dd-sdk-android-rum/src/main/json/telemetry/usage/common-features-schema.json +++ b/features/dd-sdk-android-rum/src/main/json/telemetry/usage/common-features-schema.json @@ -7,6 +7,7 @@ "oneOf": [ { "required": ["feature", "tracking_consent"], + "title": "SetTrackingConsent", "properties": { "feature": { "type": "string", @@ -22,6 +23,7 @@ }, { "required": ["feature"], + "title": "StopSession", "properties": { "feature": { "type": "string", @@ -32,6 +34,7 @@ }, { "required": ["feature"], + "title": "StartView", "properties": { "feature": { "type": "string", @@ -42,6 +45,7 @@ }, { "required": ["feature"], + "title": "AddAction", "properties": { "feature": { "type": "string", @@ -52,6 +56,7 @@ }, { "required": ["feature"], + "title": "AddError", "properties": { "feature": { "type": "string", @@ -62,6 +67,7 @@ }, { "required": ["feature"], + "title": "SetGlobalContext", "properties": { "feature": { "type": "string", @@ -72,6 +78,7 @@ }, { "required": ["feature"], + "title": "SetUser", "properties": { "feature": { "type": "string", @@ -82,6 +89,7 @@ }, { "required": ["feature"], + "title": "AddFeatureFlagEvaluation", "properties": { "feature": { "type": "string", diff --git a/features/dd-sdk-android-rum/src/main/json/telemetry/usage/mobile-features-schema.json b/features/dd-sdk-android-rum/src/main/json/telemetry/usage/mobile-features-schema.json new file mode 100644 index 0000000000..450fa9acf4 --- /dev/null +++ b/features/dd-sdk-android-rum/src/main/json/telemetry/usage/mobile-features-schema.json @@ -0,0 +1,32 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema", + "$id": "telemetry/usage/mobile-features-schema.json", + "title": "TelemetryMobileFeaturesUsage", + "type": "object", + "description": "Schema of mobile specific features usage", + "oneOf": [ + { + "required": ["feature", "no_view", "no_active_view", "overwritten"], + "title": "AddViewLoadingTime", + "properties": { + "feature": { + "type": "string", + "description": "addViewLoadingTime API", + "const": "addViewLoadingTime" + }, + "no_view": { + "type": "boolean", + "description": "Whether the view is not available" + }, + "no_active_view": { + "type": "boolean", + "description": "Whether the available view is not active" + }, + "overwritten": { + "type": "boolean", + "description": "Whether the loading time was overwritten" + } + } + } + ] +} diff --git a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/ExperimentalRumApi.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/ExperimentalRumApi.kt new file mode 100644 index 0000000000..4ba592871c --- /dev/null +++ b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/ExperimentalRumApi.kt @@ -0,0 +1,18 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.datadog.android.rum + +/** + * Marker for the experimental RUM API. + */ +@RequiresOptIn( + message = "This is an experimental RUM API." + + " It may change in the future.", + level = RequiresOptIn.Level.WARNING +) +@Retention(AnnotationRetention.BINARY) +annotation class ExperimentalRumApi diff --git a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/RumMonitor.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/RumMonitor.kt index 279ef49d89..7ff6ffdb86 100644 --- a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/RumMonitor.kt +++ b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/RumMonitor.kt @@ -323,6 +323,17 @@ interface RumMonitor { */ fun stopSession() + /** + * Adds view loading time RUM active view based on the time elapsed since the view was started. + * The view loading time is automatically calculated as the difference between the current time + * and the start time of the view. + * This method should be called only once per view. + * If no view is started or active, this method does nothing. + * @param overwrite which controls if the method overwrites the previously calculated view loading time. + */ + @ExperimentalRumApi + fun addViewLoadingTime(overwrite: Boolean) + /** * Utility setting to inspect the active RUM View. * If set, a debugging outline will be displayed on top of the application, describing the name diff --git a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/_RumInternalProxy.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/_RumInternalProxy.kt index e78c891047..1a66e11d41 100644 --- a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/_RumInternalProxy.kt +++ b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/_RumInternalProxy.kt @@ -74,6 +74,7 @@ class _RumInternalProxy internal constructor(private val rumMonitor: AdvancedRum return builder.setTelemetryConfigurationEventMapper(eventMapper) } + @Suppress("unused") fun setAdditionalConfiguration( builder: RumConfiguration.Builder, additionalConfig: Map diff --git a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/RumFeature.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/RumFeature.kt index c599b09d8c..548dbf0b66 100644 --- a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/RumFeature.kt +++ b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/RumFeature.kt @@ -33,6 +33,7 @@ import com.datadog.android.core.internal.utils.submitSafe import com.datadog.android.event.EventMapper import com.datadog.android.event.MapperSerializer import com.datadog.android.event.NoOpEventMapper +import com.datadog.android.internal.telemetry.InternalTelemetryEvent import com.datadog.android.rum.GlobalRumMonitor import com.datadog.android.rum.RumErrorSource import com.datadog.android.rum.RumSessionListener @@ -78,8 +79,6 @@ import com.datadog.android.rum.tracking.NoOpViewTrackingStrategy import com.datadog.android.rum.tracking.TrackingStrategy import com.datadog.android.rum.tracking.ViewAttributesProvider import com.datadog.android.rum.tracking.ViewTrackingStrategy -import com.datadog.android.telemetry.internal.Telemetry -import com.datadog.android.telemetry.internal.TelemetryCoreConfiguration import com.datadog.android.telemetry.model.TelemetryConfigurationEvent import java.util.Locale import java.util.concurrent.ExecutorService @@ -128,7 +127,6 @@ internal class RumFeature( private var anrDetectorExecutorService: ExecutorService? = null internal var anrDetectorRunnable: ANRDetectorRunnable? = null internal lateinit var appContext: Context - internal lateinit var telemetry: Telemetry private val lateCrashEventHandler by lazy { lateCrashReporterFactory(sdkCore as InternalSdkCore) } @@ -138,7 +136,6 @@ internal class RumFeature( override fun onInitialize(appContext: Context) { this.appContext = appContext - this.telemetry = Telemetry(sdkCore) dataWriter = createDataWriter( configuration, @@ -251,18 +248,25 @@ internal class RumFeature( // region FeatureEventReceiver override fun onReceive(event: Any) { - if (event is JvmCrash.Rum) { - addJvmCrash(event) - return - } else if (event !is Map<*, *>) { - sdkCore.internalLogger.log( - InternalLogger.Level.WARN, - InternalLogger.Target.USER, - { UNSUPPORTED_EVENT_TYPE.format(Locale.US, event::class.java.canonicalName) } - ) - return + when (event) { + is Map<*, *> -> handleMapLikeEvent(event) + is JvmCrash.Rum -> addJvmCrash(event) + is InternalTelemetryEvent -> handleTelemetryEvent(event) + else -> { + sdkCore.internalLogger.log( + InternalLogger.Level.WARN, + InternalLogger.Target.USER, + { UNSUPPORTED_EVENT_TYPE.format(Locale.US, event::class.java.canonicalName) } + ) + } } + } + + // endregion + // region Internal + + private fun handleMapLikeEvent(event: Map<*, *>) { when (event["type"]) { NDK_CRASH_BUS_MESSAGE_TYPE -> lateCrashEventHandler.handleNdkCrashEvent(event, dataWriter) @@ -272,11 +276,7 @@ internal class RumFeature( WEB_VIEW_INGESTED_NOTIFICATION_MESSAGE_TYPE -> { (GlobalRumMonitor.get(sdkCore) as? AdvancedRumMonitor)?.sendWebViewEvent() } - - TELEMETRY_ERROR_MESSAGE_TYPE -> logTelemetryError(event) - TELEMETRY_DEBUG_MESSAGE_TYPE -> logTelemetryDebug(event) - MOBILE_METRIC_MESSAGE_TYPE -> logMetric(event) - TELEMETRY_CONFIG_MESSAGE_TYPE -> logTelemetryConfiguration(event) + TELEMETRY_SESSION_REPLAY_SKIP_FRAME -> addSessionReplaySkippedFrame() FLUSH_AND_STOP_MONITOR_MESSAGE_TYPE -> { (GlobalRumMonitor.get(sdkCore) as? DatadogRumMonitor)?.let { it.stopKeepAliveCallback() @@ -294,9 +294,10 @@ internal class RumFeature( } } - // endregion - - // region Internal + private fun handleTelemetryEvent(event: InternalTelemetryEvent) { + val advancedRumMonitor = GlobalRumMonitor.get(sdkCore) as? AdvancedRumMonitor ?: return + advancedRumMonitor.sendTelemetryEvent(event) + } @AnyThread internal fun enableDebugging(advancedRumMonitor: AdvancedRumMonitor) { @@ -502,66 +503,8 @@ internal class RumFeature( ) } - private fun logTelemetryError(telemetryEvent: Map<*, *>) { - val message = telemetryEvent[EVENT_MESSAGE_PROPERTY] as? String - if (message == null) { - sdkCore.internalLogger.log( - InternalLogger.Level.WARN, - InternalLogger.Target.MAINTAINER, - { TELEMETRY_MISSING_MESSAGE_FIELD } - ) - return - } - val throwable = telemetryEvent[EVENT_THROWABLE_PROPERTY] as? Throwable - val stack = telemetryEvent[EVENT_STACKTRACE_PROPERTY] as? String - val kind = telemetryEvent["kind"] as? String - - @Suppress("UNCHECKED_CAST") - val additionalProperties = telemetryEvent[EVENT_ADDITIONAL_PROPERTIES] as? Map - if (throwable != null) { - telemetry.error(message, throwable, additionalProperties) - } else { - telemetry.error(message, stack, kind, additionalProperties) - } - } - - private fun logTelemetryDebug(telemetryEvent: Map<*, *>) { - val message = telemetryEvent[EVENT_MESSAGE_PROPERTY] as? String - - @Suppress("UNCHECKED_CAST") - val additionalProperties = telemetryEvent[EVENT_ADDITIONAL_PROPERTIES] as? Map - if (message == null) { - sdkCore.internalLogger.log( - InternalLogger.Level.WARN, - InternalLogger.Target.MAINTAINER, - { TELEMETRY_MISSING_MESSAGE_FIELD } - ) - return - } - telemetry.debug(message, additionalProperties) - } - - private fun logMetric(metricEvent: Map<*, *>) { - val message = metricEvent[EVENT_MESSAGE_PROPERTY] as? String - - @Suppress("UNCHECKED_CAST") - val additionalProperties = metricEvent[EVENT_ADDITIONAL_PROPERTIES] as? Map - if (message == null) { - sdkCore.internalLogger.log( - InternalLogger.Level.WARN, - InternalLogger.Target.MAINTAINER, - { TELEMETRY_MISSING_MESSAGE_FIELD } - ) - return - } - telemetry.metric(message, additionalProperties) - } - - private fun logTelemetryConfiguration(event: Map<*, *>) { - TelemetryCoreConfiguration.fromEvent(event, sdkCore.internalLogger)?.let { - (GlobalRumMonitor.get(sdkCore) as? AdvancedRumMonitor) - ?.sendConfigurationTelemetryEvent(it) - } + private fun addSessionReplaySkippedFrame() { + (GlobalRumMonitor.get(sdkCore) as? AdvancedRumMonitor)?.addSessionReplaySkippedFrame() } // endregion @@ -596,10 +539,7 @@ internal class RumFeature( internal const val LOGGER_ERROR_BUS_MESSAGE_TYPE = "logger_error" internal const val LOGGER_ERROR_WITH_STACK_TRACE_MESSAGE_TYPE = "logger_error_with_stacktrace" internal const val WEB_VIEW_INGESTED_NOTIFICATION_MESSAGE_TYPE = "web_view_ingested_notification" - internal const val TELEMETRY_ERROR_MESSAGE_TYPE = "telemetry_error" - internal const val TELEMETRY_DEBUG_MESSAGE_TYPE = "telemetry_debug" - internal const val MOBILE_METRIC_MESSAGE_TYPE = "mobile_metric" - internal const val TELEMETRY_CONFIG_MESSAGE_TYPE = "telemetry_configuration" + internal const val TELEMETRY_SESSION_REPLAY_SKIP_FRAME = "sr_skipped_frame" internal const val FLUSH_AND_STOP_MONITOR_MESSAGE_TYPE = "flush_and_stop_monitor" internal const val ALL_IN_SAMPLE_RATE: Float = 100f @@ -637,7 +577,6 @@ internal class RumFeature( ) internal const val EVENT_MESSAGE_PROPERTY = "message" - internal const val EVENT_ADDITIONAL_PROPERTIES = "additionalProperties" internal const val EVENT_THROWABLE_PROPERTY = "throwable" internal const val EVENT_ATTRIBUTES_PROPERTY = "attributes" internal const val EVENT_STACKTRACE_PROPERTY = "stacktrace" @@ -656,8 +595,6 @@ internal class RumFeature( internal const val LOG_ERROR_WITH_STACKTRACE_EVENT_MISSING_MANDATORY_FIELDS = "RUM feature received a log event with stacktrace" + " where mandatory message field is either missing or has a wrong type." - internal const val TELEMETRY_MISSING_MESSAGE_FIELD = "RUM feature received a telemetry" + - " event, but mandatory message field is either missing or has a wrong type." internal const val DEVELOPER_MODE_SAMPLE_RATE_CHANGED_MESSAGE = "Developer mode enabled, setting RUM sample rate to 100%." internal const val RUM_FEATURE_NOT_YET_INITIALIZED = diff --git a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/anr/ANRDetectorRunnable.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/anr/ANRDetectorRunnable.kt index fb8d1183bf..2566d5c060 100644 --- a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/anr/ANRDetectorRunnable.kt +++ b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/anr/ANRDetectorRunnable.kt @@ -12,6 +12,7 @@ import com.datadog.android.api.feature.FeatureSdkCore import com.datadog.android.core.feature.event.ThreadDump import com.datadog.android.core.internal.utils.asString import com.datadog.android.core.internal.utils.loggableStackTrace +import com.datadog.android.internal.utils.loggableStackTrace import com.datadog.android.rum.GlobalRumMonitor import com.datadog.android.rum.RumAttributes import com.datadog.android.rum.RumErrorSource diff --git a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/event/RumEventMapper.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/event/RumEventMapper.kt index 20d66a2920..00c664caf0 100644 --- a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/event/RumEventMapper.kt +++ b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/event/RumEventMapper.kt @@ -17,6 +17,7 @@ import com.datadog.android.rum.model.ViewEvent import com.datadog.android.telemetry.model.TelemetryConfigurationEvent import com.datadog.android.telemetry.model.TelemetryDebugEvent import com.datadog.android.telemetry.model.TelemetryErrorEvent +import com.datadog.android.telemetry.model.TelemetryUsageEvent import java.util.Locale internal data class RumEventMapper( @@ -61,6 +62,7 @@ internal data class RumEventMapper( is LongTaskEvent -> longTaskEventMapper.map(event) is TelemetryConfigurationEvent -> telemetryConfigurationMapper.map(event) is TelemetryDebugEvent, + is TelemetryUsageEvent, is TelemetryErrorEvent -> event else -> { internalLogger.log( diff --git a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/event/RumEventSerializer.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/event/RumEventSerializer.kt index 1b63efac8c..f93fa94670 100644 --- a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/event/RumEventSerializer.kt +++ b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/event/RumEventSerializer.kt @@ -20,6 +20,7 @@ import com.datadog.android.rum.model.ViewEvent import com.datadog.android.telemetry.model.TelemetryConfigurationEvent import com.datadog.android.telemetry.model.TelemetryDebugEvent import com.datadog.android.telemetry.model.TelemetryErrorEvent +import com.datadog.android.telemetry.model.TelemetryUsageEvent import com.google.gson.JsonObject internal class RumEventSerializer( @@ -55,6 +56,9 @@ internal class RumEventSerializer( is TelemetryConfigurationEvent -> { model.toJson().toString() } + is TelemetryUsageEvent -> { + model.toJson().toString() + } is JsonObject -> { model.toString() } diff --git a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumRawEvent.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumRawEvent.kt index 97f4075646..63047d50b7 100644 --- a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumRawEvent.kt +++ b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumRawEvent.kt @@ -7,6 +7,7 @@ package com.datadog.android.rum.internal.domain.scope import com.datadog.android.core.feature.event.ThreadDump +import com.datadog.android.internal.telemetry.InternalTelemetryEvent import com.datadog.android.rum.RumActionType import com.datadog.android.rum.RumErrorSource import com.datadog.android.rum.RumPerformanceMetric @@ -15,8 +16,6 @@ import com.datadog.android.rum.RumResourceMethod import com.datadog.android.rum.internal.RumErrorSourceType import com.datadog.android.rum.internal.domain.Time import com.datadog.android.rum.internal.domain.event.ResourceTiming -import com.datadog.android.telemetry.internal.TelemetryCoreConfiguration -import com.datadog.android.telemetry.internal.TelemetryType internal sealed class RumRawEvent { @@ -173,6 +172,11 @@ internal sealed class RumRawEvent { override val eventTime: Time = Time() ) : RumRawEvent() + internal data class AddViewLoadingTime( + val overwrite: Boolean, + override val eventTime: Time = Time() + ) : RumRawEvent() + internal data class AddLongTask( val durationNs: Long, val target: String, @@ -212,16 +216,9 @@ internal sealed class RumRawEvent { internal data class WebViewEvent(override val eventTime: Time = Time()) : RumRawEvent() - internal data class SendTelemetry( - val type: TelemetryType, - val message: String, - val stack: String?, - val kind: String?, - val coreConfiguration: TelemetryCoreConfiguration?, - val additionalProperties: Map?, - val onlyOnce: Boolean = false, - override val eventTime: Time = Time(), - val isMetric: Boolean = false + internal data class TelemetryEventWrapper( + val event: InternalTelemetryEvent, + override val eventTime: Time = Time() ) : RumRawEvent() internal data class SdkInit( diff --git a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumResourceScope.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumResourceScope.kt index dbf754d65d..b771e2b66f 100644 --- a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumResourceScope.kt +++ b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumResourceScope.kt @@ -12,6 +12,7 @@ import com.datadog.android.api.storage.DataWriter import com.datadog.android.core.InternalSdkCore import com.datadog.android.core.internal.net.FirstPartyHostHeaderTypeResolver import com.datadog.android.core.internal.utils.loggableStackTrace +import com.datadog.android.internal.utils.loggableStackTrace import com.datadog.android.rum.GlobalRumMonitor import com.datadog.android.rum.RumAttributes import com.datadog.android.rum.RumErrorSource diff --git a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumViewManagerScope.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumViewManagerScope.kt index 881682b67c..ace9fbea48 100644 --- a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumViewManagerScope.kt +++ b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumViewManagerScope.kt @@ -13,6 +13,7 @@ import com.datadog.android.api.storage.DataWriter import com.datadog.android.core.InternalSdkCore import com.datadog.android.core.internal.net.FirstPartyHostHeaderTypeResolver import com.datadog.android.core.metrics.MethodCallSamplingRate +import com.datadog.android.internal.telemetry.InternalTelemetryEvent import com.datadog.android.rum.DdRumContentProvider import com.datadog.android.rum.internal.anr.ANRException import com.datadog.android.rum.internal.domain.RumContext @@ -144,7 +145,24 @@ internal class RumViewManagerScope( val importanceForeground = ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND val isForegroundProcess = processFlag == importanceForeground - if (applicationDisplayed || !isForegroundProcess) { + if (event is RumRawEvent.AddViewLoadingTime) { + val internalLogger = sdkCore.internalLogger + internalLogger.log( + InternalLogger.Level.WARN, + InternalLogger.Target.USER, + { MESSAGE_MISSING_VIEW } + ) + internalLogger.logApiUsage( + InternalTelemetryEvent.ApiUsage.AddViewLoadingTime( + overwrite = event.overwrite, + noView = true, + noActiveView = false + ) + ) + // we should return here and not add the event to the session ended metric missed events as we already + // send the API usage telemetry + return + } else if (applicationDisplayed || !isForegroundProcess) { handleBackgroundEvent(event, writer) } else { val isSilentOrphanEvent = event.javaClass in silentOrphanEventTypes diff --git a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumViewScope.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumViewScope.kt index 79eef30a62..2f48adda0d 100644 --- a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumViewScope.kt +++ b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumViewScope.kt @@ -15,6 +15,8 @@ import com.datadog.android.api.storage.EventType import com.datadog.android.core.InternalSdkCore import com.datadog.android.core.internal.net.FirstPartyHostHeaderTypeResolver import com.datadog.android.core.internal.utils.loggableStackTrace +import com.datadog.android.internal.telemetry.InternalTelemetryEvent +import com.datadog.android.internal.utils.loggableStackTrace import com.datadog.android.rum.GlobalRumMonitor import com.datadog.android.rum.RumActionType import com.datadog.android.rum.RumAttributes @@ -79,6 +81,7 @@ internal open class RumViewScope( private val oldViewIds = mutableSetOf() private val startedNanos: Long = eventTime.nanoTime + internal var viewLoadingTime: Long? = null internal val serverTimeOffsetInMs = sdkCore.time.serverTimeOffsetMs internal val eventTimestamp = eventTime.timestamp + serverTimeOffsetInMs @@ -194,6 +197,7 @@ internal open class RumViewScope( is RumRawEvent.StopSession -> onStopSession(event, writer) is RumRawEvent.UpdatePerformanceMetric -> onUpdatePerformanceMetric(event) + is RumRawEvent.AddViewLoadingTime -> onAddViewLoadingTime(event, writer) else -> delegateEventToChildren(event, writer) } @@ -236,6 +240,76 @@ internal open class RumViewScope( // region Internal + @WorkerThread + private fun onAddViewLoadingTime(event: RumRawEvent.AddViewLoadingTime, writer: DataWriter) { + val internalLogger = sdkCore.internalLogger + val canUpdateViewLoadingTime = !stopped && (viewLoadingTime == null || event.overwrite) + if (stopped) { + internalLogger.log( + InternalLogger.Level.WARN, + InternalLogger.Target.USER, + { NO_ACTIVE_VIEW_FOR_LOADING_TIME_WARNING_MESSAGE } + ) + internalLogger.logApiUsage( + InternalTelemetryEvent.ApiUsage.AddViewLoadingTime( + overwrite = event.overwrite, + noView = false, + noActiveView = true + ) + ) + } + + if (canUpdateViewLoadingTime) { + updateViewLoadingTime(event, internalLogger, writer) + } + } + + private fun updateViewLoadingTime( + event: RumRawEvent.AddViewLoadingTime, + internalLogger: InternalLogger, + writer: DataWriter + ) { + val viewName = key.name + val previousViewLoadingTime = viewLoadingTime + val newLoadingTime = event.eventTime.nanoTime - startedNanos + if (previousViewLoadingTime == null) { + internalLogger.log( + InternalLogger.Level.DEBUG, + InternalLogger.Target.USER, + { ADDING_VIEW_LOADING_TIME_DEBUG_MESSAGE_FORMAT.format(Locale.US, viewLoadingTime, viewName) } + ) + internalLogger.logApiUsage( + InternalTelemetryEvent.ApiUsage.AddViewLoadingTime( + overwrite = false, + noView = false, + noActiveView = false + ) + ) + } else if (event.overwrite) { + internalLogger.log( + InternalLogger.Level.WARN, + InternalLogger.Target.USER, + { + OVERWRITING_VIEW_LOADING_TIME_WARNING_MESSAGE_FORMAT.format( + Locale.US, + viewName, + previousViewLoadingTime, + newLoadingTime + ) + } + ) + internalLogger.logApiUsage( + InternalTelemetryEvent.ApiUsage.AddViewLoadingTime( + overwrite = true, + noView = false, + noActiveView = false + ) + ) + } + viewLoadingTime = newLoadingTime + sendViewUpdate(event, writer) + } + @WorkerThread private fun onStartView( event: RumRawEvent.StartView, @@ -834,7 +908,8 @@ internal open class RumViewScope( frustration = ViewEvent.Frustration(eventFrustrationCount.toLong()), flutterBuildTime = eventFlutterBuildTime, flutterRasterTime = eventFlutterRasterTime, - jsRefreshRate = eventJsRefreshRate + jsRefreshRate = eventJsRefreshRate, + loadingTime = viewLoadingTime ), usr = if (user.hasUserData()) { ViewEvent.Usr( @@ -1234,6 +1309,13 @@ internal open class RumViewScope( internal const val SLOW_RENDERED_THRESHOLD_FPS = 55 internal const val NEGATIVE_DURATION_WARNING_MESSAGE = "The computed duration for the " + "view: %s was 0 or negative. In order to keep the view we forced it to 1ns." + internal const val NO_ACTIVE_VIEW_FOR_LOADING_TIME_WARNING_MESSAGE = + "No active view found to add the loading time." + internal const val ADDING_VIEW_LOADING_TIME_DEBUG_MESSAGE_FORMAT = + "View loading time %dns added to the view %s" + internal const val OVERWRITING_VIEW_LOADING_TIME_WARNING_MESSAGE_FORMAT = + "View loading time already exists for the view %s. Replacing the existing %d ns " + + "view loading time with the new %d ns loading time." internal fun fromEvent( parentScope: RumScope, diff --git a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/metric/SessionEndedMetric.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/metric/SessionEndedMetric.kt index 645344b19b..f8cc60d2ed 100644 --- a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/metric/SessionEndedMetric.kt +++ b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/metric/SessionEndedMetric.kt @@ -12,6 +12,7 @@ import com.datadog.android.rum.internal.domain.scope.RumSessionScope import com.datadog.android.rum.internal.domain.scope.RumViewManagerScope import com.datadog.android.rum.model.ViewEvent import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger /** * Metric for rum session ended event. @@ -30,6 +31,8 @@ internal class SessionEndedMetric( private val missedEventCountByType = mutableMapOf() + private var sessionReplaySkippedFramesCount: AtomicInteger = AtomicInteger(0) + private var firstTrackedView: TrackedView? = null private var lastTrackedView: TrackedView? = null private var wasStopped: Boolean = false @@ -69,6 +72,10 @@ internal class SessionEndedMetric( missedEventCountByType[missedEventType] = (missedEventCountByType[missedEventType] ?: 0) + 1 } + fun onSessionReplaySkippedFrameTracked() { + sessionReplaySkippedFramesCount.incrementAndGet() + } + fun toMetricAttributes(ntpOffsetAtEndMs: Long): Map { return mapOf( METRIC_TYPE_KEY to METRIC_TYPE_VALUE, @@ -94,7 +101,8 @@ internal class SessionEndedMetric( SDK_ERRORS_COUNT_KEY to resolveSDKErrorsCountAttributes(), NO_VIEW_EVENTS_COUNT_KEY to resolveNoViewCountsAttributes(), HAS_BACKGROUND_EVENTS_TRACKING_ENABLED_KEY to hasTrackBackgroundEventsEnabled, - NTP_OFFSET_KEY to resolveNtpOffsetAttributes(ntpOffsetAtEnd) + NTP_OFFSET_KEY to resolveNtpOffsetAttributes(ntpOffsetAtEnd), + SESSION_REPLAY_SKIPPED_FRAMES_COUNT to sessionReplaySkippedFramesCount.get() ) } @@ -301,6 +309,11 @@ internal class SessionEndedMetric( * Placeholder of error kind if the attribute is absent. */ internal const val SDK_ERROR_DEFAULT_KIND = "Empty error kind" + + /** + * Key of the counts the total frames skipped in session replay by dynamic optimisation. + */ + internal const val SESSION_REPLAY_SKIPPED_FRAMES_COUNT = "sr_skipped_frames_count" } /** diff --git a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/metric/SessionEndedMetricDispatcher.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/metric/SessionEndedMetricDispatcher.kt index f15bf4f402..31c14f026b 100644 --- a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/metric/SessionEndedMetricDispatcher.kt +++ b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/metric/SessionEndedMetricDispatcher.kt @@ -72,6 +72,10 @@ internal class SessionEndedMetricDispatcher(private val internalLogger: Internal metricsBySessionId[sessionId]?.onMissedEventTracked(missedEventType) } + override fun onSessionReplaySkippedFrameTracked(sessionId: String) { + metricsBySessionId[sessionId]?.onSessionReplaySkippedFrameTracked() + } + private fun buildSdkErrorTrackError(sessionId: String, errorKind: String?): String { return "Failed to track $errorKind error, session $sessionId has ended" } diff --git a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/metric/SessionMetricDispatcher.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/metric/SessionMetricDispatcher.kt index c89f453093..8074f25802 100644 --- a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/metric/SessionMetricDispatcher.kt +++ b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/metric/SessionMetricDispatcher.kt @@ -50,4 +50,9 @@ internal interface SessionMetricDispatcher { * Called when a missed event is tracked by this session metric. */ fun onMissedEventTracked(sessionId: String, missedEventType: SessionEndedMetric.MissedEventType) + + /** + * Called when skipped frame is tracked by this session metric. + */ + fun onSessionReplaySkippedFrameTracked(sessionId: String) } diff --git a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/monitor/AdvancedNetworkRumMonitor.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/monitor/AdvancedNetworkRumMonitor.kt index dcd58e29f3..6a40cd99e9 100644 --- a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/monitor/AdvancedNetworkRumMonitor.kt +++ b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/monitor/AdvancedNetworkRumMonitor.kt @@ -9,6 +9,7 @@ package com.datadog.android.rum.internal.monitor import com.datadog.android.lint.InternalApi import com.datadog.android.rum.RumAttributes import com.datadog.android.rum.RumErrorSource +import com.datadog.android.rum.RumMonitor import com.datadog.android.rum.RumResourceKind import com.datadog.android.rum.RumResourceMethod import com.datadog.android.rum.internal.domain.event.ResourceTiming @@ -19,7 +20,7 @@ import com.datadog.android.rum.resource.ResourceId */ @SuppressWarnings("UndocumentedPublicFunction") @InternalApi -interface AdvancedNetworkRumMonitor { +interface AdvancedNetworkRumMonitor : RumMonitor { @InternalApi fun waitForResourceTiming(key: Any) diff --git a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/monitor/AdvancedRumMonitor.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/monitor/AdvancedRumMonitor.kt index 272ccaa75c..1a89986656 100644 --- a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/monitor/AdvancedRumMonitor.kt +++ b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/monitor/AdvancedRumMonitor.kt @@ -7,11 +7,11 @@ package com.datadog.android.rum.internal.monitor import com.datadog.android.core.feature.event.ThreadDump +import com.datadog.android.internal.telemetry.InternalTelemetryEvent import com.datadog.android.rum.RumErrorSource import com.datadog.android.rum.RumMonitor import com.datadog.android.rum.RumPerformanceMetric import com.datadog.android.rum.internal.debug.RumDebugListener -import com.datadog.android.telemetry.internal.TelemetryCoreConfiguration import com.datadog.tools.annotation.NoOpImplementation /** @@ -29,6 +29,8 @@ internal interface AdvancedRumMonitor : RumMonitor, AdvancedNetworkRumMonitor { fun addLongTask(durationNs: Long, target: String) + fun addSessionReplaySkippedFrame() + fun addCrash( message: String, source: RumErrorSource, @@ -42,31 +44,7 @@ internal interface AdvancedRumMonitor : RumMonitor, AdvancedNetworkRumMonitor { fun setDebugListener(listener: RumDebugListener?) - fun sendDebugTelemetryEvent( - message: String, - additionalProperties: Map? = null - ) - - fun sendErrorTelemetryEvent( - message: String, - throwable: Throwable?, - additionalProperties: Map? = null - ) - - fun sendErrorTelemetryEvent( - message: String, - stack: String?, - kind: String?, - additionalProperties: Map? = null - ) - - fun sendMetricEvent( - message: String, - additionalProperties: Map? - ) - - @Suppress("FunctionMaxLength") - fun sendConfigurationTelemetryEvent(coreConfiguration: TelemetryCoreConfiguration) + fun sendTelemetryEvent(telemetryEvent: InternalTelemetryEvent) fun updatePerformanceMetric(metric: RumPerformanceMetric, value: Double) diff --git a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/monitor/DatadogRumMonitor.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/monitor/DatadogRumMonitor.kt index d433016333..4d636a34ea 100644 --- a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/monitor/DatadogRumMonitor.kt +++ b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/monitor/DatadogRumMonitor.kt @@ -14,9 +14,10 @@ import com.datadog.android.api.storage.DataWriter import com.datadog.android.core.InternalSdkCore import com.datadog.android.core.feature.event.ThreadDump import com.datadog.android.core.internal.net.FirstPartyHostHeaderTypeResolver -import com.datadog.android.core.internal.utils.loggableStackTrace import com.datadog.android.core.internal.utils.submitSafe +import com.datadog.android.internal.telemetry.InternalTelemetryEvent import com.datadog.android.rum.DdRumContentProvider +import com.datadog.android.rum.ExperimentalRumApi import com.datadog.android.rum.RumActionType import com.datadog.android.rum.RumAttributes import com.datadog.android.rum.RumErrorSource @@ -44,9 +45,7 @@ import com.datadog.android.rum.internal.domain.scope.RumViewScope import com.datadog.android.rum.internal.metric.SessionMetricDispatcher import com.datadog.android.rum.internal.vitals.VitalMonitor import com.datadog.android.rum.resource.ResourceId -import com.datadog.android.telemetry.internal.TelemetryCoreConfiguration import com.datadog.android.telemetry.internal.TelemetryEventHandler -import com.datadog.android.telemetry.internal.TelemetryType import java.util.Locale import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.CountDownLatch @@ -65,7 +64,7 @@ internal class DatadogRumMonitor( private val writer: DataWriter, internal val handler: Handler, internal val telemetryEventHandler: TelemetryEventHandler, - sessionEndedMetricDispatcher: SessionMetricDispatcher, + private val sessionEndedMetricDispatcher: SessionMetricDispatcher, firstPartyHostHeaderTypeResolver: FirstPartyHostHeaderTypeResolver, cpuVitalMonitor: VitalMonitor, memoryVitalMonitor: VitalMonitor, @@ -539,6 +538,11 @@ internal class DatadogRumMonitor( ) } + @ExperimentalRumApi + override fun addViewLoadingTime(overwrite: Boolean) { + handleEvent(RumRawEvent.AddViewLoadingTime(overwrite = overwrite)) + } + override fun addLongTask(durationNs: Long, target: String) { handleEvent( RumRawEvent.AddLongTask(durationNs, target) @@ -581,97 +585,17 @@ internal class DatadogRumMonitor( debugListener = listener } - override fun sendDebugTelemetryEvent( - message: String, - additionalProperties: Map? - ) { - handleEvent( - RumRawEvent.SendTelemetry( - type = TelemetryType.DEBUG, - message = message, - stack = null, - kind = null, - coreConfiguration = null, - additionalProperties = additionalProperties - ) - ) - } - - override fun sendMetricEvent(message: String, additionalProperties: Map?) { - handleEvent( - RumRawEvent.SendTelemetry( - type = TelemetryType.DEBUG, - message = message, - stack = null, - kind = null, - coreConfiguration = null, - additionalProperties = additionalProperties, - isMetric = true - ) - ) - } - - override fun sendErrorTelemetryEvent( - message: String, - throwable: Throwable?, - additionalProperties: Map? - ) { - val stack: String? = throwable?.loggableStackTrace() - val kind: String? = throwable?.javaClass?.canonicalName ?: throwable?.javaClass?.simpleName - handleEvent( - RumRawEvent.SendTelemetry( - type = TelemetryType.ERROR, - message = message, - stack = stack, - kind = kind, - coreConfiguration = null, - additionalProperties = additionalProperties - ) - ) - } - - override fun sendErrorTelemetryEvent( - message: String, - stack: String?, - kind: String?, - additionalProperties: Map? - ) { - handleEvent( - RumRawEvent.SendTelemetry( - type = TelemetryType.ERROR, - message = message, - stack = stack, - kind = kind, - coreConfiguration = null, - additionalProperties = additionalProperties - ) - ) - } - - @Suppress("FunctionMaxLength") - override fun sendConfigurationTelemetryEvent(coreConfiguration: TelemetryCoreConfiguration) { - handleEvent( - RumRawEvent.SendTelemetry( - type = TelemetryType.CONFIGURATION, - message = "", - stack = null, - kind = null, - coreConfiguration = coreConfiguration, - additionalProperties = null - ) - ) + override fun addSessionReplaySkippedFrame() { + getCurrentSessionId { sessionId -> + sessionId?.let { + sessionEndedMetricDispatcher.onSessionReplaySkippedFrameTracked(it) + } + } } override fun notifyInterceptorInstantiated() { handleEvent( - RumRawEvent.SendTelemetry( - TelemetryType.INTERCEPTOR_SETUP, - message = "", - stack = null, - kind = null, - coreConfiguration = null, - additionalProperties = null - ) + RumRawEvent.TelemetryEventWrapper(InternalTelemetryEvent.InterceptorInstantiated) ) } @@ -690,6 +614,10 @@ internal class DatadogRumMonitor( return internalProxy } + override fun sendTelemetryEvent(telemetryEvent: InternalTelemetryEvent) { + handleEvent(RumRawEvent.TelemetryEventWrapper(telemetryEvent)) + } + // endregion // region Internal @@ -714,7 +642,7 @@ internal class DatadogRumMonitor( @Suppress("ThreadSafety") // Crash handling, can't delegate to another thread rootScope.handleEvent(event, writer) } - } else if (event is RumRawEvent.SendTelemetry) { + } else if (event is RumRawEvent.TelemetryEventWrapper) { telemetryEventHandler.handleEvent(event, writer) } else { handler.removeCallbacks(keepAliveRunnable) diff --git a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/net/RumRequestFactory.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/net/RumRequestFactory.kt index 9a337264b7..6277573bc9 100644 --- a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/net/RumRequestFactory.kt +++ b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/net/RumRequestFactory.kt @@ -9,11 +9,16 @@ package com.datadog.android.rum.internal.net import com.datadog.android.api.InternalLogger import com.datadog.android.api.context.DatadogContext import com.datadog.android.api.net.Request +import com.datadog.android.api.net.RequestExecutionContext import com.datadog.android.api.net.RequestFactory import com.datadog.android.api.storage.RawBatchEvent import com.datadog.android.core.internal.utils.join +import com.datadog.android.internal.utils.toHexString import com.datadog.android.rum.RumAttributes import com.datadog.android.rum.internal.domain.event.RumViewEventFilter +import java.security.DigestException +import java.security.MessageDigest +import java.security.NoSuchAlgorithmException import java.util.Locale import java.util.UUID @@ -25,20 +30,26 @@ internal class RumRequestFactory( override fun create( context: DatadogContext, + executionContext: RequestExecutionContext, batchData: List, batchMetadata: ByteArray? - ): Request? { + ): Request { val requestId = UUID.randomUUID().toString() - + val body = viewEventFilter.filterOutRedundantViewEvents(batchData) + .map { it.data } + .join( + separator = PAYLOAD_SEPARATOR, + internalLogger = internalLogger + ) + val idempotencyKey = idempotencyKey(body) return Request( id = requestId, description = "RUM Request", - url = buildUrl(context), + url = buildUrl(context, executionContext), headers = buildHeaders( requestId, - context.clientToken, - context.source, - context.sdkVersion + idempotencyKey, + context ), body = viewEventFilter.filterOutRedundantViewEvents(batchData) .map { it.data } @@ -50,7 +61,7 @@ internal class RumRequestFactory( ) } - private fun buildUrl(context: DatadogContext): String { + private fun buildUrl(context: DatadogContext, executionContext: RequestExecutionContext): String { val queryParams = mapOf( RequestFactory.QUERY_PARAM_SOURCE to context.source, RequestFactory.QUERY_PARAM_TAGS to buildTags( @@ -58,8 +69,10 @@ internal class RumRequestFactory( context.version, context.sdkVersion, context.env, - context.variant + context.variant, + executionContext ) + ) val intakeUrl = "%s/api/v2/rum".format( @@ -73,16 +86,19 @@ internal class RumRequestFactory( private fun buildHeaders( requestId: String, - clientToken: String, - source: String, - sdkVersion: String + idempotencyKey: String?, + context: DatadogContext ): Map { - return mapOf( - RequestFactory.HEADER_API_KEY to clientToken, - RequestFactory.HEADER_EVP_ORIGIN to source, - RequestFactory.HEADER_EVP_ORIGIN_VERSION to sdkVersion, + val headers = mutableMapOf( + RequestFactory.HEADER_API_KEY to context.clientToken, + RequestFactory.HEADER_EVP_ORIGIN to context.source, + RequestFactory.HEADER_EVP_ORIGIN_VERSION to context.sdkVersion, RequestFactory.HEADER_REQUEST_ID to requestId ) + if (idempotencyKey != null) { + headers[RequestFactory.DD_IDEMPOTENCY_KEY] = idempotencyKey + } + return headers } private fun buildTags( @@ -90,7 +106,8 @@ internal class RumRequestFactory( version: String, sdkVersion: String, env: String, - variant: String + variant: String, + executionContext: RequestExecutionContext ): String { val elements = mutableListOf( "${RumAttributes.SERVICE_NAME}:$serviceName", @@ -102,11 +119,58 @@ internal class RumRequestFactory( if (variant.isNotEmpty()) { elements.add("${RumAttributes.VARIANT}:$variant") } + if (executionContext.previousResponseCode != null) { + // we had a previous failure + elements.add("${RETRY_COUNT_KEY}:${executionContext.attemptNumber}") + elements.add("${LAST_FAILURE_STATUS_KEY}:${executionContext.previousResponseCode}") + } return elements.joinToString(",") } + @Suppress("TooGenericExceptionCaught") + private fun idempotencyKey(byteArray: ByteArray): String? { + try { + val digest = MessageDigest.getInstance("SHA-1") + val hashBytes = digest.digest(byteArray) + return hashBytes.toHexString() + } catch (e: DigestException) { + internalLogger.log( + InternalLogger.Level.ERROR, + InternalLogger.Target.MAINTAINER, + { SHA1_GENERATION_ERROR_MESSAGE }, + e + ) + } catch (e: IllegalArgumentException) { + internalLogger.log( + InternalLogger.Level.ERROR, + InternalLogger.Target.MAINTAINER, + { SHA1_GENERATION_ERROR_MESSAGE }, + e + ) + } catch (e: NoSuchAlgorithmException) { + internalLogger.log( + InternalLogger.Level.ERROR, + InternalLogger.Target.MAINTAINER, + { SHA1_NO_SUCH_ALGORITHM_EXCEPTION }, + e + ) + } catch (e: NullPointerException) { + internalLogger.log( + InternalLogger.Level.ERROR, + InternalLogger.Target.MAINTAINER, + { SHA1_GENERATION_ERROR_MESSAGE }, + e + ) + } + return null + } + companion object { private val PAYLOAD_SEPARATOR = "\n".toByteArray(Charsets.UTF_8) + internal const val RETRY_COUNT_KEY = "retry_count" + internal const val LAST_FAILURE_STATUS_KEY = "last_failure_status" + private const val SHA1_GENERATION_ERROR_MESSAGE = "Cannot generate SHA-1 hash for rum request idempotency key." + private const val SHA1_NO_SUCH_ALGORITHM_EXCEPTION = "SHA-1 algorithm could not be found in MessageDigest." } } diff --git a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/tracking/ActivityLifecycleTrackingStrategy.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/tracking/ActivityLifecycleTrackingStrategy.kt index 79b81a2fe2..062f8cd5e6 100644 --- a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/tracking/ActivityLifecycleTrackingStrategy.kt +++ b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/tracking/ActivityLifecycleTrackingStrategy.kt @@ -9,7 +9,6 @@ package com.datadog.android.rum.tracking import android.app.Activity import android.app.Application import android.content.Context -import android.content.Intent import android.os.Bundle import androidx.annotation.MainThread import com.datadog.android.api.InternalLogger @@ -106,57 +105,6 @@ abstract class ActivityLifecycleTrackingStrategy : // endregion - // region Utils - - /** - * Maps the Bundle key - value properties into compatible attributes for the Rum Events. - * @param intent the [Intent] we need to transform. Returns an empty Map if this is null. - */ - protected fun convertToRumAttributes(intent: Intent?): Map { - if (intent == null) return emptyMap() - - val attributes = mutableMapOf() - - intent.action?.let { - attributes[INTENT_ACTION_TAG] = it - } - intent.dataString?.let { - attributes[INTENT_URI_TAG] = it - } - - intent.safeExtras?.let { bundle -> - bundle.keySet().forEach { - // TODO RUM-503 Bundle#get is deprecated, but there is no replacement for it. - // Issue is opened in the Google Issue Tracker. - @Suppress("DEPRECATION") - attributes["$ARGUMENT_TAG.$it"] = bundle.get(it) - } - } - - return attributes - } - - /** - * Maps the Bundle key - value properties into compatible attributes for the Rum Events. - * @param bundle the Bundle we need to transform. Returns an empty Map if this is null. - */ - protected fun convertToRumAttributes(bundle: Bundle?): Map { - if (bundle == null) return emptyMap() - - val attributes = mutableMapOf() - - bundle.keySet().forEach { - // TODO RUM-503 Bundle#get is deprecated, but there is no replacement for it. - // Issue is opened in the Google Issue Tracker. - @Suppress("DEPRECATION") - attributes["$ARGUMENT_TAG.$it"] = bundle.get(it) - } - - return attributes - } - - // endregion - // region Helper /** @@ -181,24 +129,9 @@ abstract class ActivityLifecycleTrackingStrategy : } } - private val Intent.safeExtras: Bundle? - get() = try { - // old Androids can throw different exceptions here making native calls - extras - } catch (@Suppress("TooGenericExceptionCaught") e: Exception) { - internalLogger.log( - InternalLogger.Level.ERROR, - InternalLogger.Target.USER, - { "Error getting Intent extras, ignoring it." }, - e - ) - null - } + // endregion internal companion object { - internal const val ARGUMENT_TAG = "view.arguments" - internal const val INTENT_ACTION_TAG = "view.intent.action" - internal const val INTENT_URI_TAG = "view.intent.uri" internal const val EXTRA_SYNTHETICS_TEST_ID = "_dd.synthetics.test_id" internal const val EXTRA_SYNTHETICS_RESULT_ID = "_dd.synthetics.result_id" diff --git a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/tracking/ActivityViewTrackingStrategy.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/tracking/ActivityViewTrackingStrategy.kt index 2ebb49c874..07a16354cd 100644 --- a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/tracking/ActivityViewTrackingStrategy.kt +++ b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/tracking/ActivityViewTrackingStrategy.kt @@ -7,7 +7,10 @@ package com.datadog.android.rum.tracking import android.app.Activity +import android.content.Intent +import android.os.Bundle import androidx.annotation.MainThread +import com.datadog.android.api.InternalLogger import com.datadog.android.core.internal.utils.scheduleSafe import com.datadog.android.rum.GlobalRumMonitor import com.datadog.android.rum.RumMonitor @@ -101,9 +104,47 @@ constructor( return withSdkCore { GlobalRumMonitor.get(it) } } + /** + * Maps the Bundle key - value properties into compatible attributes for the Rum Events. + * @param intent the [Intent] we need to transform. Returns an empty Map if this is null. + */ + private fun convertToRumAttributes(intent: Intent?): Map { + if (intent == null) return emptyMap() + + val attributes = mutableMapOf() + + intent.action?.let { + attributes[INTENT_ACTION_TAG] = it + } + intent.dataString?.let { + attributes[INTENT_URI_TAG] = it + } + + attributes.putAll(intent.safeExtras.convertToRumViewAttributes()) + + return attributes + } + + private val Intent.safeExtras: Bundle? + get() = try { + // old Androids can throw different exceptions here making native calls + extras + } catch (@Suppress("TooGenericExceptionCaught") e: Exception) { + internalLogger.log( + InternalLogger.Level.ERROR, + InternalLogger.Target.USER, + { "Error getting Intent extras, ignoring it." }, + e + ) + null + } + // endregion internal companion object { private const val STOP_VIEW_DELAY_MS = 200L + + internal const val INTENT_ACTION_TAG = "view.intent.action" + internal const val INTENT_URI_TAG = "view.intent.uri" } } diff --git a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/tracking/BundleExt.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/tracking/BundleExt.kt new file mode 100644 index 0000000000..599b32d503 --- /dev/null +++ b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/tracking/BundleExt.kt @@ -0,0 +1,29 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.datadog.android.rum.tracking + +import android.os.Bundle + +internal const val ARGUMENT_TAG = "view.arguments" + +/** + * Converts this bundle into a Map of attributes to be included in a RUM View event. + */ +fun Bundle?.convertToRumViewAttributes(): Map { + if (this == null) return emptyMap() + + val attributes = mutableMapOf() + + keySet().forEach { + // TODO RUM-503 Bundle#get is deprecated, but there is no replacement for it. + // Issue is opened in the Google Issue Tracker. + @Suppress("DEPRECATION") + attributes["$ARGUMENT_TAG.$it"] = get(it) + } + + return attributes +} diff --git a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/tracking/FragmentViewTrackingStrategy.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/tracking/FragmentViewTrackingStrategy.kt index 303aeecbc2..19dbeefbae 100644 --- a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/tracking/FragmentViewTrackingStrategy.kt +++ b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/tracking/FragmentViewTrackingStrategy.kt @@ -73,7 +73,7 @@ internal constructor( if (rumFeature != null && rumMonitor != null) { AndroidXFragmentLifecycleCallbacks( argumentsProvider = { - if (trackArguments) convertToRumAttributes(it.arguments) else emptyMap() + if (trackArguments) it.arguments.convertToRumViewAttributes() else emptyMap() }, componentPredicate = supportFragmentComponentPredicate, rumMonitor = rumMonitor, @@ -96,7 +96,7 @@ internal constructor( ) { OreoFragmentLifecycleCallbacks( argumentsProvider = { - if (trackArguments) convertToRumAttributes(it.arguments) else emptyMap() + if (trackArguments) it.arguments.convertToRumViewAttributes() else emptyMap() }, componentPredicate = defaultFragmentComponentPredicate, rumMonitor = rumMonitor, diff --git a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/tracking/NavigationViewTrackingStrategy.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/tracking/NavigationViewTrackingStrategy.kt index 63f55f0de3..cd24adc246 100644 --- a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/tracking/NavigationViewTrackingStrategy.kt +++ b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/tracking/NavigationViewTrackingStrategy.kt @@ -94,7 +94,7 @@ class NavigationViewTrackingStrategy( ) { val rumMonitor = withSdkCore { GlobalRumMonitor.get(it) } componentPredicate.runIfValid(destination, internalLogger) { - val attributes = if (trackArguments) convertToRumAttributes(arguments) else emptyMap() + val attributes = if (trackArguments) arguments.convertToRumViewAttributes() else emptyMap() val viewName = componentPredicate.resolveViewName(destination) rumMonitor?.startView(destination, viewName, attributes) } diff --git a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/telemetry/internal/Telemetry.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/telemetry/internal/Telemetry.kt deleted file mode 100644 index 9100732a71..0000000000 --- a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/telemetry/internal/Telemetry.kt +++ /dev/null @@ -1,51 +0,0 @@ -/* - * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. - * This product includes software developed at Datadog (https://www.datadoghq.com/). - * Copyright 2016-Present Datadog, Inc. - */ - -package com.datadog.android.telemetry.internal - -import com.datadog.android.api.SdkCore -import com.datadog.android.rum.GlobalRumMonitor -import com.datadog.android.rum.internal.monitor.AdvancedRumMonitor -import com.datadog.android.rum.internal.monitor.NoOpAdvancedRumMonitor - -internal class Telemetry( - private val sdkCore: SdkCore -) { - - internal val rumMonitor: AdvancedRumMonitor - get() { - return GlobalRumMonitor.get(sdkCore) as? AdvancedRumMonitor ?: NoOpAdvancedRumMonitor() - } - - fun error( - message: String, - throwable: Throwable? = null, - additionalProperties: Map? = null - ) { - (GlobalRumMonitor.get(sdkCore) as? AdvancedRumMonitor) - ?.sendErrorTelemetryEvent(message, throwable, additionalProperties) - } - - fun error( - message: String, - stack: String? = null, - kind: String? = null, - additionalProperties: Map? = null - ) { - (GlobalRumMonitor.get(sdkCore) as? AdvancedRumMonitor) - ?.sendErrorTelemetryEvent(message, stack, kind, additionalProperties) - } - - fun debug(message: String, additionalProperties: Map? = null) { - (GlobalRumMonitor.get(sdkCore) as? AdvancedRumMonitor) - ?.sendDebugTelemetryEvent(message, additionalProperties) - } - - fun metric(message: String, additionalProperties: Map? = null) { - (GlobalRumMonitor.get(sdkCore) as? AdvancedRumMonitor) - ?.sendMetricEvent(message, additionalProperties) - } -} diff --git a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/telemetry/internal/TelemetryCoreConfiguration.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/telemetry/internal/TelemetryCoreConfiguration.kt deleted file mode 100644 index 173ce3d49d..0000000000 --- a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/telemetry/internal/TelemetryCoreConfiguration.kt +++ /dev/null @@ -1,59 +0,0 @@ -/* - * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. - * This product includes software developed at Datadog (https://www.datadoghq.com/). - * Copyright 2016-Present Datadog, Inc. - */ - -package com.datadog.android.telemetry.internal - -import com.datadog.android.api.InternalLogger - -internal data class TelemetryCoreConfiguration( - val trackErrors: Boolean, - // batchSize.windowDurationMs - val batchSize: Long, - // uploadFrequency.baseStepMs - val batchUploadFrequency: Long, - val useProxy: Boolean, - val useLocalEncryption: Boolean, - // batchProcessingLevel.maxBatchesPerUploadJob - val batchProcessingLevel: Int -) { - companion object { - fun fromEvent(event: Map<*, *>, internalLogger: InternalLogger): TelemetryCoreConfiguration? { - val trackErrors = event["track_errors"] as? Boolean - val batchSize = event["batch_size"] as? Long - val batchUploadFrequency = event["batch_upload_frequency"] as? Long - val useProxy = event["use_proxy"] as? Boolean - val useLocalEncryption = event["use_local_encryption"] as? Boolean - val batchProcessingLevel = event["batch_processing_level"] as? Int - - @Suppress("ComplexCondition") - if (trackErrors == null || batchSize == null || batchUploadFrequency == null || - useProxy == null || useLocalEncryption == null || batchProcessingLevel == null - ) { - // TODO RUM-375 Do an intelligent reporting when message values are missing/have - // wrong type, reporting the parameter name and what is exactly wrong - // this applies to all messages going through the message bus - internalLogger.log( - InternalLogger.Level.ERROR, - InternalLogger.Target.USER, - { - "One of the mandatory parameters for core configuration telemetry" + - " reporting is either missing or have a wrong type." - } - ) - return null - } - - return TelemetryCoreConfiguration( - trackErrors = trackErrors, - batchSize = batchSize, - batchUploadFrequency = batchUploadFrequency, - useProxy = useProxy, - useLocalEncryption = useLocalEncryption, - batchProcessingLevel = batchProcessingLevel - ) - } - } -} diff --git a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/telemetry/internal/TelemetryEventExt.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/telemetry/internal/TelemetryEventExt.kt index c694758015..8b15737dc6 100644 --- a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/telemetry/internal/TelemetryEventExt.kt +++ b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/telemetry/internal/TelemetryEventExt.kt @@ -10,6 +10,7 @@ import com.datadog.android.api.InternalLogger import com.datadog.android.telemetry.model.TelemetryConfigurationEvent import com.datadog.android.telemetry.model.TelemetryDebugEvent import com.datadog.android.telemetry.model.TelemetryErrorEvent +import com.datadog.android.telemetry.model.TelemetryUsageEvent import java.util.Locale internal fun TelemetryDebugEvent.Source.Companion.tryFromSource( @@ -46,6 +47,23 @@ internal fun TelemetryErrorEvent.Source.Companion.tryFromSource( } } +internal fun TelemetryUsageEvent.Source.Companion.tryFromSource( + source: String, + internalLogger: InternalLogger +): TelemetryUsageEvent.Source? { + return try { + fromJson(source) + } catch (e: NoSuchElementException) { + internalLogger.log( + InternalLogger.Level.ERROR, + InternalLogger.Target.USER, + { UNKNOWN_SOURCE_WARNING_MESSAGE_FORMAT.format(Locale.US, source) }, + e + ) + null + } +} + internal fun TelemetryConfigurationEvent.Source.Companion.tryFromSource( source: String, internalLogger: InternalLogger diff --git a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/telemetry/internal/TelemetryEventHandler.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/telemetry/internal/TelemetryEventHandler.kt index 92856f6466..065af75f97 100644 --- a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/telemetry/internal/TelemetryEventHandler.kt +++ b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/telemetry/internal/TelemetryEventHandler.kt @@ -15,6 +15,7 @@ import com.datadog.android.api.storage.EventType import com.datadog.android.core.InternalSdkCore import com.datadog.android.core.sampling.RateBasedSampler import com.datadog.android.core.sampling.Sampler +import com.datadog.android.internal.telemetry.InternalTelemetryEvent import com.datadog.android.rum.RumSessionListener import com.datadog.android.rum.internal.RumFeature import com.datadog.android.rum.internal.domain.RumContext @@ -27,9 +28,11 @@ import com.datadog.android.rum.tracking.NavigationViewTrackingStrategy import com.datadog.android.telemetry.model.TelemetryConfigurationEvent import com.datadog.android.telemetry.model.TelemetryDebugEvent import com.datadog.android.telemetry.model.TelemetryErrorEvent +import com.datadog.android.telemetry.model.TelemetryUsageEvent import java.util.Locale import com.datadog.android.telemetry.model.TelemetryConfigurationEvent.ViewTrackingStrategy as VTS +@Suppress("TooManyFunctions") internal class TelemetryEventHandler( internal val sdkCore: InternalSdkCore, internal val eventSampler: Sampler, @@ -44,19 +47,29 @@ internal class TelemetryEventHandler( private var totalEventsSeenInCurrentSession = 0 @AnyThread + @Suppress("LongMethod") fun handleEvent( - event: RumRawEvent.SendTelemetry, + wrappedEvent: RumRawEvent.TelemetryEventWrapper, writer: DataWriter ) { + val event = wrappedEvent.event if (!canWrite(event)) return eventIDsSeenInCurrentSession.add(event.identity) totalEventsSeenInCurrentSession++ - sdkCore.getFeature(Feature.RUM_FEATURE_NAME)?.withWriteContext { datadogContext, eventBatchWriter -> - val timestamp = event.eventTime.timestamp + datadogContext.time.serverTimeOffsetMs - val telemetryEvent: Any? = when (event.type) { - TelemetryType.DEBUG -> { + val timestamp = wrappedEvent.eventTime.timestamp + datadogContext.time.serverTimeOffsetMs + val telemetryEvent: Any? = when (event) { + is InternalTelemetryEvent.Log.Debug -> { + createDebugEvent( + datadogContext = datadogContext, + timestamp = timestamp, + message = event.message, + additionalProperties = event.additionalProperties + ) + } + + is InternalTelemetryEvent.Metric -> { createDebugEvent( datadogContext = datadogContext, timestamp = timestamp, @@ -65,7 +78,7 @@ internal class TelemetryEventHandler( ) } - TelemetryType.ERROR -> { + is InternalTelemetryEvent.Log.Error -> { sessionEndedMetricDispatcher.onSdkErrorTracked( sessionId = datadogContext.rumContext().sessionId, errorKind = event.kind @@ -74,38 +87,33 @@ internal class TelemetryEventHandler( datadogContext = datadogContext, timestamp = timestamp, message = event.message, - stack = event.stack, - kind = event.kind, + stack = event.resolveStacktrace(), + kind = event.resolveKind(), additionalProperties = event.additionalProperties ) } - TelemetryType.CONFIGURATION -> { - val coreConfiguration = event.coreConfiguration - if (coreConfiguration == null) { - createErrorEvent( - datadogContext = datadogContext, - timestamp = timestamp, - message = "Trying to send configuration event with null config", - stack = null, - kind = null, - additionalProperties = null - ) - } else { - createConfigurationEvent( - datadogContext, - timestamp, - coreConfiguration - ) - } + is InternalTelemetryEvent.Configuration -> { + createConfigurationEvent( + datadogContext, + timestamp, + event + ) } - TelemetryType.INTERCEPTOR_SETUP -> { + is InternalTelemetryEvent.ApiUsage -> { + createApiUsageEvent( + datadogContext = datadogContext, + timestamp = timestamp, + event = event + ) + } + + is InternalTelemetryEvent.InterceptorInstantiated -> { trackNetworkRequests = true null } } - if (telemetryEvent != null) { writer.write(eventBatchWriter, telemetryEvent, EventType.TELEMETRY) } @@ -120,16 +128,16 @@ internal class TelemetryEventHandler( // region private @Suppress("ReturnCount") - private fun canWrite(event: RumRawEvent.SendTelemetry): Boolean { + private fun canWrite(event: InternalTelemetryEvent): Boolean { if (!eventSampler.sample()) return false - if (event.type == TelemetryType.CONFIGURATION && !configurationExtraSampler.sample()) { + if (event is InternalTelemetryEvent.Configuration && !configurationExtraSampler.sample()) { return false } val eventIdentity = event.identity - if (!event.isMetric && eventIDsSeenInCurrentSession.contains(eventIdentity)) { + if (isLog(event) && eventIDsSeenInCurrentSession.contains(eventIdentity)) { sdkCore.internalLogger.log( InternalLogger.Level.INFO, InternalLogger.Target.MAINTAINER, @@ -150,6 +158,10 @@ internal class TelemetryEventHandler( return true } + private fun isLog(event: InternalTelemetryEvent): Boolean { + return event is InternalTelemetryEvent.Log + } + private fun createDebugEvent( datadogContext: DatadogContext, timestamp: Long, @@ -243,17 +255,21 @@ internal class TelemetryEventHandler( private fun createConfigurationEvent( datadogContext: DatadogContext, timestamp: Long, - coreConfiguration: TelemetryCoreConfiguration + event: InternalTelemetryEvent.Configuration ): TelemetryConfigurationEvent { val traceFeature = sdkCore.getFeature(Feature.TRACING_FEATURE_NAME) val sessionReplayFeatureContext = sdkCore.getFeatureContext(Feature.SESSION_REPLAY_FEATURE_NAME) val sessionReplaySampleRate = sessionReplayFeatureContext[SESSION_REPLAY_SAMPLE_RATE_KEY] as? Long - val startSessionReplayManually = - sessionReplayFeatureContext[SESSION_REPLAY_MANUAL_RECORDING_KEY] as? Boolean - val sessionReplayPrivacy = sessionReplayFeatureContext[SESSION_REPLAY_PRIVACY_KEY] - as? String + val startRecordingImmediately = + sessionReplayFeatureContext[SESSION_REPLAY_START_IMMEDIATE_RECORDING_KEY] as? Boolean + val sessionReplayImagePrivacy = + sessionReplayFeatureContext[SESSION_REPLAY_IMAGE_PRIVACY_KEY] as? String + val sessionReplayTouchPrivacy = + sessionReplayFeatureContext[SESSION_REPLAY_TOUCH_PRIVACY_KEY] as? String + val sessionReplayTextAndInputPrivacy = + sessionReplayFeatureContext[SESSION_REPLAY_TEXT_AND_INPUT_PRIVACY_KEY] as? String val rumConfig = sdkCore.getFeature(Feature.RUM_FEATURE_NAME) ?.unwrap() ?.configuration @@ -298,30 +314,76 @@ internal class TelemetryEventHandler( configuration = TelemetryConfigurationEvent.Configuration( sessionSampleRate = rumConfig?.sampleRate?.toLong(), telemetrySampleRate = rumConfig?.telemetrySampleRate?.toLong(), - useProxy = coreConfiguration.useProxy, + useProxy = event.useProxy, trackFrustrations = rumConfig?.trackFrustrations, - useLocalEncryption = coreConfiguration.useLocalEncryption, + useLocalEncryption = event.useLocalEncryption, viewTrackingStrategy = viewTrackingStrategy, trackBackgroundEvents = rumConfig?.backgroundEventTracking, trackInteractions = rumConfig?.userActionTracking != null, - trackErrors = coreConfiguration.trackErrors, + trackErrors = event.trackErrors, trackNativeLongTasks = rumConfig?.longTaskTrackingStrategy != null, - batchSize = coreConfiguration.batchSize, - batchUploadFrequency = coreConfiguration.batchUploadFrequency, + batchSize = event.batchSize, + batchUploadFrequency = event.batchUploadFrequency, mobileVitalsUpdatePeriod = rumConfig?.vitalsMonitorUpdateFrequency?.periodInMs, useTracing = useTracing, tracerApi = tracerApi?.name, tracerApiVersion = openTelemetryApiVersion, trackNetworkRequests = trackNetworkRequests, sessionReplaySampleRate = sessionReplaySampleRate, - defaultPrivacyLevel = sessionReplayPrivacy, - startSessionReplayRecordingManually = startSessionReplayManually, - batchProcessingLevel = coreConfiguration.batchProcessingLevel.toLong() + imagePrivacyLevel = sessionReplayImagePrivacy, + touchPrivacyLevel = sessionReplayTouchPrivacy, + textAndInputPrivacyLevel = sessionReplayTextAndInputPrivacy, + startRecordingImmediately = startRecordingImmediately, + batchProcessingLevel = event.batchProcessingLevel.toLong() ) ) ) } + private fun createApiUsageEvent( + datadogContext: DatadogContext, + timestamp: Long, + event: InternalTelemetryEvent.ApiUsage + ): TelemetryUsageEvent? { + val rumContext = datadogContext.rumContext() + return when (event) { + is InternalTelemetryEvent.ApiUsage.AddViewLoadingTime -> { + TelemetryUsageEvent( + dd = TelemetryUsageEvent.Dd(), + date = timestamp, + source = TelemetryUsageEvent.Source.tryFromSource( + datadogContext.source, + sdkCore.internalLogger + ) ?: TelemetryUsageEvent.Source.ANDROID, + service = TELEMETRY_SERVICE_NAME, + version = datadogContext.sdkVersion, + application = TelemetryUsageEvent.Application(rumContext.applicationId), + session = TelemetryUsageEvent.Session(rumContext.sessionId), + view = rumContext.viewId?.let { TelemetryUsageEvent.View(it) }, + action = rumContext.actionId?.let { TelemetryUsageEvent.Action(it) }, + telemetry = TelemetryUsageEvent.Telemetry( + additionalProperties = event.additionalProperties, + device = TelemetryUsageEvent.Device( + architecture = datadogContext.deviceInfo.architecture, + brand = datadogContext.deviceInfo.deviceBrand, + model = datadogContext.deviceInfo.deviceModel + ), + os = TelemetryUsageEvent.Os( + build = datadogContext.deviceInfo.deviceBuildId, + version = datadogContext.deviceInfo.osVersion, + name = datadogContext.deviceInfo.osName + ), + usage = TelemetryUsageEvent.Usage.AddViewLoadingTime( + overwritten = event.overwrite, + noView = event.noView, + noActiveView = event.noActiveView + ) + ) + ) + } + } + } + private fun isGlobalTracerRegistered(): Boolean { // We don't reference io.opentracing from RUM directly, so using reflection for this. // Would be nice to add the test with the flavor which is has no io.opentracing and test @@ -393,8 +455,10 @@ internal class TelemetryEventHandler( internal const val IS_OPENTELEMETRY_ENABLED_CONTEXT_KEY = "is_opentelemetry_enabled" internal const val OPENTELEMETRY_API_VERSION_CONTEXT_KEY = "opentelemetry_api_version" internal const val SESSION_REPLAY_SAMPLE_RATE_KEY = "session_replay_sample_rate" - internal const val SESSION_REPLAY_PRIVACY_KEY = "session_replay_privacy" - internal const val SESSION_REPLAY_MANUAL_RECORDING_KEY = - "session_replay_requires_manual_recording" + internal const val SESSION_REPLAY_TEXT_AND_INPUT_PRIVACY_KEY = "session_replay_text_and_input_privacy" + internal const val SESSION_REPLAY_IMAGE_PRIVACY_KEY = "session_replay_image_privacy" + internal const val SESSION_REPLAY_TOUCH_PRIVACY_KEY = "session_replay_touch_privacy" + internal const val SESSION_REPLAY_START_IMMEDIATE_RECORDING_KEY = + "session_replay_start_immediate_recording" } } diff --git a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/telemetry/internal/TelemetryEventId.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/telemetry/internal/TelemetryEventId.kt index d4387f1960..9cfd255d16 100644 --- a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/telemetry/internal/TelemetryEventId.kt +++ b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/telemetry/internal/TelemetryEventId.kt @@ -6,7 +6,7 @@ package com.datadog.android.telemetry.internal -import com.datadog.android.rum.internal.domain.scope.RumRawEvent +import com.datadog.android.internal.telemetry.InternalTelemetryEvent internal data class TelemetryEventId( val type: TelemetryType, @@ -14,7 +14,22 @@ internal data class TelemetryEventId( val kind: String? ) -internal val RumRawEvent.SendTelemetry.identity: TelemetryEventId +internal val InternalTelemetryEvent.identity: TelemetryEventId get() { - return TelemetryEventId(type, message, kind) + return when (this) { + is InternalTelemetryEvent.Log.Error -> TelemetryEventId(type(), message, resolveKind()) + is InternalTelemetryEvent.Log.Debug -> TelemetryEventId(type(), message, null) + else -> TelemetryEventId(type(), "", null) + } } + +internal fun InternalTelemetryEvent.type(): TelemetryType { + return when (this) { + is InternalTelemetryEvent.Log.Debug -> TelemetryType.DEBUG + is InternalTelemetryEvent.Log.Error -> TelemetryType.ERROR + is InternalTelemetryEvent.Configuration -> TelemetryType.CONFIGURATION + is InternalTelemetryEvent.Metric -> TelemetryType.METRIC + is InternalTelemetryEvent.ApiUsage -> TelemetryType.API_USAGE + is InternalTelemetryEvent.InterceptorInstantiated -> TelemetryType.INTERCEPTOR_SETUP + } +} diff --git a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/telemetry/internal/TelemetryType.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/telemetry/internal/TelemetryType.kt index b1b1d4f11a..860346b451 100644 --- a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/telemetry/internal/TelemetryType.kt +++ b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/telemetry/internal/TelemetryType.kt @@ -10,5 +10,7 @@ internal enum class TelemetryType { DEBUG, ERROR, CONFIGURATION, - INTERCEPTOR_SETUP + INTERCEPTOR_SETUP, + API_USAGE, + METRIC } diff --git a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/RumFeatureTest.kt b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/RumFeatureTest.kt index 671a0a1071..9c197ec0a5 100644 --- a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/RumFeatureTest.kt +++ b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/RumFeatureTest.kt @@ -19,6 +19,7 @@ import com.datadog.android.core.feature.event.ThreadDump import com.datadog.android.core.internal.system.BuildSdkVersionProvider import com.datadog.android.event.EventMapper import com.datadog.android.event.MapperSerializer +import com.datadog.android.internal.telemetry.InternalTelemetryEvent import com.datadog.android.rum.GlobalRumMonitor import com.datadog.android.rum.RumErrorSource import com.datadog.android.rum.assertj.RumFeatureAssert @@ -45,7 +46,6 @@ import com.datadog.android.rum.utils.config.ApplicationContextTestConfiguration import com.datadog.android.rum.utils.config.MainLooperTestConfiguration import com.datadog.android.rum.utils.forge.Configurator import com.datadog.android.rum.utils.verifyLog -import com.datadog.android.telemetry.internal.TelemetryCoreConfiguration import com.datadog.tools.unit.annotations.TestConfigurationsProvider import com.datadog.tools.unit.annotations.TestTargetApi import com.datadog.tools.unit.extensions.ApiLevelExtension @@ -57,7 +57,6 @@ import com.datadog.tools.unit.forge.exhaustiveAttributes import com.datadog.tools.unit.getFieldValue import com.google.gson.JsonObject import fr.xgouchet.elmyr.Forge -import fr.xgouchet.elmyr.annotation.BoolForgery import fr.xgouchet.elmyr.annotation.Forgery import fr.xgouchet.elmyr.annotation.IntForgery import fr.xgouchet.elmyr.annotation.LongForgery @@ -1175,6 +1174,8 @@ internal class RumFeatureTest { // endregion + // region FeatureEventReceiver#onReceive + webview notification + @Test fun `M notify webview event received W onReceive() {webview event received}`() { // Given @@ -1191,324 +1192,26 @@ internal class RumFeatureTest { verifyNoInteractions(mockInternalLogger) } - // region FeatureEventReceiver#onReceive + telemetry event - - @Test - fun `M handle telemetry debug event W onReceive(){no additionalProperties}`( - @StringForgery fakeMessage: String - ) { - // Given - testedFeature.onInitialize(appContext.mockInstance) - val event = mapOf( - "type" to "telemetry_debug", - "message" to fakeMessage - ) - - // When - testedFeature.onReceive(event) - - // Then - verify(mockRumMonitor).sendDebugTelemetryEvent(fakeMessage, null) - verifyNoMoreInteractions(mockRumMonitor) - verifyNoInteractions(mockInternalLogger) - } - - @Test - fun `M handle telemetry debug event W onReceive() {additionalProperties}`( - @StringForgery fakeMessage: String, - forge: Forge - ) { - // Given - val fakeAdditionalProperties = forge.exhaustiveAttributes() - testedFeature.onInitialize(appContext.mockInstance) - val event = mapOf( - "type" to "telemetry_debug", - "message" to fakeMessage, - "additionalProperties" to fakeAdditionalProperties - ) - - // When - testedFeature.onReceive(event) - - // Then - verify(mockRumMonitor).sendDebugTelemetryEvent(fakeMessage, fakeAdditionalProperties) - verifyNoMoreInteractions(mockRumMonitor) - verifyNoInteractions(mockInternalLogger) - } - - @Test - fun `M handle telemetry debug event W onReceive {additionalProperties is null}`( - @StringForgery fakeMessage: String - ) { - // Given - val fakeAdditionalProperties = null - testedFeature.onInitialize(appContext.mockInstance) - val event = mapOf( - "type" to "telemetry_debug", - "message" to fakeMessage, - "additionalProperties" to fakeAdditionalProperties - ) - - // When - testedFeature.onReceive(event) - - // Then - verify(mockRumMonitor).sendDebugTelemetryEvent(fakeMessage, fakeAdditionalProperties) - verifyNoMoreInteractions(mockRumMonitor) - verifyNoInteractions(mockInternalLogger) - } - - @Test - fun `M log warning W onReceive() { telemetry debug + message is missing }`() { - // Given - val event = mapOf( - "type" to "telemetry_debug" - ) - - // When - testedFeature.onReceive(event) - - // Then - mockInternalLogger.verifyLog( - InternalLogger.Level.WARN, - InternalLogger.Target.MAINTAINER, - RumFeature.TELEMETRY_MISSING_MESSAGE_FIELD - ) - - verifyNoInteractions(mockRumMonitor) - } - - @Test - fun `M handle telemetry error event W onReceive() { with throwable, no additional properties }`( - @StringForgery fakeMessage: String, - forge: Forge - ) { - // Given - testedFeature.onInitialize(appContext.mockInstance) - val fakeThrowable = forge.aThrowable() - val event = mapOf( - "type" to "telemetry_error", - "message" to fakeMessage, - "throwable" to fakeThrowable - ) - - // When - testedFeature.onReceive(event) - - // Then - verify(mockRumMonitor) - .sendErrorTelemetryEvent(fakeMessage, fakeThrowable) - verifyNoMoreInteractions(mockRumMonitor) - verifyNoInteractions(mockInternalLogger) - } - - @Test - fun `M handle telemetry error event W onReceive() { with throwable, with additional properties }`( - @StringForgery fakeMessage: String, - forge: Forge - ) { - // Given - testedFeature.onInitialize(appContext.mockInstance) - val fakeThrowable = forge.aThrowable() - val fakeAdditionalProperties = forge.exhaustiveAttributes() - val event = mapOf( - "type" to "telemetry_error", - "message" to fakeMessage, - "throwable" to fakeThrowable, - "additionalProperties" to fakeAdditionalProperties - ) - - // When - testedFeature.onReceive(event) - - // Then - verify(mockRumMonitor) - .sendErrorTelemetryEvent(fakeMessage, fakeThrowable, fakeAdditionalProperties) - verifyNoMoreInteractions(mockRumMonitor) - verifyNoInteractions(mockInternalLogger) - } - - @Test - fun `M handle telemetry error event W onReceive() { with stack and kind, no additional properties }`( - @StringForgery fakeMessage: String, - forge: Forge - ) { - // Given - testedFeature.onInitialize(appContext.mockInstance) - val fakeStack = forge.aNullable { aString() } - val fakeKind = forge.aNullable { aString() } - val event = mapOf( - "type" to "telemetry_error", - "message" to fakeMessage, - "stacktrace" to fakeStack, - "kind" to fakeKind - ) - - // When - testedFeature.onReceive(event) - - // Then - verify(mockRumMonitor) - .sendErrorTelemetryEvent(fakeMessage, fakeStack, fakeKind) - - verifyNoMoreInteractions(mockRumMonitor) - - verifyNoInteractions(mockInternalLogger) - } - - @Test - fun `M handle telemetry error event W onReceive() { with stack and kind, with additional properties }`( - @StringForgery fakeMessage: String, - forge: Forge - ) { - // Given - testedFeature.onInitialize(appContext.mockInstance) - val fakeStack = forge.aNullable { aString() } - val fakeKind = forge.aNullable { aString() } - val fakeAdditionalProperties = forge.exhaustiveAttributes() - val event = mapOf( - "type" to "telemetry_error", - "message" to fakeMessage, - "stacktrace" to fakeStack, - "kind" to fakeKind, - "additionalProperties" to fakeAdditionalProperties - ) - - // When - testedFeature.onReceive(event) - - // Then - verify(mockRumMonitor) - .sendErrorTelemetryEvent(fakeMessage, fakeStack, fakeKind, fakeAdditionalProperties) - - verifyNoMoreInteractions(mockRumMonitor) - - verifyNoInteractions(mockInternalLogger) - } - - @Test - fun `M handle metric event W onReceive()`( - @StringForgery fakeMessage: String, - forge: Forge - ) { - // Given - val fakeAdditionalProperties = forge.exhaustiveAttributes() - testedFeature.onInitialize(appContext.mockInstance) - val event = mapOf( - "type" to RumFeature.MOBILE_METRIC_MESSAGE_TYPE, - "message" to fakeMessage, - "additionalProperties" to fakeAdditionalProperties - ) - - // When - testedFeature.onReceive(event) + // endregion - // Then - verify(mockRumMonitor).sendMetricEvent(fakeMessage, fakeAdditionalProperties) - verifyNoMoreInteractions(mockRumMonitor) - verifyNoInteractions(mockInternalLogger) - } + // region FeatureEventReceiver#onReceive + telemetry event @Test - fun `M handle metric event W onReceive(){no additionalProperties}`( - @StringForgery fakeMessage: String + fun `M handle telemetry event W onReceive()`( + @Forgery fakeInternalTelemetryEvent: InternalTelemetryEvent ) { // Given testedFeature.onInitialize(appContext.mockInstance) - val event = mapOf( - "type" to RumFeature.MOBILE_METRIC_MESSAGE_TYPE, - "message" to fakeMessage - ) // When - testedFeature.onReceive(event) + testedFeature.onReceive(fakeInternalTelemetryEvent) // Then - verify(mockRumMonitor).sendMetricEvent(fakeMessage, null) + verify(mockRumMonitor).sendTelemetryEvent(fakeInternalTelemetryEvent) verifyNoMoreInteractions(mockRumMonitor) verifyNoInteractions(mockInternalLogger) } - @Test - fun `M handle metric event W onReceive(){no message}`() { - // Given - testedFeature.onInitialize(appContext.mockInstance) - val event = mapOf( - "type" to RumFeature.MOBILE_METRIC_MESSAGE_TYPE - ) - - // When - testedFeature.onReceive(event) - - // Then - mockInternalLogger.verifyLog( - InternalLogger.Level.WARN, - InternalLogger.Target.MAINTAINER, - RumFeature.TELEMETRY_MISSING_MESSAGE_FIELD - ) - verifyNoInteractions(mockRumMonitor) - } - - @Test - fun `M log warning W onReceive() { telemetry error + message is missing }`() { - // Given - val event = mapOf( - "type" to "telemetry_error" - ) - - // When - testedFeature.onReceive(event) - - // Then - mockInternalLogger.verifyLog( - InternalLogger.Level.WARN, - InternalLogger.Target.MAINTAINER, - RumFeature.TELEMETRY_MISSING_MESSAGE_FIELD - ) - - verifyNoInteractions(mockRumMonitor) - } - - @Test - fun `M submit configuration telemetry W onReceive() { telemetry configuration }`( - @BoolForgery trackErrors: Boolean, - @BoolForgery useProxy: Boolean, - @BoolForgery useLocalEncryption: Boolean, - @LongForgery(min = 0L) batchSize: Long, - @LongForgery(min = 0L) batchUploadFrequency: Long, - @IntForgery(min = 0) batchProcessingLevel: Int - ) { - // Given - testedFeature.onInitialize(appContext.mockInstance) - val event = mapOf( - "type" to "telemetry_configuration", - "track_errors" to trackErrors, - "batch_size" to batchSize, - "batch_upload_frequency" to batchUploadFrequency, - "use_proxy" to useProxy, - "use_local_encryption" to useLocalEncryption, - "batch_processing_level" to batchProcessingLevel - ) - - // When - testedFeature.onReceive(event) - - // Then - verify(mockRumMonitor) - .sendConfigurationTelemetryEvent( - TelemetryCoreConfiguration( - trackErrors = trackErrors, - batchSize = batchSize, - batchUploadFrequency = batchUploadFrequency, - useProxy = useProxy, - useLocalEncryption = useLocalEncryption, - batchProcessingLevel = batchProcessingLevel - ) - ) - - verifyNoInteractions(mockInternalLogger) - } - // endregion private fun Forge.anApplicationExitInfoList( diff --git a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/anr/ANRDetectorRunnableTest.kt b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/anr/ANRDetectorRunnableTest.kt index 337fcc7c1e..0ea47f251a 100644 --- a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/anr/ANRDetectorRunnableTest.kt +++ b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/anr/ANRDetectorRunnableTest.kt @@ -11,6 +11,7 @@ import android.os.Handler import android.os.Looper import com.datadog.android.core.feature.event.ThreadDump import com.datadog.android.core.internal.utils.loggableStackTrace +import com.datadog.android.internal.utils.loggableStackTrace import com.datadog.android.rum.RumAttributes import com.datadog.android.rum.RumErrorSource import com.datadog.android.rum.utils.config.ApplicationContextTestConfiguration diff --git a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/domain/event/RumEventMapperTest.kt b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/domain/event/RumEventMapperTest.kt index 612c76621d..1ae60d49a8 100644 --- a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/domain/event/RumEventMapperTest.kt +++ b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/domain/event/RumEventMapperTest.kt @@ -19,6 +19,7 @@ import com.datadog.android.rum.utils.verifyLog import com.datadog.android.telemetry.model.TelemetryConfigurationEvent import com.datadog.android.telemetry.model.TelemetryDebugEvent import com.datadog.android.telemetry.model.TelemetryErrorEvent +import com.datadog.android.telemetry.model.TelemetryUsageEvent import fr.xgouchet.elmyr.Forge import fr.xgouchet.elmyr.annotation.Forgery import fr.xgouchet.elmyr.junit5.ForgeConfiguration @@ -208,6 +209,18 @@ internal class RumEventMapperTest { assertThat(mappedRumEvent).isEqualTo(fakeRumEvent) } + @Test + fun `M return the original event W map { TelemetryUsageEvent }`( + @Forgery telemetryUsageEvent: TelemetryUsageEvent + ) { + // WHEN + val mappedRumEvent = testedRumEventMapper.map(telemetryUsageEvent) + + // THEN + verifyNoInteractions(mockInternalLogger) + assertThat(mappedRumEvent).isSameAs(telemetryUsageEvent) + } + @Test fun `M return the original event W map { TelemetryDebugEvent }`( @Forgery telemetryDebugEvent: TelemetryDebugEvent diff --git a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/domain/event/RumEventSerializerTest.kt b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/domain/event/RumEventSerializerTest.kt index dfe4188240..019227bc17 100644 --- a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/domain/event/RumEventSerializerTest.kt +++ b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/domain/event/RumEventSerializerTest.kt @@ -19,6 +19,7 @@ import com.datadog.android.rum.utils.forge.Configurator import com.datadog.android.telemetry.model.TelemetryConfigurationEvent import com.datadog.android.telemetry.model.TelemetryDebugEvent import com.datadog.android.telemetry.model.TelemetryErrorEvent +import com.datadog.android.telemetry.model.TelemetryUsageEvent import com.datadog.tools.unit.assertj.JsonObjectAssert.Companion.assertThat import com.datadog.tools.unit.forge.anException import com.google.gson.JsonObject @@ -33,6 +34,7 @@ import org.junit.jupiter.api.RepeatedTest import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith import org.junit.jupiter.api.extension.Extensions +import org.junit.jupiter.api.fail import org.mockito.Mock import org.mockito.junit.jupiter.MockitoExtension import org.mockito.junit.jupiter.MockitoSettings @@ -748,6 +750,110 @@ internal class RumEventSerializerTest { } } + @RepeatedTest(8) + fun `M serialize RUM event W serialize() with TelemetryUsageEvent`( + @Forgery event: TelemetryUsageEvent + ) { + val serialized = testedSerializer.serialize(event) + val jsonObject = JsonParser.parseString(serialized).asJsonObject + assertThat(jsonObject) + .hasField("type", "telemetry") + .hasField("_dd") { + hasField("format_version", 2L) + } + .hasField("date", event.date) + .hasField("source", event.source.name.lowercase(Locale.US).replace('_', '-')) + .hasField("service", event.service) + .hasField("version", event.version) + .hasField("telemetry") { + hasField("usage") { + when (event.telemetry.usage) { + is TelemetryUsageEvent.Usage.AddViewLoadingTime -> { + val usage = event.telemetry.usage as TelemetryUsageEvent.Usage.AddViewLoadingTime + hasField("no_view", usage.noView) + hasField("no_active_view", usage.noActiveView) + hasField("overwritten", usage.overwritten) + } + + else -> { + fail("Usage type not covered in assertions") + } + } + } + if (event.telemetry.device != null) { + hasField("device") { + val device = event.telemetry.device + checkNotNull(device) + if (device.architecture != null) { + hasField("architecture", device.architecture!!) + } + if (device.brand != null) { + hasField("brand", device.brand!!) + } + if (device.model != null) { + hasField("model", device.model!!) + } + } + } + if (event.telemetry.os != null) { + hasField("os") { + val os = event.telemetry.os + checkNotNull(os) + if (os.build != null) { + hasField("build", os.build!!) + } + if (os.name != null) { + hasField("name", os.name!!) + } + if (os.version != null) { + hasField("version", os.version!!) + } + } + } + containsAttributes(event.telemetry.additionalProperties) + } + + val application = event.application + if (application != null) { + assertThat(jsonObject) + .hasField("application") { + hasField("id", application.id) + } + } else { + assertThat(jsonObject).doesNotHaveField("application") + } + + val session = event.session + if (session != null) { + assertThat(jsonObject) + .hasField("session") { + hasField("id", session.id) + } + } else { + assertThat(jsonObject).doesNotHaveField("session") + } + + val view = event.view + if (view != null) { + assertThat(jsonObject) + .hasField("view") { + hasField("id", view.id) + } + } else { + assertThat(jsonObject).doesNotHaveField("view") + } + + val action = event.action + if (action != null) { + assertThat(jsonObject) + .hasField("action") { + hasField("id", action.id) + } + } else { + assertThat(jsonObject).doesNotHaveField("action") + } + } + @Test fun `M serialize RUM event W serialize() with unknown event`( @Forgery unknownEvent: UserInfo @@ -1451,18 +1557,21 @@ internal class RumEventSerializerTest { usr = (it.usr ?: ViewEvent.Usr()).copy(additionalProperties = userAttributes) ) } + 2 -> this.getForgery(ActionEvent::class.java).let { it.copy( context = ActionEvent.Context(additionalProperties = attributes), usr = (it.usr ?: ActionEvent.Usr()).copy(additionalProperties = userAttributes) ) } + 3 -> this.getForgery(ErrorEvent::class.java).let { it.copy( context = ErrorEvent.Context(additionalProperties = attributes), usr = (it.usr ?: ErrorEvent.Usr()).copy(additionalProperties = userAttributes) ) } + 4 -> this.getForgery(ResourceEvent::class.java).let { it.copy( context = ResourceEvent.Context(additionalProperties = attributes), @@ -1470,6 +1579,7 @@ internal class RumEventSerializerTest { .copy(additionalProperties = userAttributes) ) } + else -> this.getForgery(LongTaskEvent::class.java).let { it.copy( context = LongTaskEvent.Context(additionalProperties = attributes), diff --git a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/domain/scope/RumRawEventExt.kt b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/domain/scope/RumRawEventExt.kt index 22a669d234..1db369cb73 100644 --- a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/domain/scope/RumRawEventExt.kt +++ b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/domain/scope/RumRawEventExt.kt @@ -112,6 +112,10 @@ internal fun Forge.addErrorEvent(): RumRawEvent.AddError { ) } +internal fun Forge.addViewLoadingTimeEvent(): RumRawEvent.AddViewLoadingTime { + return RumRawEvent.AddViewLoadingTime(overwrite = aBool()) +} + internal fun Forge.addLongTaskEvent(): RumRawEvent.AddLongTask { return RumRawEvent.AddLongTask( durationNs = aLong(min = 1), @@ -178,7 +182,8 @@ internal fun Forge.invalidBackgroundEvent(): RumRawEvent { stopActionEvent(), stopResourceEvent(), stopResourceWithErrorEvent(), - stopResourceWithStacktraceEvent() + stopResourceWithStacktraceEvent(), + addViewLoadingTimeEvent() ) ) } @@ -197,7 +202,8 @@ internal fun Forge.anyRumEvent(excluding: List = listOf()): RumRawEvent { addLongTaskEvent(), addFeatureFlagEvaluationEvent(), addCustomTimingEvent(), - updatePerformanceMetricEvent() + updatePerformanceMetricEvent(), + addViewLoadingTimeEvent() ) return this.anElementFrom( allEvents.filter { diff --git a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/domain/scope/RumResourceScopeTest.kt b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/domain/scope/RumResourceScopeTest.kt index 85cf1ff5e5..ed510d2b2f 100644 --- a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/domain/scope/RumResourceScopeTest.kt +++ b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/domain/scope/RumResourceScopeTest.kt @@ -16,6 +16,7 @@ import com.datadog.android.api.storage.EventBatchWriter import com.datadog.android.api.storage.EventType import com.datadog.android.core.internal.net.FirstPartyHostHeaderTypeResolver import com.datadog.android.core.internal.utils.loggableStackTrace +import com.datadog.android.internal.utils.loggableStackTrace import com.datadog.android.rum.RumAttributes import com.datadog.android.rum.RumErrorSource import com.datadog.android.rum.RumResourceKind diff --git a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/domain/scope/RumViewManagerScopeTest.kt b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/domain/scope/RumViewManagerScopeTest.kt index 3b6a0f7452..1f67cea660 100644 --- a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/domain/scope/RumViewManagerScopeTest.kt +++ b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/domain/scope/RumViewManagerScopeTest.kt @@ -17,6 +17,7 @@ import com.datadog.android.api.storage.EventBatchWriter import com.datadog.android.api.storage.EventType import com.datadog.android.core.InternalSdkCore import com.datadog.android.core.internal.net.FirstPartyHostHeaderTypeResolver +import com.datadog.android.internal.telemetry.InternalTelemetryEvent import com.datadog.android.rum.DdRumContentProvider import com.datadog.android.rum.RumErrorSource import com.datadog.android.rum.internal.anr.ANRDetectorRunnable @@ -28,6 +29,7 @@ import com.datadog.android.rum.internal.vitals.NoOpVitalMonitor import com.datadog.android.rum.internal.vitals.VitalMonitor import com.datadog.android.rum.model.ActionEvent import com.datadog.android.rum.utils.forge.Configurator +import com.datadog.android.rum.utils.verifyApiUsage import com.datadog.android.rum.utils.verifyLog import fr.xgouchet.elmyr.Forge import fr.xgouchet.elmyr.annotation.BoolForgery @@ -55,6 +57,7 @@ import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.verify import org.mockito.kotlin.verifyNoInteractions +import org.mockito.kotlin.verifyNoMoreInteractions import org.mockito.kotlin.whenever import org.mockito.quality.Strictness import java.util.concurrent.TimeUnit @@ -818,6 +821,39 @@ internal class RumViewManagerScopeTest { // endregion + // region AddViewLoadingTime + + @Test + fun `M send a warning log and api usage telemetry W handleEvent { AddViewLoadingTime, no active view }`( + forge: Forge + ) { + // Given + val fakeEvent = forge.addViewLoadingTimeEvent() + testedScope.applicationDisplayed = true + + // When + testedScope.handleEvent(fakeEvent, mockWriter) + + // Then + mockInternalLogger.verifyLog( + InternalLogger.Level.WARN, + InternalLogger.Target.USER, + RumViewManagerScope.MESSAGE_MISSING_VIEW + ) + mockInternalLogger.verifyApiUsage( + InternalTelemetryEvent.ApiUsage.AddViewLoadingTime( + overwrite = fakeEvent.overwrite, + noView = true, + noActiveView = false + ), + 15f + ) + verifyNoMoreInteractions(mockInternalLogger) + verifyNoInteractions(mockWriter) + } + + // endregion + private fun resolveExpectedTimestamp(timestamp: Long): Long { return timestamp + fakeTime.serverTimeOffsetMs } diff --git a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/domain/scope/RumViewScopeTest.kt b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/domain/scope/RumViewScopeTest.kt index 68776d2092..8d8562fb3b 100644 --- a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/domain/scope/RumViewScopeTest.kt +++ b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/domain/scope/RumViewScopeTest.kt @@ -18,6 +18,8 @@ import com.datadog.android.api.storage.EventType import com.datadog.android.core.feature.event.ThreadDump import com.datadog.android.core.internal.net.FirstPartyHostHeaderTypeResolver import com.datadog.android.core.internal.utils.loggableStackTrace +import com.datadog.android.internal.telemetry.InternalTelemetryEvent +import com.datadog.android.internal.utils.loggableStackTrace import com.datadog.android.rum.RumActionType import com.datadog.android.rum.RumAttributes import com.datadog.android.rum.RumErrorSource @@ -44,6 +46,7 @@ import com.datadog.android.rum.model.LongTaskEvent import com.datadog.android.rum.model.ViewEvent import com.datadog.android.rum.utils.config.GlobalRumMonitorTestConfiguration import com.datadog.android.rum.utils.forge.Configurator +import com.datadog.android.rum.utils.verifyApiUsage import com.datadog.android.rum.utils.verifyLog import com.datadog.tools.unit.annotations.TestConfigurationsProvider import com.datadog.tools.unit.extensions.TestConfigurationExtension @@ -6960,6 +6963,360 @@ internal class RumViewScopeTest { // endregion + // region View Loading Time + + @Test + fun `M send event with view loading time W handleEvent(AddViewLoadingTime) on active view`( + @BoolForgery fakeOverwrite: Boolean + ) { + // Given + val viewLoadingTimeEvent = RumRawEvent.AddViewLoadingTime(overwrite = fakeOverwrite) + val expectedViewLoadingTime = viewLoadingTimeEvent.eventTime.nanoTime - fakeEventTime.nanoTime + + // When + testedScope.handleEvent( + viewLoadingTimeEvent, + mockWriter + ) + + // Then + argumentCaptor { + verify(mockWriter).write(eq(mockEventBatchWriter), capture(), eq(EventType.DEFAULT)) + assertThat(lastValue) + .apply { + hasTimestamp(resolveExpectedTimestamp(fakeEventTime.timestamp)) + hasName(fakeKey.name) + hasUrl(fakeUrl) + hasDurationGreaterThan(1) + hasLoadingType(null) + hasVersion(2) + hasErrorCount(0) + hasResourceCount(0) + hasActionCount(0) + hasFrustrationCount(0) + hasLongTaskCount(0) + hasFrozenFrameCount(0) + hasCpuMetric(null) + hasMemoryMetric(null, null) + hasRefreshRateMetric(null, null) + isActive(true) + isSlowRendered(false) + hasUserInfo(fakeDatadogContext.userInfo) + hasViewId(testedScope.viewId) + hasApplicationId(fakeParentContext.applicationId) + hasSessionId(fakeParentContext.sessionId) + hasUserSession() + hasNoSyntheticsTest() + hasStartReason(fakeParentContext.sessionStartReason) + hasReplay(fakeHasReplay) + hasReplayStats(fakeReplayStats) + hasSource(fakeSourceViewEvent) + hasLoadingTime(expectedViewLoadingTime) + hasDeviceInfo( + fakeDatadogContext.deviceInfo.deviceName, + fakeDatadogContext.deviceInfo.deviceModel, + fakeDatadogContext.deviceInfo.deviceBrand, + fakeDatadogContext.deviceInfo.deviceType.toViewSchemaType(), + fakeDatadogContext.deviceInfo.architecture + ) + hasOsInfo( + fakeDatadogContext.deviceInfo.osName, + fakeDatadogContext.deviceInfo.osVersion, + fakeDatadogContext.deviceInfo.osMajorVersion + ) + hasConnectivityInfo(fakeDatadogContext.networkInfo) + hasServiceName(fakeDatadogContext.service) + hasVersion(fakeDatadogContext.version) + hasSessionActive(fakeParentContext.isSessionActive) + hasSampleRate(fakeSampleRate) + } + } + mockInternalLogger.verifyLog( + InternalLogger.Level.DEBUG, + InternalLogger.Target.USER, + RumViewScope.ADDING_VIEW_LOADING_TIME_DEBUG_MESSAGE_FORMAT.format( + expectedViewLoadingTime, + testedScope.key.name + ) + ) + mockInternalLogger.verifyApiUsage( + InternalTelemetryEvent.ApiUsage.AddViewLoadingTime( + overwrite = false, + noView = false, + noActiveView = false + ), + 15f + ) + verifyNoMoreInteractions(mockWriter) + } + + @Test + fun `M overwrite view loading W handleEvent(AddViewLoadingTime, overwrite=true)`( + forge: Forge + ) { + // Given + val previousLoadingTime = forge.aPositiveLong() + testedScope.viewLoadingTime = previousLoadingTime + val newViewLoadingTime = RumRawEvent.AddViewLoadingTime(overwrite = true) + val expectedViewLoadingTime = newViewLoadingTime.eventTime.nanoTime - fakeEventTime.nanoTime + + // When + testedScope.handleEvent( + newViewLoadingTime, + mockWriter + ) + + // Then + argumentCaptor { + verify(mockWriter) + .write(eq(mockEventBatchWriter), capture(), eq(EventType.DEFAULT)) + assertThat(firstValue) + .apply { + hasTimestamp(resolveExpectedTimestamp(fakeEventTime.timestamp)) + hasName(fakeKey.name) + hasUrl(fakeUrl) + hasDurationGreaterThan(1) + hasLoadingType(null) + hasVersion(2) + hasErrorCount(0) + hasResourceCount(0) + hasActionCount(0) + hasFrustrationCount(0) + hasLongTaskCount(0) + hasFrozenFrameCount(0) + hasCpuMetric(null) + hasMemoryMetric(null, null) + hasRefreshRateMetric(null, null) + isActive(true) + isSlowRendered(false) + hasUserInfo(fakeDatadogContext.userInfo) + hasViewId(testedScope.viewId) + hasApplicationId(fakeParentContext.applicationId) + hasSessionId(fakeParentContext.sessionId) + hasUserSession() + hasNoSyntheticsTest() + hasStartReason(fakeParentContext.sessionStartReason) + hasReplay(fakeHasReplay) + hasReplayStats(fakeReplayStats) + hasSource(fakeSourceViewEvent) + hasLoadingTime(expectedViewLoadingTime) + hasDeviceInfo( + fakeDatadogContext.deviceInfo.deviceName, + fakeDatadogContext.deviceInfo.deviceModel, + fakeDatadogContext.deviceInfo.deviceBrand, + fakeDatadogContext.deviceInfo.deviceType.toViewSchemaType(), + fakeDatadogContext.deviceInfo.architecture + ) + hasOsInfo( + fakeDatadogContext.deviceInfo.osName, + fakeDatadogContext.deviceInfo.osVersion, + fakeDatadogContext.deviceInfo.osMajorVersion + ) + hasConnectivityInfo(fakeDatadogContext.networkInfo) + hasServiceName(fakeDatadogContext.service) + hasVersion(fakeDatadogContext.version) + hasSessionActive(fakeParentContext.isSessionActive) + hasSampleRate(fakeSampleRate) + } + } + mockInternalLogger.verifyLog( + InternalLogger.Level.WARN, + InternalLogger.Target.USER, + RumViewScope.OVERWRITING_VIEW_LOADING_TIME_WARNING_MESSAGE_FORMAT.format( + Locale.US, + testedScope.key.name, + previousLoadingTime, + expectedViewLoadingTime + ) + ) + mockInternalLogger.verifyApiUsage( + InternalTelemetryEvent.ApiUsage.AddViewLoadingTime( + noActiveView = false, + noView = false, + overwrite = true + ), + 15f + ) + verifyNoMoreInteractions(mockWriter) + } + + @Test + fun `M update view loading time each time W handleEvent(AddViewLoadingTime, overwrite=true) multi calls`( + forge: Forge + ) { + // Given + val viewLoadingTimeEvents = forge.aList { RumRawEvent.AddViewLoadingTime(overwrite = true) } + val expectedViewLoadingTime = viewLoadingTimeEvents.last().eventTime.nanoTime - fakeEventTime.nanoTime + + // When + viewLoadingTimeEvents.forEach { + testedScope.handleEvent( + it, + mockWriter + ) + } + + // Then + argumentCaptor { + verify(mockWriter, times(viewLoadingTimeEvents.size)) + .write(eq(mockEventBatchWriter), capture(), eq(EventType.DEFAULT)) + assertThat(lastValue) + .apply { + hasTimestamp(resolveExpectedTimestamp(fakeEventTime.timestamp)) + hasName(fakeKey.name) + hasUrl(fakeUrl) + hasDurationGreaterThan(1) + hasLoadingType(null) + hasVersion((viewLoadingTimeEvents.size + 1).toLong()) + hasErrorCount(0) + hasResourceCount(0) + hasActionCount(0) + hasFrustrationCount(0) + hasLongTaskCount(0) + hasFrozenFrameCount(0) + hasCpuMetric(null) + hasMemoryMetric(null, null) + hasRefreshRateMetric(null, null) + isActive(true) + isSlowRendered(false) + hasUserInfo(fakeDatadogContext.userInfo) + hasViewId(testedScope.viewId) + hasApplicationId(fakeParentContext.applicationId) + hasSessionId(fakeParentContext.sessionId) + hasUserSession() + hasNoSyntheticsTest() + hasStartReason(fakeParentContext.sessionStartReason) + hasReplay(fakeHasReplay) + hasReplayStats(fakeReplayStats) + hasSource(fakeSourceViewEvent) + hasLoadingTime(expectedViewLoadingTime) + hasDeviceInfo( + fakeDatadogContext.deviceInfo.deviceName, + fakeDatadogContext.deviceInfo.deviceModel, + fakeDatadogContext.deviceInfo.deviceBrand, + fakeDatadogContext.deviceInfo.deviceType.toViewSchemaType(), + fakeDatadogContext.deviceInfo.architecture + ) + hasOsInfo( + fakeDatadogContext.deviceInfo.osName, + fakeDatadogContext.deviceInfo.osVersion, + fakeDatadogContext.deviceInfo.osMajorVersion + ) + hasConnectivityInfo(fakeDatadogContext.networkInfo) + hasServiceName(fakeDatadogContext.service) + hasVersion(fakeDatadogContext.version) + hasSessionActive(fakeParentContext.isSessionActive) + hasSampleRate(fakeSampleRate) + } + } + verifyNoMoreInteractions(mockWriter) + } + + @Test + fun `M update view loading time only first time W handleEvent(AddViewLoadingTime, overwrite=false) multi calls`( + forge: Forge + ) { + // Given + val viewLoadingTimeEvents = forge.aList { RumRawEvent.AddViewLoadingTime(overwrite = false) } + val expectedViewLoadingTime = viewLoadingTimeEvents.first().eventTime.nanoTime - fakeEventTime.nanoTime + + // When + viewLoadingTimeEvents.forEach { + testedScope.handleEvent( + it, + mockWriter + ) + } + + // Then + argumentCaptor { + verify(mockWriter) + .write(eq(mockEventBatchWriter), capture(), eq(EventType.DEFAULT)) + assertThat(lastValue) + .apply { + hasTimestamp(resolveExpectedTimestamp(fakeEventTime.timestamp)) + hasName(fakeKey.name) + hasUrl(fakeUrl) + hasDurationGreaterThan(1) + hasLoadingType(null) + hasVersion(2) + hasErrorCount(0) + hasResourceCount(0) + hasActionCount(0) + hasFrustrationCount(0) + hasLongTaskCount(0) + hasFrozenFrameCount(0) + hasCpuMetric(null) + hasMemoryMetric(null, null) + hasRefreshRateMetric(null, null) + isActive(true) + isSlowRendered(false) + hasUserInfo(fakeDatadogContext.userInfo) + hasViewId(testedScope.viewId) + hasApplicationId(fakeParentContext.applicationId) + hasSessionId(fakeParentContext.sessionId) + hasUserSession() + hasNoSyntheticsTest() + hasStartReason(fakeParentContext.sessionStartReason) + hasReplay(fakeHasReplay) + hasReplayStats(fakeReplayStats) + hasSource(fakeSourceViewEvent) + hasLoadingTime(expectedViewLoadingTime) + hasDeviceInfo( + fakeDatadogContext.deviceInfo.deviceName, + fakeDatadogContext.deviceInfo.deviceModel, + fakeDatadogContext.deviceInfo.deviceBrand, + fakeDatadogContext.deviceInfo.deviceType.toViewSchemaType(), + fakeDatadogContext.deviceInfo.architecture + ) + hasOsInfo( + fakeDatadogContext.deviceInfo.osName, + fakeDatadogContext.deviceInfo.osVersion, + fakeDatadogContext.deviceInfo.osMajorVersion + ) + hasConnectivityInfo(fakeDatadogContext.networkInfo) + hasServiceName(fakeDatadogContext.service) + hasVersion(fakeDatadogContext.version) + hasSessionActive(fakeParentContext.isSessionActive) + hasSampleRate(fakeSampleRate) + } + } + verifyNoMoreInteractions(mockWriter) + } + + @Test + fun `M not update the view loading time W handleEvent(AddViewLoadingTime) on stopped view`( + @BoolForgery fakeOverwrite: Boolean + ) { + // Given + testedScope.stopped = true + + // When + testedScope.handleEvent( + RumRawEvent.AddViewLoadingTime(overwrite = fakeOverwrite), + mockWriter + ) + + // Then + assertThat(testedScope.viewLoadingTime).isNull() + mockInternalLogger.verifyLog( + InternalLogger.Level.WARN, + InternalLogger.Target.USER, + RumViewScope.NO_ACTIVE_VIEW_FOR_LOADING_TIME_WARNING_MESSAGE + ) + mockInternalLogger.verifyApiUsage( + InternalTelemetryEvent.ApiUsage.AddViewLoadingTime( + noActiveView = true, + noView = false, + overwrite = fakeOverwrite + ), + 15f + ) + verifyNoInteractions(mockWriter) + } + + // endregion + // region Vitals @Test diff --git a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/metric/FakeInternalLogger.kt b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/metric/FakeInternalLogger.kt index b374028b53..8c6a21f7ba 100644 --- a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/metric/FakeInternalLogger.kt +++ b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/metric/FakeInternalLogger.kt @@ -9,6 +9,7 @@ package com.datadog.android.rum.internal.metric import com.datadog.android.api.InternalLogger import com.datadog.android.core.metrics.PerformanceMetric import com.datadog.android.core.metrics.TelemetryMetricType +import com.datadog.android.internal.telemetry.InternalTelemetryEvent class FakeInternalLogger : InternalLogger { @@ -51,4 +52,8 @@ class FakeInternalLogger : InternalLogger { // do nothing return null } + + override fun logApiUsage(apiUsageEvent: InternalTelemetryEvent.ApiUsage, samplingRate: Float) { + // do nothing + } } diff --git a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/metric/RumSessionEndedMetricTest.kt b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/metric/RumSessionEndedMetricTest.kt index 1790d60931..8effbf858f 100644 --- a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/metric/RumSessionEndedMetricTest.kt +++ b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/metric/RumSessionEndedMetricTest.kt @@ -437,7 +437,7 @@ class RumSessionEndedMetricTest { } @Test - fun `M have correct 'no_view_events_count' W toMetricAttributes()`( + fun `M have correct no_view_events_count W toMetricAttributes()`( @BoolForgery fakeBackgroundEventTracking: Boolean, @IntForgery(min = 0, max = 5) randomActionCount: Int, @IntForgery(min = 0, max = 5) randomResourceCount: Int, @@ -474,7 +474,7 @@ class RumSessionEndedMetricTest { } @Test - fun `M have correct 'has_background_events_tracking_enabled' W toMetricAttributes()`( + fun `M have correct has_background_events_tracking_enabled W toMetricAttributes()`( @BoolForgery fakeBackgroundEventTracking: Boolean ) { // Given @@ -492,7 +492,7 @@ class RumSessionEndedMetricTest { } @Test - fun `M have correct 'ntp_offset' W toMetricAttributes()`( + fun `M have correct ntp_offset W toMetricAttributes()`( @LongForgery ntpOffsetAtStart: Long, @LongForgery ntpOffsetAtEnd: Long ) { @@ -514,6 +514,30 @@ class RumSessionEndedMetricTest { assertThat(ntpOffset[SessionEndedMetric.NTP_OFFSET_AT_END_KEY]).isEqualTo(ntpOffsetAtEnd) } + @Test + fun `M have correct sr_skipped_frames_count W toMetricAttributes()`( + @IntForgery(min = 0, max = 100) count: Int + ) { + // Given + val sessionEndedMetric = SessionEndedMetric( + fakeSessionId, + fakeStartReason, + fakeNtpOffsetAtStart, + backgroundEventTracking + ) + + // When + repeat(count) { + sessionEndedMetric.onSessionReplaySkippedFrameTracked() + } + val attributes = sessionEndedMetric.toMetricAttributes(fakeNtpOffsetAtEnd) + + // Then + val rse = attributes[SessionEndedMetric.RSE_KEY] as Map<*, *> + val skippedFramesCount = rse[SessionEndedMetric.SESSION_REPLAY_SKIPPED_FRAMES_COUNT] as Int + assertThat(skippedFramesCount).isEqualTo(count) + } + @Test fun `M encode type W creating metric`(@Forgery viewEvent: ViewEvent) { // Given diff --git a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/metric/SessionEndedMetricDispatcherTest.kt b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/metric/SessionEndedMetricDispatcherTest.kt index c40664f290..937ca6988e 100644 --- a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/metric/SessionEndedMetricDispatcherTest.kt +++ b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/metric/SessionEndedMetricDispatcherTest.kt @@ -12,6 +12,7 @@ import com.datadog.android.rum.utils.forge.Configurator import com.datadog.tools.unit.extensions.TestConfigurationExtension import fr.xgouchet.elmyr.annotation.BoolForgery import fr.xgouchet.elmyr.annotation.Forgery +import fr.xgouchet.elmyr.annotation.IntForgery import fr.xgouchet.elmyr.annotation.LongForgery import fr.xgouchet.elmyr.annotation.StringForgery import fr.xgouchet.elmyr.junit5.ForgeConfiguration @@ -257,6 +258,25 @@ internal class SessionEndedMetricDispatcherTest { assertThat(fakeInternalLogger.getNtpAtEndOffset()).isEqualTo(fakeNtpOffsetAtEnd) } + @Test + fun `M has correct skipped frames count W start metric`( + @StringForgery fakeSessionId: String, + @IntForgery(min = 0, max = 100) skippedFramesCount: Int + ) { + // Given + val dispatcher = SessionEndedMetricDispatcher(fakeInternalLogger) + + // When + dispatcher.startMetric(fakeSessionId, fakeStartReason, fakeNtpOffsetAtStart, backgroundEventTracking) + repeat(skippedFramesCount) { + dispatcher.onSessionReplaySkippedFrameTracked(fakeSessionId) + } + dispatcher.endMetric(fakeSessionId, fakeNtpOffsetAtEnd) + + // Then + assertThat(fakeInternalLogger.getSkippedFramesCount()).isEqualTo(skippedFramesCount) + } + private fun FakeInternalLogger.getNtpAtStartOffset(): Long { return lastMetric?.second?.let { attributes -> val rse = attributes[SessionEndedMetric.RSE_KEY] as Map<*, *> @@ -280,6 +300,13 @@ internal class SessionEndedMetricDispatcherTest { } } + private fun FakeInternalLogger.getSkippedFramesCount(): Int? { + return lastMetric?.second?.let { attributes -> + val rse = attributes[SessionEndedMetric.RSE_KEY] as Map<*, *> + rse[SessionEndedMetric.SESSION_REPLAY_SKIPPED_FRAMES_COUNT] as? Int + } + } + private fun FakeInternalLogger.getMissedTypeCount(missedEventType: SessionEndedMetric.MissedEventType): Int { return lastMetric?.second?.let { attributes -> val rse = attributes[SessionEndedMetric.RSE_KEY] as Map<*, *> diff --git a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/monitor/DatadogRumMonitorTest.kt b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/monitor/DatadogRumMonitorTest.kt index 5351cc13c3..d2a2a6450c 100644 --- a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/monitor/DatadogRumMonitorTest.kt +++ b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/monitor/DatadogRumMonitorTest.kt @@ -16,8 +16,9 @@ import com.datadog.android.api.storage.DataWriter import com.datadog.android.core.InternalSdkCore import com.datadog.android.core.feature.event.ThreadDump import com.datadog.android.core.internal.net.FirstPartyHostHeaderTypeResolver -import com.datadog.android.core.internal.utils.loggableStackTrace +import com.datadog.android.internal.telemetry.InternalTelemetryEvent import com.datadog.android.rum.DdRumContentProvider +import com.datadog.android.rum.ExperimentalRumApi import com.datadog.android.rum.RumActionType import com.datadog.android.rum.RumAttributes import com.datadog.android.rum.RumErrorSource @@ -44,9 +45,7 @@ import com.datadog.android.rum.internal.vitals.VitalMonitor import com.datadog.android.rum.resource.ResourceId import com.datadog.android.rum.utils.forge.Configurator import com.datadog.android.rum.utils.verifyLog -import com.datadog.android.telemetry.internal.TelemetryCoreConfiguration import com.datadog.android.telemetry.internal.TelemetryEventHandler -import com.datadog.android.telemetry.internal.TelemetryType import com.datadog.tools.unit.forge.aThrowable import com.datadog.tools.unit.forge.exhaustiveAttributes import fr.xgouchet.elmyr.Forge @@ -80,6 +79,7 @@ import org.mockito.kotlin.inOrder import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.same +import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.verifyNoInteractions import org.mockito.kotlin.verifyNoMoreInteractions @@ -132,7 +132,7 @@ internal class DatadogRumMonitorTest { lateinit var mockTelemetryEventHandler: TelemetryEventHandler @Mock - lateinit var sessionEndedMetricDispatcher: SessionMetricDispatcher + lateinit var mockSessionEndedMetricDispatcher: SessionMetricDispatcher @Mock lateinit var mockSdkCore: InternalSdkCore @@ -183,7 +183,7 @@ internal class DatadogRumMonitorTest { mockWriter, mockHandler, mockTelemetryEventHandler, - sessionEndedMetricDispatcher, + mockSessionEndedMetricDispatcher, mockResolver, mockCpuVitalMonitor, mockMemoryVitalMonitor, @@ -205,7 +205,7 @@ internal class DatadogRumMonitorTest { mockWriter, mockHandler, mockTelemetryEventHandler, - sessionEndedMetricDispatcher, + mockSessionEndedMetricDispatcher, mockResolver, mockCpuVitalMonitor, mockMemoryVitalMonitor, @@ -264,7 +264,7 @@ internal class DatadogRumMonitorTest { mockWriter, mockHandler, mockTelemetryEventHandler, - sessionEndedMetricDispatcher, + mockSessionEndedMetricDispatcher, mockResolver, mockCpuVitalMonitor, mockMemoryVitalMonitor, @@ -301,7 +301,7 @@ internal class DatadogRumMonitorTest { mockWriter, mockHandler, mockTelemetryEventHandler, - sessionEndedMetricDispatcher, + mockSessionEndedMetricDispatcher, mockResolver, mockCpuVitalMonitor, mockMemoryVitalMonitor, @@ -874,6 +874,22 @@ internal class DatadogRumMonitorTest { verifyNoMoreInteractions(mockScope, mockWriter) } + @Test + @OptIn(ExperimentalRumApi::class) + fun `M delegate event to rootScope W addViewLoadTime()`( + @BoolForgery fakeOverwrite: Boolean + ) { + testedMonitor.addViewLoadingTime(fakeOverwrite) + Thread.sleep(PROCESSING_DELAY) + + argumentCaptor { + verify(mockScope).handleEvent(capture(), same(mockWriter)) + val event = firstValue + check(event is RumRawEvent.AddViewLoadingTime) + } + verifyNoMoreInteractions(mockScope, mockWriter) + } + @Test fun `M delegate event to rootScope on current thread W addCrash()`( @StringForgery message: String, @@ -1617,7 +1633,7 @@ internal class DatadogRumMonitorTest { mockWriter, mockHandler, mockTelemetryEventHandler, - sessionEndedMetricDispatcher, + mockSessionEndedMetricDispatcher, mockResolver, mockCpuVitalMonitor, mockMemoryVitalMonitor, @@ -1662,7 +1678,7 @@ internal class DatadogRumMonitorTest { mockWriter, mockHandler, mockTelemetryEventHandler, - sessionEndedMetricDispatcher, + mockSessionEndedMetricDispatcher, mockResolver, mockCpuVitalMonitor, mockMemoryVitalMonitor, @@ -1694,7 +1710,7 @@ internal class DatadogRumMonitorTest { mockWriter, mockHandler, mockTelemetryEventHandler, - sessionEndedMetricDispatcher, + mockSessionEndedMetricDispatcher, mockResolver, mockCpuVitalMonitor, mockMemoryVitalMonitor, @@ -1845,178 +1861,55 @@ internal class DatadogRumMonitorTest { } @Test - fun `M handle debug telemetry event W sendDebugTelemetryEvent()`( - @StringForgery message: String, - forge: Forge - ) { - // Given - val fakeAdditionalProperties = forge.aNullable { exhaustiveAttributes() } - + fun `M handle telemetry event W sendTelemetryEvent()`(@Forgery fakeInternalTelemetryEvent: InternalTelemetryEvent) { // When - testedMonitor.sendDebugTelemetryEvent(message, fakeAdditionalProperties) + testedMonitor.sendTelemetryEvent(fakeInternalTelemetryEvent) // Then - argumentCaptor { + argumentCaptor { verify(mockTelemetryEventHandler).handleEvent( capture(), eq(mockWriter) ) - assertThat(lastValue.message).isEqualTo(message) - assertThat(lastValue.type).isEqualTo(TelemetryType.DEBUG) - assertThat(lastValue.stack).isNull() - assertThat(lastValue.kind).isNull() - assertThat(lastValue.coreConfiguration).isNull() - assertThat(lastValue.isMetric).isFalse - assertThat(lastValue.additionalProperties).isEqualTo(fakeAdditionalProperties) + assertThat(lastValue.event).isEqualTo(fakeInternalTelemetryEvent) } } @Test - fun `M handle error telemetry event W sendErrorTelemetryEvent() {stack+kind}`( - @StringForgery message: String, - @StringForgery stackTrace: String, - @StringForgery kind: String, - forge: Forge + fun `M call sessionEndedMetricDispatcher W addSkippedFrame`( + @IntForgery(min = 0, max = 100) count: Int, + @StringForgery(type = StringForgeryType.ASCII) key: String, + @StringForgery name: String ) { - // Given - val fakeAdditionalProperties = forge.aNullable { exhaustiveAttributes() } - - // When - testedMonitor.sendErrorTelemetryEvent(message, stackTrace, kind, fakeAdditionalProperties) - - // Then - argumentCaptor { - verify(mockTelemetryEventHandler).handleEvent( - capture(), - eq(mockWriter) - ) - assertThat(lastValue.message).isEqualTo(message) - assertThat(lastValue.type).isEqualTo(TelemetryType.ERROR) - assertThat(lastValue.stack).isEqualTo(stackTrace) - assertThat(lastValue.kind).isEqualTo(kind) - assertThat(lastValue.isMetric).isFalse - assertThat(lastValue.coreConfiguration).isNull() - assertThat(lastValue.additionalProperties).isEqualTo(fakeAdditionalProperties) - } - } + val attributes = fakeAttributes + (RumAttributes.INTERNAL_TIMESTAMP to fakeTimestamp) - @Test - fun `M handle error telemetry event W sendErrorTelemetryEvent() {throwable}`( - @StringForgery message: String, - forge: Forge - ) { + testedMonitor.startView(key, name, attributes) // Given - val throwable = forge.aNullable { forge.aThrowable() } - val fakeAdditionalProperties = forge.aNullable { exhaustiveAttributes() } - - // When - testedMonitor.sendErrorTelemetryEvent(message, throwable, fakeAdditionalProperties) - - // Then - argumentCaptor { - verify(mockTelemetryEventHandler).handleEvent( - capture(), - eq(mockWriter) - ) - assertThat(lastValue.message).isEqualTo(message) - assertThat(lastValue.type).isEqualTo(TelemetryType.ERROR) - assertThat(lastValue.stack).isEqualTo(throwable?.loggableStackTrace()) - assertThat(lastValue.kind).isEqualTo(throwable?.javaClass?.canonicalName) - assertThat(lastValue.isMetric).isFalse - assertThat(lastValue.coreConfiguration).isNull() - assertThat(lastValue.additionalProperties).isEqualTo(fakeAdditionalProperties) - } - } - - @Test - fun `M handle configuration telemetry event W sendConfigurationTelemetryEvent()`( - @Forgery fakeConfiguration: TelemetryCoreConfiguration - ) { - // When - testedMonitor.sendConfigurationTelemetryEvent(fakeConfiguration) - - // Then - argumentCaptor { - verify(mockTelemetryEventHandler).handleEvent( - capture(), - eq(mockWriter) - ) - assertThat(lastValue.message).isEmpty() - assertThat(lastValue.type).isEqualTo(TelemetryType.CONFIGURATION) - assertThat(lastValue.stack).isNull() - assertThat(lastValue.kind).isNull() - assertThat(lastValue.isMetric).isFalse - assertThat(lastValue.coreConfiguration).isSameAs(fakeConfiguration) - } - } - - @Test - fun `M handle metric event W sendMetricEvent()`( - @StringForgery message: String, - forge: Forge - ) { - // When - val fakeAdditionalProperties = forge.exhaustiveAttributes() - testedMonitor.sendMetricEvent(message, fakeAdditionalProperties) - - // Then - argumentCaptor { - verify(mockTelemetryEventHandler).handleEvent( - capture(), - eq(mockWriter) - ) - assertThat(lastValue.message).isEqualTo(message) - assertThat(lastValue.type).isEqualTo(TelemetryType.DEBUG) - assertThat(lastValue.stack).isNull() - assertThat(lastValue.kind).isNull() - assertThat(lastValue.coreConfiguration).isNull() - assertThat(lastValue.isMetric).isTrue - assertThat(lastValue.additionalProperties) - .containsExactlyInAnyOrderEntriesOf(fakeAdditionalProperties) - } - } - - @Test - fun `M handle metric event W sendMetricEvent(){additionalProperties is null}`( - @StringForgery message: String - ) { + testedMonitor = DatadogRumMonitor( + fakeApplicationId, + mockSdkCore, + 100.0f, + fakeBackgroundTrackingEnabled, + fakeTrackFrustrations, + mockWriter, + mockHandler, + mockTelemetryEventHandler, + mockSessionEndedMetricDispatcher, + mockResolver, + mockCpuVitalMonitor, + mockMemoryVitalMonitor, + mockFrameRateVitalMonitor, + mockSessionListener, + mockExecutorService + ) + testedMonitor.startView(key, name, attributes) // When - testedMonitor.sendMetricEvent(message, null) - - // Then - argumentCaptor { - verify(mockTelemetryEventHandler).handleEvent( - capture(), - eq(mockWriter) - ) - assertThat(lastValue.message).isEqualTo(message) - assertThat(lastValue.type).isEqualTo(TelemetryType.DEBUG) - assertThat(lastValue.stack).isNull() - assertThat(lastValue.kind).isNull() - assertThat(lastValue.coreConfiguration).isNull() - assertThat(lastValue.isMetric).isTrue - assertThat(lastValue.additionalProperties).isNull() + repeat(count) { + testedMonitor.addSessionReplaySkippedFrame() } - } - - @Test - fun `M handle interceptor event W notifyInterceptorInstantiated()`() { - // When - testedMonitor.notifyInterceptorInstantiated() // Then - argumentCaptor { - verify(mockTelemetryEventHandler).handleEvent( - capture(), - eq(mockWriter) - ) - assertThat(lastValue.message).isEmpty() - assertThat(lastValue.type).isEqualTo(TelemetryType.INTERCEPTOR_SETUP) - assertThat(lastValue.stack).isNull() - assertThat(lastValue.kind).isNull() - assertThat(lastValue.isMetric).isFalse - assertThat(lastValue.coreConfiguration).isNull() - } + verify(mockSessionEndedMetricDispatcher, times(count)).onSessionReplaySkippedFrameTracked(any()) } @Test diff --git a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/net/RumRequestFactoryTest.kt b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/net/RumRequestFactoryTest.kt index 1dd398c7a9..7d9254f037 100644 --- a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/net/RumRequestFactoryTest.kt +++ b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/net/RumRequestFactoryTest.kt @@ -8,6 +8,7 @@ package com.datadog.android.rum.internal.net import com.datadog.android.api.InternalLogger import com.datadog.android.api.context.DatadogContext +import com.datadog.android.api.net.RequestExecutionContext import com.datadog.android.api.net.RequestFactory import com.datadog.android.api.storage.RawBatchEvent import com.datadog.android.core.internal.utils.join @@ -48,6 +49,9 @@ internal class RumRequestFactoryTest { @Forgery lateinit var fakeDatadogContext: DatadogContext + @Forgery + lateinit var fakeExecutionContext: RequestExecutionContext + @BeforeEach fun `set up`() { whenever(mockViewEventFilter.filterOutRedundantViewEvents(any())) doAnswer { @@ -72,13 +76,20 @@ internal class RumRequestFactoryTest { val batchMetadata = forge.aNullable { batchMetadata.toByteArray() } // When - val request = testedFactory.create(fakeDatadogContext, batchData, batchMetadata) + val request = testedFactory.create(fakeDatadogContext, fakeExecutionContext, batchData, batchMetadata) // Then requireNotNull(request) assertThat(request.url).isEqualTo(expectedUrl(fakeDatadogContext.site.intakeEndpoint)) assertThat(request.contentType).isEqualTo(RequestFactory.CONTENT_TYPE_TEXT_UTF8) - assertThat(request.headers.minus(RequestFactory.HEADER_REQUEST_ID)).isEqualTo( + assertThat( + request.headers.minus( + listOf( + RequestFactory.HEADER_REQUEST_ID, + RequestFactory.DD_IDEMPOTENCY_KEY + ) + ) + ).isEqualTo( mapOf( RequestFactory.HEADER_API_KEY to fakeDatadogContext.clientToken, RequestFactory.HEADER_EVP_ORIGIN to fakeDatadogContext.source, @@ -87,6 +98,7 @@ internal class RumRequestFactoryTest { ) assertThat(request.headers[RequestFactory.HEADER_REQUEST_ID]).isNotEmpty() assertThat(request.id).isEqualTo(request.headers[RequestFactory.HEADER_REQUEST_ID]) + assertThat(request.headers[RequestFactory.DD_IDEMPOTENCY_KEY]).matches("[a-f0-9]{40}") assertThat(request.description).isEqualTo("RUM Request") assertThat(request.body).isEqualTo( batchData.map { it.data }.join( @@ -113,13 +125,20 @@ internal class RumRequestFactoryTest { val batchMetadata = forge.aNullable { batchMetadata.toByteArray() } // When - val request = testedFactory.create(fakeDatadogContext, batchData, batchMetadata) + val request = testedFactory.create(fakeDatadogContext, fakeExecutionContext, batchData, batchMetadata) // Then requireNotNull(request) assertThat(request.url).isEqualTo(expectedUrl(fakeEndpoint)) assertThat(request.contentType).isEqualTo(RequestFactory.CONTENT_TYPE_TEXT_UTF8) - assertThat(request.headers.minus(RequestFactory.HEADER_REQUEST_ID)).isEqualTo( + assertThat( + request.headers.minus( + listOf( + RequestFactory.HEADER_REQUEST_ID, + RequestFactory.DD_IDEMPOTENCY_KEY + ) + ) + ).isEqualTo( mapOf( RequestFactory.HEADER_API_KEY to fakeDatadogContext.clientToken, RequestFactory.HEADER_EVP_ORIGIN to fakeDatadogContext.source, @@ -128,6 +147,7 @@ internal class RumRequestFactoryTest { ) assertThat(request.headers[RequestFactory.HEADER_REQUEST_ID]).isNotEmpty() assertThat(request.id).isEqualTo(request.headers[RequestFactory.HEADER_REQUEST_ID]) + assertThat(request.headers[RequestFactory.DD_IDEMPOTENCY_KEY]).matches("[a-f0-9]{40}") assertThat(request.description).isEqualTo("RUM Request") assertThat(request.body).isEqualTo( batchData.map { it.data }.join( @@ -148,6 +168,10 @@ internal class RumRequestFactoryTest { if (fakeDatadogContext.variant.isNotEmpty()) { queryTags.add("${RumAttributes.VARIANT}:${fakeDatadogContext.variant}") } + if (fakeExecutionContext.previousResponseCode != null) { + queryTags.add("${RumRequestFactory.RETRY_COUNT_KEY}:${fakeExecutionContext.attemptNumber}") + queryTags.add("${RumRequestFactory.LAST_FAILURE_STATUS_KEY}:${fakeExecutionContext.previousResponseCode}") + } return "$endpointUrl/api/v2/rum?ddsource=${fakeDatadogContext.source}" + "&ddtags=${queryTags.joinToString(",")}" diff --git a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/tracking/BundleExtTest.kt b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/tracking/BundleExtTest.kt new file mode 100644 index 0000000000..993b586382 --- /dev/null +++ b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/tracking/BundleExtTest.kt @@ -0,0 +1,111 @@ +package com.datadog.android.rum.tracking + +import android.os.Bundle +import com.datadog.android.rum.utils.forge.Configurator +import fr.xgouchet.elmyr.Forge +import fr.xgouchet.elmyr.junit5.ForgeConfiguration +import fr.xgouchet.elmyr.junit5.ForgeExtension +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.junit.jupiter.api.extension.Extensions +import org.mockito.junit.jupiter.MockitoExtension +import org.mockito.junit.jupiter.MockitoSettings +import org.mockito.quality.Strictness + +@Extensions( + ExtendWith(ForgeExtension::class), + ExtendWith(MockitoExtension::class) +) +@MockitoSettings(strictness = Strictness.LENIENT) +@ForgeConfiguration(Configurator::class) +class BundleExtTest { + @Test + fun `M return empty map W convertToRumViewAttributes() {null bundle}`() { + // Given + val bundle: Bundle? = null + + // When + val result = bundle.convertToRumViewAttributes() + + // Then + + assertThat(result).isEmpty() + } + + @Test + fun `M return empty map W convertToRumViewAttributes() {empty bundle}`() { + // Given + val bundle = Bundle() + + // When + val result = bundle.convertToRumViewAttributes() + + // Then + + assertThat(result).isEmpty() + } + + @Test + fun `M return map with String attributes W convertToRumViewAttributes() {bundle}`( + forge: Forge + ) { + // Given + val expectedAttributes = mutableMapOf() + val bundle = Bundle() + repeat(forge.aSmallInt()) { + val key = forge.anAlphabeticalString() + val value = forge.aNullable { aString() } + bundle.putString(key, value) + expectedAttributes["view.arguments.$key"] = value + } + + // When + val result = bundle.convertToRumViewAttributes() + + // Then + assertThat(result).isEqualTo(expectedAttributes) + } + + @Test + fun `M return map with Int attributes W convertToRumViewAttributes() {bundle}`( + forge: Forge + ) { + // Given + val expectedAttributes = mutableMapOf() + val bundle = Bundle() + repeat(forge.aSmallInt()) { + val key = forge.anAlphabeticalString() + val value = forge.anInt() + bundle.putInt(key, value) + expectedAttributes["view.arguments.$key"] = value + } + + // When + val result = bundle.convertToRumViewAttributes() + + // Then + assertThat(result).isEqualTo(expectedAttributes) + } + + @Test + fun `M return map with Float attributes W convertToRumViewAttributes() {bundle}`( + forge: Forge + ) { + // Given + val expectedAttributes = mutableMapOf() + val bundle = Bundle() + repeat(forge.aSmallInt()) { + val key = forge.anAlphabeticalString() + val value = forge.aFloat() + bundle.putFloat(key, value) + expectedAttributes["view.arguments.$key"] = value + } + + // When + val result = bundle.convertToRumViewAttributes() + + // Then + assertThat(result).isEqualTo(expectedAttributes) + } +} diff --git a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/utils/InternalLoggerUtils.kt b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/utils/InternalLoggerUtils.kt index 32d736ab6a..3992b4a0da 100644 --- a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/utils/InternalLoggerUtils.kt +++ b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/utils/InternalLoggerUtils.kt @@ -9,6 +9,8 @@ package com.datadog.android.rum.utils import com.datadog.android.api.InternalLogger +import com.datadog.android.internal.telemetry.InternalTelemetryEvent +import com.datadog.android.rum.utils.assertj.InternalApiUsageEventAssert import org.assertj.core.api.Assertions.assertThat import org.mockito.ArgumentMatchers.isA import org.mockito.kotlin.argumentCaptor @@ -18,6 +20,17 @@ import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.verification.VerificationMode +fun InternalLogger.verifyApiUsage( + apiUsage: InternalTelemetryEvent.ApiUsage, + samplingRate: Float +) { + argumentCaptor { + verify(this@verifyApiUsage).logApiUsage(capture(), eq(samplingRate)) + val event = firstValue + InternalApiUsageEventAssert.assertThat(event).isEqualTo(apiUsage) + } +} + fun InternalLogger.verifyLog( level: InternalLogger.Level, target: InternalLogger.Target, diff --git a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/utils/assertj/InternalAddViewLoadingTimeEventAssert.kt b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/utils/assertj/InternalAddViewLoadingTimeEventAssert.kt new file mode 100644 index 0000000000..66c1a1e3a6 --- /dev/null +++ b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/utils/assertj/InternalAddViewLoadingTimeEventAssert.kt @@ -0,0 +1,73 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.datadog.android.rum.utils.assertj + +import com.datadog.android.internal.telemetry.InternalTelemetryEvent +import org.assertj.core.api.AbstractAssert +import org.assertj.core.api.Assertions.assertThat + +internal class InternalAddViewLoadingTimeEventAssert(actual: InternalTelemetryEvent.ApiUsage.AddViewLoadingTime) : + AbstractAssert( + actual, + InternalAddViewLoadingTimeEventAssert::class.java + ) { + + fun isEqualTo(expected: InternalTelemetryEvent.ApiUsage.AddViewLoadingTime) { + hasNoView(expected.noView) + hasNoActiveView(expected.noActiveView) + hasOverwrite(expected.overwrite) + hasAdditionalProperties(expected.additionalProperties) + } + + fun hasNoView(expected: Boolean): InternalAddViewLoadingTimeEventAssert { + assertThat(actual.noView) + .overridingErrorMessage( + "Expected viewLoadingTimeTelemetryEvent event to" + + " have noView $expected but was ${actual.noView}" + ) + .isEqualTo(expected) + return this + } + + fun hasNoActiveView(expected: Boolean): InternalAddViewLoadingTimeEventAssert { + assertThat(actual.noActiveView) + .overridingErrorMessage( + "Expected viewLoadingTimeTelemetryEvent event to have" + + " noActiveView $expected but was ${actual.noActiveView}" + ) + .isEqualTo(expected) + return this + } + + fun hasOverwrite(expected: Boolean): InternalAddViewLoadingTimeEventAssert { + assertThat(actual.overwrite) + .overridingErrorMessage( + "Expected viewLoadingTimeTelemetryEvent event to have" + + " overwrite $expected but was ${actual.overwrite}" + ) + .isEqualTo(expected) + return this + } + + fun hasAdditionalProperties(expected: Map): InternalAddViewLoadingTimeEventAssert { + assertThat(actual.additionalProperties) + .overridingErrorMessage( + "Expected viewLoadingTimeTelemetryEvent event to have" + + " additionalProperties $expected but was ${actual.additionalProperties}" + ) + .isEqualTo(expected) + return this + } + + companion object { + fun assertThat( + actual: InternalTelemetryEvent.ApiUsage.AddViewLoadingTime + ): InternalAddViewLoadingTimeEventAssert { + return InternalAddViewLoadingTimeEventAssert(actual) + } + } +} diff --git a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/utils/assertj/InternalApiUsageEventAssert.kt b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/utils/assertj/InternalApiUsageEventAssert.kt new file mode 100644 index 0000000000..5c28a0d6ad --- /dev/null +++ b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/utils/assertj/InternalApiUsageEventAssert.kt @@ -0,0 +1,38 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.datadog.android.rum.utils.assertj + +import com.datadog.android.internal.telemetry.InternalTelemetryEvent +import org.assertj.core.api.AbstractAssert + +class InternalApiUsageEventAssert(actual: InternalTelemetryEvent.ApiUsage) : + AbstractAssert( + actual, + InternalApiUsageEventAssert::class.java + ) { + + fun isEqualTo(expected: InternalTelemetryEvent.ApiUsage): InternalApiUsageEventAssert { + when (actual) { + is InternalTelemetryEvent.ApiUsage.AddViewLoadingTime -> { + InternalAddViewLoadingTimeEventAssert + .assertThat(actual as InternalTelemetryEvent.ApiUsage.AddViewLoadingTime) + .isEqualTo(expected as InternalTelemetryEvent.ApiUsage.AddViewLoadingTime) + } + + else -> { + failWithMessage("Unknown event type: ${actual::class.java.simpleName}") + } + } + return this + } + + companion object { + fun assertThat(actual: InternalTelemetryEvent.ApiUsage): InternalApiUsageEventAssert { + return InternalApiUsageEventAssert(actual) + } + } +} diff --git a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/utils/forge/Configurator.kt b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/utils/forge/Configurator.kt index b9a01987f9..e92da37b22 100644 --- a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/utils/forge/Configurator.kt +++ b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/utils/forge/Configurator.kt @@ -6,6 +6,12 @@ package com.datadog.android.rum.utils.forge +import com.datadog.android.internal.tests.elmyr.InternalTelemetryApiUsageForgeryFactory +import com.datadog.android.internal.tests.elmyr.InternalTelemetryConfigurationForgeryFactory +import com.datadog.android.internal.tests.elmyr.InternalTelemetryDebugLogForgeryFactory +import com.datadog.android.internal.tests.elmyr.InternalTelemetryErrorLogForgeryFactory +import com.datadog.android.internal.tests.elmyr.InternalTelemetryEventForgeryFactory +import com.datadog.android.internal.tests.elmyr.InternalTelemetryMetricForgeryFactory import com.datadog.android.rum.tests.elmyr.ResourceIdForgeryFactory import com.datadog.android.rum.tests.elmyr.RumScopeKeyForgeryFactory import com.datadog.android.tests.elmyr.useCoreFactories @@ -35,15 +41,23 @@ internal class Configurator : BaseConfigurator() { forge.addFactory(ResourceTimingForgeryFactory()) forge.addFactory(ViewEventForgeryFactory()) forge.addFactory(VitalInfoForgeryFactory()) - forge.addFactory(TelemetryCoreConfigurationForgeryFactory()) forge.addFactory(RumEventMetaForgeryFactory()) forge.addFactory(ViewEventMetaForgeryFactory()) forge.addFactory(RumScopeKeyForgeryFactory()) forge.addFactory(ResourceIdForgeryFactory()) - // Telemetry + // Telemetry schema models forge.addFactory(TelemetryDebugEventForgeryFactory()) forge.addFactory(TelemetryErrorEventForgeryFactory()) forge.addFactory(TelemetryConfigurationEventForgeryFactory()) + forge.addFactory(TelemetryUsageEventForgeryFactory()) + + // Telemetry internal models + forge.addFactory(InternalTelemetryEventForgeryFactory()) + forge.addFactory(InternalTelemetryMetricForgeryFactory()) + forge.addFactory(InternalTelemetryDebugLogForgeryFactory()) + forge.addFactory(InternalTelemetryErrorLogForgeryFactory()) + forge.addFactory(InternalTelemetryConfigurationForgeryFactory()) + forge.addFactory(InternalTelemetryApiUsageForgeryFactory()) } } diff --git a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/utils/forge/ErrorEventForgeryFactory.kt b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/utils/forge/ErrorEventForgeryFactory.kt index 9c4d8f151e..a7d663f53a 100644 --- a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/utils/forge/ErrorEventForgeryFactory.kt +++ b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/utils/forge/ErrorEventForgeryFactory.kt @@ -7,6 +7,7 @@ package com.datadog.android.rum.utils.forge import com.datadog.android.core.internal.utils.loggableStackTrace +import com.datadog.android.internal.utils.loggableStackTrace import com.datadog.android.rum.model.ErrorEvent import com.datadog.tools.unit.forge.aThrowable import com.datadog.tools.unit.forge.exhaustiveAttributes diff --git a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/utils/forge/TelemetryCoreConfigurationForgeryFactory.kt b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/utils/forge/TelemetryCoreConfigurationForgeryFactory.kt deleted file mode 100644 index e5694f319c..0000000000 --- a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/utils/forge/TelemetryCoreConfigurationForgeryFactory.kt +++ /dev/null @@ -1,25 +0,0 @@ -/* - * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. - * This product includes software developed at Datadog (https://www.datadoghq.com/). - * Copyright 2016-Present Datadog, Inc. - */ - -package com.datadog.android.rum.utils.forge - -import com.datadog.android.telemetry.internal.TelemetryCoreConfiguration -import fr.xgouchet.elmyr.Forge -import fr.xgouchet.elmyr.ForgeryFactory - -internal class TelemetryCoreConfigurationForgeryFactory : - ForgeryFactory { - override fun getForgery(forge: Forge): TelemetryCoreConfiguration { - return TelemetryCoreConfiguration( - trackErrors = forge.aBool(), - batchSize = forge.aPositiveLong(), - batchUploadFrequency = forge.aPositiveLong(), - useProxy = forge.aBool(), - useLocalEncryption = forge.aBool(), - batchProcessingLevel = forge.aPositiveInt() - ) - } -} diff --git a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/utils/forge/TelemetryErrorEventForgeryFactory.kt b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/utils/forge/TelemetryErrorEventForgeryFactory.kt index 0bffb5b388..40ba8930bd 100644 --- a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/utils/forge/TelemetryErrorEventForgeryFactory.kt +++ b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/utils/forge/TelemetryErrorEventForgeryFactory.kt @@ -7,6 +7,7 @@ package com.datadog.android.rum.utils.forge import com.datadog.android.core.internal.utils.loggableStackTrace +import com.datadog.android.internal.utils.loggableStackTrace import com.datadog.android.telemetry.model.TelemetryErrorEvent import com.datadog.tools.unit.forge.aThrowable import fr.xgouchet.elmyr.Forge diff --git a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/utils/forge/TelemetryUsageEventForgeryFactory.kt b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/utils/forge/TelemetryUsageEventForgeryFactory.kt new file mode 100644 index 0000000000..8870c0e106 --- /dev/null +++ b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/utils/forge/TelemetryUsageEventForgeryFactory.kt @@ -0,0 +1,67 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.datadog.android.rum.utils.forge + +import com.datadog.android.telemetry.model.TelemetryUsageEvent +import com.datadog.android.tests.elmyr.exhaustiveAttributes +import fr.xgouchet.elmyr.Forge +import fr.xgouchet.elmyr.ForgeryFactory + +internal class TelemetryUsageEventForgeryFactory : ForgeryFactory { + override fun getForgery(forge: Forge): TelemetryUsageEvent { + return TelemetryUsageEvent( + dd = TelemetryUsageEvent.Dd(), + date = forge.aPositiveLong(), + service = forge.anAlphabeticalString(), + source = forge.aValueFrom(TelemetryUsageEvent.Source::class.java), + version = forge.anAlphabeticalString(), + application = forge.aNullable { + TelemetryUsageEvent.Application( + id = anAlphabeticalString() + ) + }, + session = forge.aNullable { + TelemetryUsageEvent.Session( + id = anAlphabeticalString() + ) + }, + view = forge.aNullable { + TelemetryUsageEvent.View( + id = anAlphabeticalString() + ) + }, + action = forge.aNullable { + TelemetryUsageEvent.Action( + id = anAlphabeticalString() + ) + }, + experimentalFeatures = forge.aNullable { aList { anAlphabeticalString() } }, + telemetry = TelemetryUsageEvent.Telemetry( + device = forge.aNullable { + TelemetryUsageEvent.Device( + architecture = anAlphabeticalString(), + brand = anAlphabeticalString(), + model = anAlphabeticalString() + ) + }, + os = forge.aNullable { + TelemetryUsageEvent.Os( + build = anAlphabeticalString(), + name = anAlphabeticalString(), + version = anAlphabeticalString() + ) + }, + usage = TelemetryUsageEvent.Usage.AddViewLoadingTime( + noView = forge.aBool(), + noActiveView = forge.aBool(), + overwritten = forge.aBool() + ), + additionalProperties = forge.exhaustiveAttributes() + ) + ) + } +} diff --git a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/telemetry/assertj/TelemetryConfigurationEventAssert.kt b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/telemetry/assertj/TelemetryConfigurationEventAssert.kt index 1c65d43645..bfd29dc99d 100644 --- a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/telemetry/assertj/TelemetryConfigurationEventAssert.kt +++ b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/telemetry/assertj/TelemetryConfigurationEventAssert.kt @@ -269,6 +269,7 @@ internal class TelemetryConfigurationEventAssert(actual: TelemetryConfigurationE .isEqualTo(expected) return this } + fun hasBatchProcessingLevel(expected: Int?): TelemetryConfigurationEventAssert { assertThat(actual.telemetry.configuration.batchProcessingLevel) .overridingErrorMessage( @@ -348,23 +349,45 @@ internal class TelemetryConfigurationEventAssert(actual: TelemetryConfigurationE return this } - fun hasSessionReplayPrivacy(expected: String?): TelemetryConfigurationEventAssert { - assertThat(actual.telemetry.configuration.defaultPrivacyLevel) + fun hasSessionReplayImagePrivacy(expected: String?): TelemetryConfigurationEventAssert { + assertThat(actual.telemetry.configuration.imagePrivacyLevel) + .overridingErrorMessage( + "Expected event data to have telemetry.configuration.imagePrivacyLevel" + + " $expected " + + "but was ${actual.telemetry.configuration.imagePrivacyLevel}" + ) + .isEqualTo(expected) + return this + } + + fun hasSessionReplayTouchPrivacy(expected: String?): TelemetryConfigurationEventAssert { + assertThat(actual.telemetry.configuration.touchPrivacyLevel) + .overridingErrorMessage( + "Expected event data to have telemetry.configuration.touchPrivacyLevel" + + " $expected " + + "but was ${actual.telemetry.configuration.touchPrivacyLevel}" + ) + .isEqualTo(expected) + return this + } + + fun hasSessionReplayTextAndInputPrivacy(expected: String?): TelemetryConfigurationEventAssert { + assertThat(actual.telemetry.configuration.textAndInputPrivacyLevel) .overridingErrorMessage( - "Expected event data to have telemetry.configuration.defaultPrivacyLevel" + + "Expected event data to have telemetry.configuration.textAndInputPrivacyLevel" + " $expected " + - "but was ${actual.telemetry.configuration.defaultPrivacyLevel}" + "but was ${actual.telemetry.configuration.textAndInputPrivacyLevel}" ) .isEqualTo(expected) return this } - fun hasSessionReplayStartManually(expected: Boolean?): TelemetryConfigurationEventAssert { + fun hasStartRecordingImmediately(expected: Boolean?): TelemetryConfigurationEventAssert { val assertErrorMessage = "Expected event data to have" + - " telemetry.configuration.startSessionReplayRecordingManually" + + " telemetry.configuration.startRecordingImmediately" + " $expected " + - "but was ${actual.telemetry.configuration.startSessionReplayRecordingManually}" - assertThat(actual.telemetry.configuration.startSessionReplayRecordingManually) + "but was ${actual.telemetry.configuration.startRecordingImmediately}" + assertThat(actual.telemetry.configuration.startRecordingImmediately) .overridingErrorMessage(assertErrorMessage) .isEqualTo(expected) return this diff --git a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/telemetry/assertj/TelemetryUsageEventAssert.kt b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/telemetry/assertj/TelemetryUsageEventAssert.kt new file mode 100644 index 0000000000..2aece50e4b --- /dev/null +++ b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/telemetry/assertj/TelemetryUsageEventAssert.kt @@ -0,0 +1,217 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.datadog.android.telemetry.assertj + +import com.datadog.android.internal.telemetry.InternalTelemetryEvent +import com.datadog.android.telemetry.model.TelemetryUsageEvent +import org.assertj.core.api.AbstractObjectAssert +import org.assertj.core.api.Assertions.assertThat + +internal class TelemetryUsageEventAssert(actual: TelemetryUsageEvent) : + AbstractObjectAssert( + actual, + TelemetryUsageEventAssert::class.java + ) { + + fun hasDate(expected: Long): TelemetryUsageEventAssert { + assertThat(actual.date) + .overridingErrorMessage( + "Expected event data to have date $expected but was ${actual.date}" + ) + .isEqualTo(expected) + return this + } + + fun hasSource(expected: TelemetryUsageEvent.Source): TelemetryUsageEventAssert { + assertThat(actual.source) + .overridingErrorMessage( + "Expected event data to have source $expected but was ${actual.source}" + ) + .isEqualTo(expected) + return this + } + + fun hasService(expected: String): TelemetryUsageEventAssert { + assertThat(actual.service) + .overridingErrorMessage( + "Expected event data to have service $expected but was ${actual.service}" + ) + .isEqualTo(expected) + return this + } + + fun hasVersion(expected: String): TelemetryUsageEventAssert { + assertThat(actual.version) + .overridingErrorMessage( + "Expected event data to have version $expected but was ${actual.version}" + ) + .isEqualTo(expected) + return this + } + + fun hasApplicationId(expected: String?): TelemetryUsageEventAssert { + assertThat(actual.application?.id) + .overridingErrorMessage( + "Expected event data to have" + + " application.id $expected but was ${actual.application?.id}" + ) + .isEqualTo(expected) + return this + } + + fun hasSessionId(expected: String?): TelemetryUsageEventAssert { + assertThat(actual.session?.id) + .overridingErrorMessage( + "Expected event data to have session.id $expected but was ${actual.session?.id}" + ) + .isEqualTo(expected) + return this + } + + fun hasViewId(expected: String?): TelemetryUsageEventAssert { + assertThat(actual.view?.id) + .overridingErrorMessage( + "Expected event data to have view.id $expected but was ${actual.view?.id}" + ) + .isEqualTo(expected) + return this + } + + fun hasActionId(expected: String?): TelemetryUsageEventAssert { + assertThat(actual.action?.id) + .overridingErrorMessage( + "Expected event data to have action.id $expected but was ${actual.action?.id}" + ) + .isEqualTo(expected) + return this + } + + fun hasAdditionalProperties(additionalProperties: Map): TelemetryUsageEventAssert { + assertThat(actual.telemetry.additionalProperties) + .overridingErrorMessage( + "Expected event data to have telemetry.additionalProperties $additionalProperties" + + " but was ${actual.telemetry.additionalProperties}" + ) + .isEqualTo(additionalProperties) + return this + } + + fun hasDeviceArchitecture(expected: String?): TelemetryUsageEventAssert { + assertThat(actual.telemetry.device?.architecture) + .overridingErrorMessage( + "Expected event data to have telemetry.device architecture $expected" + + " but was ${actual.telemetry.device?.architecture}" + ) + .isEqualTo(expected) + return this + } + + fun hasDeviceModel(expected: String?): TelemetryUsageEventAssert { + assertThat(actual.telemetry.device?.model) + .overridingErrorMessage( + "Expected event data to have telemetry.device model $expected" + + " but was ${actual.telemetry.device?.model}" + ) + .isEqualTo(expected) + return this + } + + fun hasDeviceBrand(expected: String?): TelemetryUsageEventAssert { + assertThat(actual.telemetry.device?.brand) + .overridingErrorMessage( + "Expected event data to have telemetry.device brand $expected" + + " but was ${actual.telemetry.device?.brand}" + ) + .isEqualTo(expected) + return this + } + + fun hasOsBuild(expected: String?): TelemetryUsageEventAssert { + assertThat(actual.telemetry.os?.build) + .overridingErrorMessage( + "Expected event data to have telemetry.os build $expected" + + " but was ${actual.telemetry.os?.build}" + ) + .isEqualTo(expected) + return this + } + + fun hasOsName(expected: String?): TelemetryUsageEventAssert { + assertThat(actual.telemetry.os?.name) + .overridingErrorMessage( + "Expected event data to have telemetry.os name $expected" + + " but was ${actual.telemetry.os?.name}" + ) + .isEqualTo(expected) + return this + } + + fun hasOsVersion(expected: String?): TelemetryUsageEventAssert { + assertThat(actual.telemetry.os?.version) + .overridingErrorMessage( + "Expected event data to have telemetry.os version $expected" + + " but was ${actual.telemetry.os?.version}" + ) + .isEqualTo(expected) + return this + } + + fun hasUsage(expected: InternalTelemetryEvent.ApiUsage) { + when (expected) { + is InternalTelemetryEvent.ApiUsage.AddViewLoadingTime -> { + val actualUsage = actual.telemetry.usage as TelemetryUsageEvent.Usage.AddViewLoadingTime + assertThat(actualUsage) + .hasNoView(expected.noView) + .hasOverwritten(actualUsage.overwritten) + .hasNoActiveView(expected.noActiveView) + } + } + } + + private class ViewLoadingTimeEventAssert(actual: TelemetryUsageEvent.Usage.AddViewLoadingTime) : + AbstractObjectAssert( + actual, + ViewLoadingTimeEventAssert::class.java + ) { + + fun hasNoView(expected: Boolean): ViewLoadingTimeEventAssert { + assertThat(actual.noView) + .overridingErrorMessage( + "Expected viewLoadingTimeUsage event to" + + " have noView $expected but was ${actual.noView}" + ) + .isEqualTo(expected) + return this + } + + fun hasNoActiveView(expected: Boolean): ViewLoadingTimeEventAssert { + assertThat(actual.noActiveView) + .overridingErrorMessage( + "Expected viewLoadingTimeUsage event to have" + + " noActiveView $expected but was ${actual.noActiveView}" + ) + .isEqualTo(expected) + return this + } + + fun hasOverwritten(expected: Boolean): ViewLoadingTimeEventAssert { + assertThat(actual.overwritten) + .overridingErrorMessage( + "Expected viewLoadingTimeUsage event to have" + + " overwritten $expected but was ${actual.overwritten}" + ) + .isEqualTo(expected) + return this + } + } + + companion object { + fun assertThat(actual: TelemetryUsageEvent) = TelemetryUsageEventAssert(actual) + private fun assertThat(actual: TelemetryUsageEvent.Usage.AddViewLoadingTime) = + ViewLoadingTimeEventAssert(actual) + } +} diff --git a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/telemetry/assertj/ViewLoadingTimeEventAssert.kt b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/telemetry/assertj/ViewLoadingTimeEventAssert.kt new file mode 100644 index 0000000000..cadbd588a7 --- /dev/null +++ b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/telemetry/assertj/ViewLoadingTimeEventAssert.kt @@ -0,0 +1,50 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.datadog.android.telemetry.assertj + +import com.datadog.android.telemetry.model.TelemetryUsageEvent +import org.assertj.core.api.AbstractObjectAssert +import org.assertj.core.api.Assertions.assertThat + +internal class ViewLoadingTimeEventAssert(actual: TelemetryUsageEvent.Usage.AddViewLoadingTime) : + AbstractObjectAssert( + actual, + ViewLoadingTimeEventAssert::class.java + ) { + + fun hasNoView(expected: Boolean): ViewLoadingTimeEventAssert { + assertThat(actual.noView) + .overridingErrorMessage( + "Expected event data to have noView $expected but was ${actual.noView}" + ) + .isEqualTo(expected) + return this + } + + fun hasNoActiveView(expected: Boolean): ViewLoadingTimeEventAssert { + assertThat(actual.noActiveView) + .overridingErrorMessage( + "Expected event data to have noActiveView $expected but was ${actual.noActiveView}" + ) + .isEqualTo(expected) + return this + } + + fun hasOverwritten(expected: Boolean): ViewLoadingTimeEventAssert { + assertThat(actual.overwritten) + .overridingErrorMessage( + "Expected event data to have overwriten $expected but was ${actual.overwritten}" + ) + .isEqualTo(expected) + return this + } + + companion object { + fun assertThat(actual: TelemetryUsageEvent.Usage.AddViewLoadingTime) = + ViewLoadingTimeEventAssert(actual) + } +} diff --git a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/telemetry/internal/TelemetryCoreConfigurationTest.kt b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/telemetry/internal/TelemetryCoreConfigurationTest.kt deleted file mode 100644 index eaac829b9e..0000000000 --- a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/telemetry/internal/TelemetryCoreConfigurationTest.kt +++ /dev/null @@ -1,74 +0,0 @@ -/* - * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. - * This product includes software developed at Datadog (https://www.datadoghq.com/). - * Copyright 2016-Present Datadog, Inc. - */ - -package com.datadog.android.telemetry.internal - -import fr.xgouchet.elmyr.annotation.AdvancedForgery -import fr.xgouchet.elmyr.annotation.BoolForgery -import fr.xgouchet.elmyr.annotation.IntForgery -import fr.xgouchet.elmyr.annotation.LongForgery -import fr.xgouchet.elmyr.annotation.MapForgery -import fr.xgouchet.elmyr.annotation.StringForgery -import fr.xgouchet.elmyr.annotation.StringForgeryType -import fr.xgouchet.elmyr.junit5.ForgeExtension -import org.assertj.core.api.Assertions.assertThat -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.extension.ExtendWith -import org.junit.jupiter.api.extension.Extensions -import org.mockito.kotlin.mock - -@Extensions( - ExtendWith(ForgeExtension::class) -) -internal class TelemetryCoreConfigurationTest { - - @Test - fun `M create TelemetryCoreConfiguration W fromEvent()`( - @BoolForgery trackErrors: Boolean, - @BoolForgery useProxy: Boolean, - @BoolForgery useLocalEncryption: Boolean, - @LongForgery(min = 0L) batchSize: Long, - @LongForgery(min = 0L) batchUploadFrequency: Long, - @IntForgery(min = 0) batchProcessingLevel: Int - ) { - // Given - val event = mapOf( - "type" to "telemetry_configuration", - "track_errors" to trackErrors, - "batch_size" to batchSize, - "batch_upload_frequency" to batchUploadFrequency, - "use_proxy" to useProxy, - "use_local_encryption" to useLocalEncryption, - "batch_processing_level" to batchProcessingLevel - ) - - // When - val coreConfig = TelemetryCoreConfiguration.fromEvent(event, internalLogger = mock()) - - // Then - assertThat(coreConfig).isNotNull - assertThat(coreConfig!!.trackErrors).isEqualTo(trackErrors) - assertThat(coreConfig.batchSize).isEqualTo(batchSize) - assertThat(coreConfig.batchUploadFrequency).isEqualTo(batchUploadFrequency) - assertThat(coreConfig.useProxy).isEqualTo(useProxy) - assertThat(coreConfig.useLocalEncryption).isEqualTo(useLocalEncryption) - assertThat(coreConfig.batchProcessingLevel).isEqualTo(batchProcessingLevel) - } - - @Test - fun `M return null W fromEvent() { malformed message }`( - @MapForgery( - key = AdvancedForgery(string = [StringForgery(StringForgeryType.ALPHABETICAL)]), - value = AdvancedForgery(string = [StringForgery(StringForgeryType.ALPHABETICAL)]) - ) fakeEvent: Map - ) { - // When - val coreConfig = TelemetryCoreConfiguration.fromEvent(fakeEvent, internalLogger = mock()) - - // Then - assertThat(coreConfig).isNull() - } -} diff --git a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/telemetry/internal/TelemetryEventExtTest.kt b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/telemetry/internal/TelemetryEventExtTest.kt index d3631a9c24..925f36d548 100644 --- a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/telemetry/internal/TelemetryEventExtTest.kt +++ b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/telemetry/internal/TelemetryEventExtTest.kt @@ -13,6 +13,7 @@ import com.datadog.android.rum.utils.verifyLog import com.datadog.android.telemetry.model.TelemetryConfigurationEvent import com.datadog.android.telemetry.model.TelemetryDebugEvent import com.datadog.android.telemetry.model.TelemetryErrorEvent +import com.datadog.android.telemetry.model.TelemetryUsageEvent import fr.xgouchet.elmyr.Forge import fr.xgouchet.elmyr.junit5.ForgeConfiguration import fr.xgouchet.elmyr.junit5.ForgeExtension @@ -113,6 +114,39 @@ internal class TelemetryEventExtTest { // endregion + // region TelemetryUsageEvent + + @Test + fun `M resolve the TelemetryUsageEvent source W telemetryUsageEventSource`() { + assertThat( + TelemetryUsageEvent.Source.tryFromSource(fakeValidTelemetrySource, mock()) + ?.toJson()?.asString + ) + .isEqualTo(fakeValidTelemetrySource) + } + + @Test + fun `M return null W telemetryUsageEventSource { unknown source }`() { + assertThat(TelemetryUsageEvent.Source.tryFromSource(fakeInvalidSource, mock())).isNull() + } + + @Test + fun `M send an error dev log W telemetryUsageEventSource { unknown source }`() { + // When + val mockInternalLogger = mock() + TelemetryUsageEvent.Source.tryFromSource(fakeInvalidSource, mockInternalLogger) + + // Then + mockInternalLogger.verifyLog( + InternalLogger.Level.ERROR, + InternalLogger.Target.USER, + UNKNOWN_SOURCE_WARNING_MESSAGE_FORMAT.format(Locale.US, fakeInvalidSource), + NoSuchElementException::class.java + ) + } + + // endregion + // region TelemetryConfigurationEvent @Test diff --git a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/telemetry/internal/TelemetryEventHandlerTest.kt b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/telemetry/internal/TelemetryEventHandlerTest.kt index fc78624e26..e997f169ce 100644 --- a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/telemetry/internal/TelemetryEventHandlerTest.kt +++ b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/telemetry/internal/TelemetryEventHandlerTest.kt @@ -15,8 +15,9 @@ import com.datadog.android.api.storage.DataWriter import com.datadog.android.api.storage.EventBatchWriter import com.datadog.android.api.storage.EventType import com.datadog.android.core.InternalSdkCore -import com.datadog.android.core.internal.utils.loggableStackTrace import com.datadog.android.core.sampling.Sampler +import com.datadog.android.internal.telemetry.InternalTelemetryEvent +import com.datadog.android.internal.utils.loggableStackTrace import com.datadog.android.rum.internal.RumFeature import com.datadog.android.rum.internal.domain.RumContext import com.datadog.android.rum.internal.domain.scope.RumRawEvent @@ -30,11 +31,12 @@ import com.datadog.android.rum.utils.verifyLog import com.datadog.android.telemetry.assertj.TelemetryConfigurationEventAssert.Companion.assertThat import com.datadog.android.telemetry.assertj.TelemetryDebugEventAssert.Companion.assertThat import com.datadog.android.telemetry.assertj.TelemetryErrorEventAssert.Companion.assertThat +import com.datadog.android.telemetry.assertj.TelemetryUsageEventAssert.Companion.assertThat import com.datadog.android.telemetry.model.TelemetryConfigurationEvent import com.datadog.android.telemetry.model.TelemetryDebugEvent import com.datadog.android.telemetry.model.TelemetryErrorEvent +import com.datadog.android.telemetry.model.TelemetryUsageEvent import com.datadog.tools.unit.forge.aThrowable -import com.datadog.tools.unit.forge.exhaustiveAttributes import com.datadog.tools.unit.setStaticValue import fr.xgouchet.elmyr.Forge import fr.xgouchet.elmyr.annotation.Forgery @@ -197,24 +199,31 @@ internal class TelemetryEventHandlerTest { // region Debug Event @Test - fun `M create debug event W handleEvent(SendTelemetry) { debug event status }`(forge: Forge) { + fun `M create debug event W handleEvent(Log Debug)`(@Forgery fakeLogDebugEvent: InternalTelemetryEvent.Log.Debug) { // Given - val debugRawEvent = forge.createRumRawTelemetryDebugEvent() + val fakeWrappedEvent = RumRawEvent.TelemetryEventWrapper(fakeLogDebugEvent) // When - testedTelemetryHandler.handleEvent(debugRawEvent, mockWriter) + testedTelemetryHandler.handleEvent(fakeWrappedEvent, mockWriter) // Then argumentCaptor { verify(mockWriter).write(eq(mockEventBatchWriter), capture(), eq(EventType.TELEMETRY)) - assertDebugEventMatchesRawEvent(lastValue, debugRawEvent, fakeRumContext) + assertDebugEventMatchesInternalEvent( + lastValue, + fakeLogDebugEvent, + fakeRumContext, + fakeWrappedEvent.eventTime.timestamp + ) } } @Test - fun `M create debug event W handleEvent(SendTelemetry) { debug event status, no RUM }`(forge: Forge) { + fun `M create debug event W handleEvent(Log Debug, no RUM)`( + @Forgery fakeLogDebugEvent: InternalTelemetryEvent.Log.Debug + ) { // Given - val debugRawEvent = forge.createRumRawTelemetryDebugEvent() + val fakeWrappedEvent = RumRawEvent.TelemetryEventWrapper(fakeLogDebugEvent) fakeDatadogContext = fakeDatadogContext.copy( featuresContext = fakeDatadogContext.featuresContext.toMutableMap().apply { remove(Feature.RUM_FEATURE_NAME) @@ -228,12 +237,17 @@ internal class TelemetryEventHandlerTest { ) // When - testedTelemetryHandler.handleEvent(debugRawEvent, mockWriter) + testedTelemetryHandler.handleEvent(fakeWrappedEvent, mockWriter) // Then argumentCaptor { verify(mockWriter).write(eq(mockEventBatchWriter), capture(), eq(EventType.TELEMETRY)) - assertDebugEventMatchesRawEvent(lastValue, debugRawEvent, noRumContext) + assertDebugEventMatchesInternalEvent( + lastValue, + fakeLogDebugEvent, + noRumContext, + fakeWrappedEvent.eventTime.timestamp + ) } } @@ -242,24 +256,186 @@ internal class TelemetryEventHandlerTest { // region Error Event @Test - fun `M create error event W handleEvent(SendTelemetry) { error event status }`(forge: Forge) { + fun `M create error event W handleEvent(Log Error, only stacktrace)`(forge: Forge) { + // Given + val expectedStackTrace = forge.aString() + val fakeLogErrorEvent = InternalTelemetryEvent.Log.Error( + message = forge.aString(), + additionalProperties = forge.aMap { aString() to aString() }, + error = null, + stacktrace = expectedStackTrace, + kind = null + ) + val fakeWrappedEvent = RumRawEvent.TelemetryEventWrapper(fakeLogErrorEvent) + fakeDatadogContext = fakeDatadogContext.copy( + featuresContext = fakeDatadogContext.featuresContext.toMutableMap().apply { + remove(Feature.RUM_FEATURE_NAME) + } + ) + val noRumContext = RumContext( + applicationId = RumContext.NULL_UUID, + sessionId = RumContext.NULL_UUID, + viewId = null, + actionId = null + ) + + // When + testedTelemetryHandler.handleEvent(fakeWrappedEvent, mockWriter) + + // Then + argumentCaptor { + verify(mockWriter).write(eq(mockEventBatchWriter), capture(), eq(EventType.TELEMETRY)) + assertErrorEventMatchesInternalEvent( + lastValue, + fakeLogErrorEvent, + noRumContext, + fakeWrappedEvent.eventTime.timestamp, + kind = null, + stacktrace = expectedStackTrace + ) + } + } + + @Test + fun `M create error event W handleEvent(Log Error, only kind)`(forge: Forge) { + // Given + val expectedKind = forge.aString() + val fakeLogErrorEvent = InternalTelemetryEvent.Log.Error( + message = forge.aString(), + additionalProperties = forge.aMap { aString() to aString() }, + error = null, + stacktrace = null, + kind = expectedKind + ) + val fakeWrappedEvent = RumRawEvent.TelemetryEventWrapper(fakeLogErrorEvent) + fakeDatadogContext = fakeDatadogContext.copy( + featuresContext = fakeDatadogContext.featuresContext.toMutableMap().apply { + remove(Feature.RUM_FEATURE_NAME) + } + ) + val noRumContext = RumContext( + applicationId = RumContext.NULL_UUID, + sessionId = RumContext.NULL_UUID, + viewId = null, + actionId = null + ) + + // When + testedTelemetryHandler.handleEvent(fakeWrappedEvent, mockWriter) + + // Then + argumentCaptor { + verify(mockWriter).write(eq(mockEventBatchWriter), capture(), eq(EventType.TELEMETRY)) + assertErrorEventMatchesInternalEvent( + lastValue, + fakeLogErrorEvent, + noRumContext, + fakeWrappedEvent.eventTime.timestamp, + kind = expectedKind, + stacktrace = null + ) + } + } + + @Test + fun `M create error event W handleEvent(Log Error, kind and stacktrace)`(forge: Forge) { + // Given + val expectedKind = forge.aString() + val expectedStacktrace = forge.aString() + val fakeLogErrorEvent = InternalTelemetryEvent.Log.Error( + message = forge.aString(), + additionalProperties = forge.aMap { aString() to aString() }, + error = null, + stacktrace = expectedStacktrace, + kind = expectedKind + ) + val fakeWrappedEvent = RumRawEvent.TelemetryEventWrapper(fakeLogErrorEvent) + fakeDatadogContext = fakeDatadogContext.copy( + featuresContext = fakeDatadogContext.featuresContext.toMutableMap().apply { + remove(Feature.RUM_FEATURE_NAME) + } + ) + val noRumContext = RumContext( + applicationId = RumContext.NULL_UUID, + sessionId = RumContext.NULL_UUID, + viewId = null, + actionId = null + ) + + // When + testedTelemetryHandler.handleEvent(fakeWrappedEvent, mockWriter) + + // Then + argumentCaptor { + verify(mockWriter).write(eq(mockEventBatchWriter), capture(), eq(EventType.TELEMETRY)) + assertErrorEventMatchesInternalEvent( + lastValue, + fakeLogErrorEvent, + noRumContext, + fakeWrappedEvent.eventTime.timestamp, + kind = expectedKind, + stacktrace = expectedStacktrace + ) + } + } + + @Test + fun `M create error event W handleEvent(Log Error, only throwable)`(forge: Forge) { // Given - val errorRawEvent = forge.createRumRawTelemetryErrorEvent() + val expectedThrowable = forge.aThrowable() + val expectedKind = expectedThrowable.javaClass.canonicalName + val expectedStacktrace = expectedThrowable.loggableStackTrace() + val fakeLogErrorEvent = InternalTelemetryEvent.Log.Error( + message = forge.aString(), + additionalProperties = forge.aMap { aString() to aString() }, + error = expectedThrowable, + stacktrace = null, + kind = null + ) + val fakeWrappedEvent = RumRawEvent.TelemetryEventWrapper(fakeLogErrorEvent) + fakeDatadogContext = fakeDatadogContext.copy( + featuresContext = fakeDatadogContext.featuresContext.toMutableMap().apply { + remove(Feature.RUM_FEATURE_NAME) + } + ) + val noRumContext = RumContext( + applicationId = RumContext.NULL_UUID, + sessionId = RumContext.NULL_UUID, + viewId = null, + actionId = null + ) // When - testedTelemetryHandler.handleEvent(errorRawEvent, mockWriter) + testedTelemetryHandler.handleEvent(fakeWrappedEvent, mockWriter) // Then argumentCaptor { verify(mockWriter).write(eq(mockEventBatchWriter), capture(), eq(EventType.TELEMETRY)) - assertErrorEventMatchesRawEvent(lastValue, errorRawEvent, fakeRumContext) + assertErrorEventMatchesInternalEvent( + lastValue, + fakeLogErrorEvent, + noRumContext, + fakeWrappedEvent.eventTime.timestamp, + kind = expectedKind, + stacktrace = expectedStacktrace + ) } } @Test - fun `M create error event W handleEvent(SendTelemetry) { error event status, no RUM }`(forge: Forge) { + fun `M create error event W handleEvent(Log Error, throwable, stacktrace and kind)`(forge: Forge) { // Given - val errorRawEvent = forge.createRumRawTelemetryErrorEvent() + val expectedThrowable = forge.aThrowable() + val expectedKind = forge.aString() + val expectedStacktrace = forge.aString() + val fakeLogErrorEvent = InternalTelemetryEvent.Log.Error( + message = forge.aString(), + additionalProperties = forge.aMap { aString() to aString() }, + error = expectedThrowable, + stacktrace = expectedStacktrace, + kind = expectedKind + ) + val fakeWrappedEvent = RumRawEvent.TelemetryEventWrapper(fakeLogErrorEvent) fakeDatadogContext = fakeDatadogContext.copy( featuresContext = fakeDatadogContext.featuresContext.toMutableMap().apply { remove(Feature.RUM_FEATURE_NAME) @@ -273,12 +449,19 @@ internal class TelemetryEventHandlerTest { ) // When - testedTelemetryHandler.handleEvent(errorRawEvent, mockWriter) + testedTelemetryHandler.handleEvent(fakeWrappedEvent, mockWriter) // Then argumentCaptor { verify(mockWriter).write(eq(mockEventBatchWriter), capture(), eq(EventType.TELEMETRY)) - assertErrorEventMatchesRawEvent(lastValue, errorRawEvent, noRumContext) + assertErrorEventMatchesInternalEvent( + lastValue, + fakeLogErrorEvent, + noRumContext, + fakeWrappedEvent.eventTime.timestamp, + kind = expectedKind, + stacktrace = expectedStacktrace + ) } } @@ -287,24 +470,33 @@ internal class TelemetryEventHandlerTest { // region Configuration Event @Test - fun `M create config event W handleEvent(SendTelemetry) { configuration }`(forge: Forge) { + fun `M create config event W handleEvent(Configuration)`( + @Forgery fakeConfigurationEvent: InternalTelemetryEvent.Configuration + ) { // Given - val configRawEvent = forge.createRumRawTelemetryConfigurationEvent() + val fakeWrappedEvent = RumRawEvent.TelemetryEventWrapper(fakeConfigurationEvent) // When - testedTelemetryHandler.handleEvent(configRawEvent, mockWriter) + testedTelemetryHandler.handleEvent(fakeWrappedEvent, mockWriter) // Then argumentCaptor { verify(mockWriter).write(eq(mockEventBatchWriter), capture(), eq(EventType.TELEMETRY)) - assertConfigEventMatchesRawEvent(firstValue, configRawEvent, fakeRumContext) + assertConfigEventMatchesInternalEvent( + firstValue, + fakeConfigurationEvent, + fakeRumContext, + fakeWrappedEvent.eventTime.timestamp + ) } } @Test - fun `M create config event W handleEvent(SendTelemetry) { configuration, no RUM }`(forge: Forge) { + fun `M create config event W handleEvent() { Configuration, no RUM)`( + @Forgery fakeConfigurationEvent: InternalTelemetryEvent.Configuration + ) { // Given - val configRawEvent = forge.createRumRawTelemetryConfigurationEvent() + val fakeWrappedEvent = RumRawEvent.TelemetryEventWrapper(fakeConfigurationEvent) fakeDatadogContext = fakeDatadogContext.copy( featuresContext = fakeDatadogContext.featuresContext.toMutableMap().apply { remove(Feature.RUM_FEATURE_NAME) @@ -318,26 +510,31 @@ internal class TelemetryEventHandlerTest { ) // When - testedTelemetryHandler.handleEvent(configRawEvent, mockWriter) + testedTelemetryHandler.handleEvent(fakeWrappedEvent, mockWriter) // Then argumentCaptor { verify(mockWriter).write(eq(mockEventBatchWriter), capture(), eq(EventType.TELEMETRY)) - assertConfigEventMatchesRawEvent(firstValue, configRawEvent, noRumContext) + assertConfigEventMatchesInternalEvent( + firstValue, + fakeConfigurationEvent, + noRumContext, + fakeWrappedEvent.eventTime.timestamp + ) } } @Test - fun `M create config event W handleEvent(SendTelemetry) { with RUM config }`( + fun `M create config event W handleEvent() {Configuration, with RUM config }`( @Forgery fakeRumConfiguration: RumFeature.Configuration, - forge: Forge + @Forgery fakeConfigurationEvent: InternalTelemetryEvent.Configuration ) { // Given val mockRumFeature = mock() whenever(mockRumFeature.configuration) doReturn fakeRumConfiguration whenever(mockRumFeatureScope.unwrap()) doReturn mockRumFeature - val configRawEvent = forge.createRumRawTelemetryConfigurationEvent() + val configRawEvent = RumRawEvent.TelemetryEventWrapper(fakeConfigurationEvent) val expectedViewTrackingStrategy = when (fakeRumConfiguration.viewTrackingStrategy) { is ActivityViewTrackingStrategy -> VTS.ACTIVITYVIEWTRACKINGSTRATEGY @@ -353,7 +550,12 @@ internal class TelemetryEventHandlerTest { // Then argumentCaptor { verify(mockWriter).write(eq(mockEventBatchWriter), capture(), eq(EventType.TELEMETRY)) - assertConfigEventMatchesRawEvent(firstValue, configRawEvent, fakeRumContext) + assertConfigEventMatchesInternalEvent( + firstValue, + fakeConfigurationEvent, + fakeRumContext, + configRawEvent.eventTime.timestamp + ) assertThat(firstValue) .hasSessionSampleRate(fakeRumConfiguration.sampleRate.toLong()) .hasTelemetrySampleRate(fakeRumConfiguration.telemetrySampleRate.toLong()) @@ -367,41 +569,16 @@ internal class TelemetryEventHandlerTest { } } - @Test - fun `M create config event W handleEvent(SendTelemetry) { with Core config }`( - @Forgery fakeCoreConfiguration: TelemetryCoreConfiguration, - forge: Forge - ) { - // Given - val configRawEvent = forge.createRumRawTelemetryConfigurationEvent(fakeCoreConfiguration) - - // When - testedTelemetryHandler.handleEvent(configRawEvent, mockWriter) - - // Then - argumentCaptor { - verify(mockWriter).write(eq(mockEventBatchWriter), capture(), eq(EventType.TELEMETRY)) - assertConfigEventMatchesRawEvent(firstValue, configRawEvent, fakeRumContext) - assertThat(firstValue) - .hasUseProxy(fakeCoreConfiguration.useProxy) - .hasUseLocalEncryption(fakeCoreConfiguration.useLocalEncryption) - .hasTrackErrors(fakeCoreConfiguration.trackErrors) - .hasBatchSize(fakeCoreConfiguration.batchSize) - .hasBatchUploadFrequency(fakeCoreConfiguration.batchUploadFrequency) - .hasBatchProcessingLevel(fakeCoreConfiguration.batchProcessingLevel) - } - } - @ParameterizedTest @MethodSource("tracingConfigurationParameters") - fun `M create config event W handleEvent(SendTelemetry) { tracing configuration with tracing settings }`( + fun `M create config event W handleEvent() { tracing configuration with tracing settings }`( useTracer: Boolean, tracerApi: TelemetryEventHandler.TracerApi?, tracerApiVersion: String?, - @Forgery fakeConfiguration: TelemetryCoreConfiguration + @Forgery fakeConfiguration: InternalTelemetryEvent.Configuration ) { // Given - val configRawEvent = forge.createRumRawTelemetryConfigurationEvent(fakeConfiguration) + val configRawEvent = RumRawEvent.TelemetryEventWrapper(fakeConfiguration) if (useTracer) { whenever(mockSdkCore.getFeature(Feature.TRACING_FEATURE_NAME)) doReturn mock() if (tracerApi == TelemetryEventHandler.TracerApi.OpenTracing) { @@ -421,7 +598,12 @@ internal class TelemetryEventHandlerTest { // Then argumentCaptor { verify(mockWriter).write(eq(mockEventBatchWriter), capture(), eq(EventType.TELEMETRY)) - assertConfigEventMatchesRawEvent(firstValue, configRawEvent, fakeRumContext) + assertConfigEventMatchesInternalEvent( + firstValue, + fakeConfiguration, + fakeRumContext, + configRawEvent.eventTime.timestamp + ) assertThat(firstValue) .hasUseTracing(useTracer) .hasTracerApi(tracerApi?.name) @@ -430,25 +612,18 @@ internal class TelemetryEventHandlerTest { } @Test - fun `M create config event W handleEvent(SendTelemetry) { configuration with interceptor }`( - @Forgery fakeConfiguration: TelemetryCoreConfiguration, + fun `M create config event W handleEvent() { configuration with interceptor }`( + @Forgery fakeConfiguration: InternalTelemetryEvent.Configuration, forge: Forge ) { // Given val trackNetworkRequests = forge.aBool() - val configRawEvent = forge.createRumRawTelemetryConfigurationEvent(fakeConfiguration) + val configRawEvent = RumRawEvent.TelemetryEventWrapper(fakeConfiguration) // When if (trackNetworkRequests) { testedTelemetryHandler.handleEvent( - RumRawEvent.SendTelemetry( - TelemetryType.INTERCEPTOR_SETUP, - "", - null, - null, - coreConfiguration = null, - additionalProperties = null - ), + RumRawEvent.TelemetryEventWrapper(InternalTelemetryEvent.InterceptorInstantiated), mockWriter ) } @@ -457,18 +632,23 @@ internal class TelemetryEventHandlerTest { // Then argumentCaptor { verify(mockWriter).write(eq(mockEventBatchWriter), capture(), eq(EventType.TELEMETRY)) - assertConfigEventMatchesRawEvent(firstValue, configRawEvent, fakeRumContext) + assertConfigEventMatchesInternalEvent( + firstValue, + fakeConfiguration, + fakeRumContext, + configRawEvent.eventTime.timestamp + ) assertThat(firstValue) .hasTrackNetworkRequests(trackNetworkRequests) } } @Test - fun `M create config event W handleEvent(SendTelemetry) { configuration, no SessionReplay }`( - forge: Forge + fun `M create config event W handleEvent() { configuration, no SessionReplay }`( + @Forgery fakeConfiguration: InternalTelemetryEvent.Configuration ) { // Given - val configRawEvent = forge.createRumRawTelemetryConfigurationEvent() + val configRawEvent = RumRawEvent.TelemetryEventWrapper(fakeConfiguration) // When testedTelemetryHandler.handleEvent(configRawEvent, mockWriter) @@ -476,30 +656,37 @@ internal class TelemetryEventHandlerTest { // Then argumentCaptor { verify(mockWriter).write(eq(mockEventBatchWriter), capture(), eq(EventType.TELEMETRY)) - assertConfigEventMatchesRawEvent(firstValue, configRawEvent) + assertConfigEventMatchesInternalEvent(firstValue, fakeConfiguration, configRawEvent.eventTime.timestamp) assertThat(firstValue).hasSessionReplaySampleRate(null) - assertThat(firstValue).hasSessionReplayStartManually(null) - assertThat(firstValue).hasSessionReplayPrivacy(null) + assertThat(firstValue).hasStartRecordingImmediately(null) + assertThat(firstValue).hasSessionReplayImagePrivacy(null) + assertThat(firstValue).hasSessionReplayTouchPrivacy(null) + assertThat(firstValue).hasSessionReplayTextAndInputPrivacy(null) } } @Test - fun `M create config event W handleEvent(SendTelemetry) { configuration, with SessionReplay }`( + fun `M create config event W handleEvent() { configuration, with SessionReplay }`( + @Forgery fakeConfiguration: InternalTelemetryEvent.Configuration, forge: Forge ) { // Given val fakeSampleRate = forge.aPositiveLong() - val fakeSessionReplayPrivacy = forge.aString() - val fakeSessionReplayIsStartManually = forge.aBool() + val fakeSessionReplayImagePrivacy = forge.aString() + val fakeSessionReplayTouchPrivacy = forge.aString() + val fakeSessionReplayTextAndInputPrivacy = forge.aString() + val fakeSessionReplayIsStartImmediately = forge.aBool() val fakeSessionReplayContext = mutableMapOf( - TelemetryEventHandler.SESSION_REPLAY_PRIVACY_KEY to fakeSessionReplayPrivacy, - TelemetryEventHandler.SESSION_REPLAY_MANUAL_RECORDING_KEY to - fakeSessionReplayIsStartManually, - TelemetryEventHandler.SESSION_REPLAY_SAMPLE_RATE_KEY to fakeSampleRate + TelemetryEventHandler.SESSION_REPLAY_START_IMMEDIATE_RECORDING_KEY to + fakeSessionReplayIsStartImmediately, + TelemetryEventHandler.SESSION_REPLAY_SAMPLE_RATE_KEY to fakeSampleRate, + TelemetryEventHandler.SESSION_REPLAY_IMAGE_PRIVACY_KEY to fakeSessionReplayImagePrivacy, + TelemetryEventHandler.SESSION_REPLAY_TOUCH_PRIVACY_KEY to fakeSessionReplayTouchPrivacy, + TelemetryEventHandler.SESSION_REPLAY_TEXT_AND_INPUT_PRIVACY_KEY to fakeSessionReplayTextAndInputPrivacy ) whenever(mockSdkCore.getFeatureContext(Feature.SESSION_REPLAY_FEATURE_NAME)) doReturn fakeSessionReplayContext - val configRawEvent = forge.createRumRawTelemetryConfigurationEvent() + val configRawEvent = RumRawEvent.TelemetryEventWrapper(fakeConfiguration) // When testedTelemetryHandler.handleEvent(configRawEvent, mockWriter) @@ -507,30 +694,31 @@ internal class TelemetryEventHandlerTest { // Then argumentCaptor { verify(mockWriter).write(eq(mockEventBatchWriter), capture(), eq(EventType.TELEMETRY)) - assertConfigEventMatchesRawEvent(firstValue, configRawEvent) + assertConfigEventMatchesInternalEvent(firstValue, fakeConfiguration, configRawEvent.eventTime.timestamp) assertThat(firstValue).hasSessionReplaySampleRate(fakeSampleRate) - assertThat(firstValue).hasSessionReplayStartManually(fakeSessionReplayIsStartManually) - assertThat(firstValue).hasSessionReplayPrivacy(fakeSessionReplayPrivacy) + assertThat(firstValue).hasStartRecordingImmediately(fakeSessionReplayIsStartImmediately) + assertThat(firstValue).hasSessionReplayImagePrivacy(fakeSessionReplayImagePrivacy) + assertThat(firstValue).hasSessionReplayTouchPrivacy(fakeSessionReplayTouchPrivacy) + assertThat(firstValue).hasSessionReplayTextAndInputPrivacy(fakeSessionReplayTextAndInputPrivacy) } } @Test fun `M create config event W handleEvent(SendTelemetry) { with SessionReplay, bad format }`( + @Forgery fakeConfiguration: InternalTelemetryEvent.Configuration, forge: Forge ) { // Given val fakeSampleRate = forge.aNullable { aString() } - val fakeSessionReplayPrivacy = forge.aNullable { aLong() } - val fakeSessionReplayIsStartManually = forge.aNullable { aString() } + val fakeSessionReplayIsStartedImmediatley = forge.aNullable { aString() } val fakeSessionReplayContext = mutableMapOf( - TelemetryEventHandler.SESSION_REPLAY_PRIVACY_KEY to fakeSessionReplayPrivacy, - TelemetryEventHandler.SESSION_REPLAY_MANUAL_RECORDING_KEY to - fakeSessionReplayIsStartManually, + TelemetryEventHandler.SESSION_REPLAY_START_IMMEDIATE_RECORDING_KEY to + fakeSessionReplayIsStartedImmediatley, TelemetryEventHandler.SESSION_REPLAY_SAMPLE_RATE_KEY to fakeSampleRate ) whenever(mockSdkCore.getFeatureContext(Feature.SESSION_REPLAY_FEATURE_NAME)) doReturn fakeSessionReplayContext - val configRawEvent = forge.createRumRawTelemetryConfigurationEvent() + val configRawEvent = RumRawEvent.TelemetryEventWrapper(fakeConfiguration) // When testedTelemetryHandler.handleEvent(configRawEvent, mockWriter) @@ -538,10 +726,12 @@ internal class TelemetryEventHandlerTest { // Then argumentCaptor { verify(mockWriter).write(eq(mockEventBatchWriter), capture(), eq(EventType.TELEMETRY)) - assertConfigEventMatchesRawEvent(firstValue, configRawEvent) + assertConfigEventMatchesInternalEvent(firstValue, fakeConfiguration, configRawEvent.eventTime.timestamp) assertThat(firstValue).hasSessionReplaySampleRate(null) - assertThat(firstValue).hasSessionReplayStartManually(null) - assertThat(firstValue).hasSessionReplayPrivacy(null) + assertThat(firstValue).hasStartRecordingImmediately(null) + assertThat(firstValue).hasSessionReplayImagePrivacy(null) + assertThat(firstValue).hasSessionReplayTouchPrivacy(null) + assertThat(firstValue).hasSessionReplayTextAndInputPrivacy(null) } } @@ -550,9 +740,10 @@ internal class TelemetryEventHandlerTest { // region Sampling @Test - fun `M not write event W handleEvent(SendTelemetry) { event is not sampled }`(forge: Forge) { + fun `M not write event W handleEvent() { event is not sampled }`(forge: Forge) { // Given - val rawEvent = forge.createRumRawTelemetryEvent() + val fakeInternalTelemetryEvent = forge.forgeWritableInternalTelemetryEvent() + val rawEvent = RumRawEvent.TelemetryEventWrapper(fakeInternalTelemetryEvent) whenever(mockSampler.sample()) doReturn false // When @@ -563,14 +754,15 @@ internal class TelemetryEventHandlerTest { } @Test - fun `M write debug&error event W handleEvent(SendTelemetry) { configuration sampler returns false }`( + fun `M write debug&error event W handleEvent() { log events, configuration sampler returns false }`( forge: Forge ) { // Given - val rawEvent = forge.anElementFrom( - forge.createRumRawTelemetryDebugEvent(), - forge.createRumRawTelemetryErrorEvent() + val logeEvent = forge.anElementFrom( + forge.getForgery(), + forge.getForgery() ) + val rawEvent = RumRawEvent.TelemetryEventWrapper(logeEvent) whenever(mockSampler.sample()) doReturn true whenever(mockConfigurationSampler.sample()) doReturn false @@ -580,7 +772,7 @@ internal class TelemetryEventHandlerTest { // Then argumentCaptor { verify(mockWriter).write(eq(mockEventBatchWriter), capture(), eq(EventType.TELEMETRY)) - if (rawEvent.type == TelemetryType.DEBUG) { + if (logeEvent is InternalTelemetryEvent.Log.Debug) { assertThat(lastValue).isInstanceOf(TelemetryDebugEvent::class.java) } else { assertThat(lastValue).isInstanceOf(TelemetryErrorEvent::class.java) @@ -589,11 +781,11 @@ internal class TelemetryEventHandlerTest { } @Test - fun `M not write configuration event W handleEvent(SendTelemetry) { event is not sampled }`( - forge: Forge + fun `M not write configuration event W handleEvent() { event is not sampled }`( + @Forgery fakeConfiguration: InternalTelemetryEvent.Configuration ) { // Given - val rawEvent = forge.createRumRawTelemetryConfigurationEvent() + val rawEvent = RumRawEvent.TelemetryEventWrapper(fakeConfiguration) whenever(mockSampler.sample()) doReturn true whenever(mockConfigurationSampler.sample()) doReturn false @@ -605,11 +797,15 @@ internal class TelemetryEventHandlerTest { } @Test - fun `M not write event W handleEvent(SendTelemetry){seen in the session, not metric}`( + fun `M not write event W handleEvent(){ seen in the session, log event }`( forge: Forge ) { // Given - val rawEvent = forge.createRumRawTelemetryEvent().copy(isMetric = false) + val internalTelemetryEvent = forge.anElementFrom( + forge.getForgery(), + forge.getForgery() + ) + val rawEvent = RumRawEvent.TelemetryEventWrapper(internalTelemetryEvent) val anotherEvent = rawEvent.copy() // When @@ -622,11 +818,7 @@ internal class TelemetryEventHandlerTest { InternalLogger.Target.MAINTAINER, TelemetryEventHandler.ALREADY_SEEN_EVENT_MESSAGE.format( Locale.US, - TelemetryEventId( - rawEvent.type, - rawEvent.message, - rawEvent.kind - ) + internalTelemetryEvent.identity ) ) @@ -634,15 +826,21 @@ internal class TelemetryEventHandlerTest { verify(mockWriter).write(eq(mockEventBatchWriter), capture(), eq(EventType.TELEMETRY)) when (val capturedValue = lastValue) { is TelemetryDebugEvent -> { - assertDebugEventMatchesRawEvent(capturedValue, rawEvent, fakeRumContext) + assertDebugEventMatchesInternalEvent( + capturedValue, + internalTelemetryEvent as InternalTelemetryEvent.Log.Debug, + fakeRumContext, + rawEvent.eventTime.timestamp + ) } is TelemetryErrorEvent -> { - assertErrorEventMatchesRawEvent(capturedValue, rawEvent, fakeRumContext) - } - - is TelemetryConfigurationEvent -> { - assertConfigEventMatchesRawEvent(capturedValue, rawEvent, fakeRumContext) + assertErrorEventMatchesInternalEvent( + capturedValue, + internalTelemetryEvent as InternalTelemetryEvent.Log.Error, + fakeRumContext, + rawEvent.eventTime.timestamp + ) } else -> throw IllegalArgumentException( @@ -654,12 +852,12 @@ internal class TelemetryEventHandlerTest { } @Test - fun `M write event W handleEvent(SendTelemetry){seen in the session, is metric}`( - forge: Forge + fun `M write event W handleEvent(){ seen in the session, is metric }`( + @Forgery fakeMetricEvent: InternalTelemetryEvent.Metric ) { // Given - val rawEvent = forge.createRumRawTelemetryEvent().copy(isMetric = true) - val events = listOf(rawEvent, rawEvent.copy()) + val rawEvent = RumRawEvent.TelemetryEventWrapper(fakeMetricEvent) + val events = listOf(rawEvent, RumRawEvent.TelemetryEventWrapper(fakeMetricEvent)) // When testedTelemetryHandler.handleEvent(events[0], mockWriter) @@ -668,57 +866,30 @@ internal class TelemetryEventHandlerTest { // Then argumentCaptor { verify(mockWriter, times(2)).write(eq(mockEventBatchWriter), capture(), eq(EventType.TELEMETRY)) - allValues.withIndex().forEach { - when (val capturedValue = it.value) { - is TelemetryDebugEvent -> { - assertDebugEventMatchesRawEvent( - capturedValue, - events[it.index], - fakeRumContext - ) - } - - is TelemetryErrorEvent -> { - assertErrorEventMatchesRawEvent( - capturedValue, - events[it.index], - fakeRumContext - ) - } - - is TelemetryConfigurationEvent -> { - assertConfigEventMatchesRawEvent( - capturedValue, - events[it.index], - fakeRumContext - ) - } - - else -> throw IllegalArgumentException( - "Unexpected type=${lastValue::class.jvmName} of the captured value." - ) - } - } + assertDebugEventMatchesMetricInternalEvent( + firstValue as TelemetryDebugEvent, + fakeMetricEvent, + fakeRumContext, + events[0].eventTime.timestamp + ) + assertDebugEventMatchesMetricInternalEvent( + secondValue as TelemetryDebugEvent, + fakeMetricEvent, + fakeRumContext, + events[1].eventTime.timestamp + ) } } @Test - fun `M not write events over the limit W handleEvent(SendTelemetry)`() { - // Given - val event = RumRawEvent.SendTelemetry( - TelemetryType.DEBUG, - "Metric event", - null, - null, - coreConfiguration = null, - additionalProperties = null, - isMetric = true - ) - val events = (0..MAX_EVENTS_PER_SESSION_TEST).map { event } + fun `M not write events over the limit W handleEvent() { metric event }`( + forge: Forge + ) { + val events = (0..MAX_EVENTS_PER_SESSION_TEST).map { forge.getForgery() } // When events.forEach { - testedTelemetryHandler.handleEvent(it, mockWriter) + testedTelemetryHandler.handleEvent(RumRawEvent.TelemetryEventWrapper(it), mockWriter) } // Then @@ -730,19 +901,15 @@ internal class TelemetryEventHandlerTest { } @Test - fun `M continue writing events after new session W handleEvent(SendTelemetry)`(forge: Forge) { + fun `M continue writing events after new session W handleEvent() { metric event }`(forge: Forge) { // Given - val event = RumRawEvent.SendTelemetry( - TelemetryType.DEBUG, - "Metric event", - null, - null, - coreConfiguration = null, - additionalProperties = null, - isMetric = true // important because non-metric events can only be seen once - ) - val eventsInOldSession = (0..MAX_EVENTS_PER_SESSION_TEST / 2).map { event } - val eventsInNewSession = (0..MAX_EVENTS_PER_SESSION_TEST / 2).map { event } + // important because non-metric events can only be seen once + val eventsInOldSession = (0..MAX_EVENTS_PER_SESSION_TEST / 2).map { + forge.getForgery() + }.map { RumRawEvent.TelemetryEventWrapper(it) } + val eventsInNewSession = (0..MAX_EVENTS_PER_SESSION_TEST / 2).map { + forge.getForgery() + }.map { RumRawEvent.TelemetryEventWrapper(it) } eventsInOldSession.forEach { testedTelemetryHandler.handleEvent(it, mockWriter) @@ -760,7 +927,7 @@ internal class TelemetryEventHandlerTest { } @Test - fun `M count the limit only after the sampling W handleEvent(SendTelemetry)`(forge: Forge) { + fun `M count the limit only after the sampling W handleEvent()`(forge: Forge) { // Given // sample out 50% whenever(mockSampler.sample()) doAnswer object : Answer { @@ -773,10 +940,10 @@ internal class TelemetryEventHandlerTest { val events = forge.aList( size = MAX_EVENTS_PER_SESSION_TEST * 10 - ) { createRumRawTelemetryEvent() } + ) { forge.forgeWritableInternalTelemetryEvent() } // remove unwanted identity collisions .groupBy { it.identity } - .map { it.value.first() } + .map { RumRawEvent.TelemetryEventWrapper(it.value.first()) } .take(MAX_EVENTS_PER_SESSION_TEST * 2) assumeTrue(events.size == MAX_EVENTS_PER_SESSION_TEST * 2) @@ -793,26 +960,173 @@ internal class TelemetryEventHandlerTest { verifyNoInteractions(mockInternalLogger) } - // endregion + @Test + fun `M write event W handleEvent(){ consecutive error events, same message, different kind }`( + forge: Forge + ) { + // Given + val fakeMessage = forge.aString() + val fakeThrowable = forge.aThrowable() + val fakeLogErrorEventNoKindNoThrowable = InternalTelemetryEvent.Log.Error( + message = fakeMessage, + additionalProperties = forge.aMap { aString() to aString() }, + error = null, + stacktrace = null, + kind = null + ) + val fakeLogErrorEventNoKindWithThrowable = InternalTelemetryEvent.Log.Error( + message = fakeMessage, + additionalProperties = forge.aMap { aString() to aString() }, + error = fakeThrowable, + stacktrace = null, + kind = null + ) + val events = listOf( + RumRawEvent.TelemetryEventWrapper(fakeLogErrorEventNoKindNoThrowable), + RumRawEvent.TelemetryEventWrapper(fakeLogErrorEventNoKindWithThrowable) + ) + + // When + testedTelemetryHandler.handleEvent(events[0], mockWriter) + testedTelemetryHandler.handleEvent(events[1], mockWriter) + + // Then + argumentCaptor { + verify(mockWriter, times(2)).write(eq(mockEventBatchWriter), capture(), eq(EventType.TELEMETRY)) + assertErrorEventMatchesInternalEvent( + firstValue as TelemetryErrorEvent, + fakeLogErrorEventNoKindNoThrowable, + fakeRumContext, + events[0].eventTime.timestamp + ) + assertErrorEventMatchesInternalEvent( + secondValue as TelemetryErrorEvent, + fakeLogErrorEventNoKindWithThrowable, + fakeRumContext, + events[0].eventTime.timestamp + ) + } + } + +// endregion +// region Api Usage + + @Test + fun `M create api usage event W handleEvent(api usage event)`( + @Forgery fakeApiUsageEvent: InternalTelemetryEvent.ApiUsage + ) { + // Given + val fakeWrappedEvent = RumRawEvent.TelemetryEventWrapper(fakeApiUsageEvent) + + // When + testedTelemetryHandler.handleEvent(fakeWrappedEvent, mockWriter) + + // Then + argumentCaptor { + verify(mockWriter).write(eq(mockEventBatchWriter), capture(), eq(EventType.TELEMETRY)) + assertApiUsageMatchesInternalEvent( + lastValue, + fakeApiUsageEvent, + fakeRumContext, + fakeWrappedEvent.eventTime.timestamp + ) + } + } + + @Test + fun `M write event W handleEvent(){ seen in the session, is api usage }`( + @Forgery fakeApiUsageEvent: InternalTelemetryEvent.ApiUsage + ) { + // Given + val rawEvent = RumRawEvent.TelemetryEventWrapper(fakeApiUsageEvent) + val events = listOf(rawEvent, RumRawEvent.TelemetryEventWrapper(fakeApiUsageEvent)) + + // When + testedTelemetryHandler.handleEvent(events[0], mockWriter) + testedTelemetryHandler.handleEvent(events[1], mockWriter) + + // Then + argumentCaptor { + verify(mockWriter, times(2)).write(eq(mockEventBatchWriter), capture(), eq(EventType.TELEMETRY)) + assertApiUsageMatchesInternalEvent( + firstValue as TelemetryUsageEvent, + fakeApiUsageEvent, + fakeRumContext, + rawEvent.eventTime.timestamp + ) + assertApiUsageMatchesInternalEvent( + secondValue as TelemetryUsageEvent, + fakeApiUsageEvent, + fakeRumContext, + rawEvent.eventTime.timestamp + ) + } + } + + @Test + fun `M not write events over the limit W handleEvent() { api usage event }`( + forge: Forge + ) { + val events = (0..MAX_EVENTS_PER_SESSION_TEST).map { forge.getForgery() } + + // When + events.forEach { + testedTelemetryHandler.handleEvent(RumRawEvent.TelemetryEventWrapper(it), mockWriter) + } + + // Then + mockInternalLogger.verifyLog( + level = InternalLogger.Level.INFO, + target = InternalLogger.Target.MAINTAINER, + message = TelemetryEventHandler.MAX_EVENT_NUMBER_REACHED_MESSAGE + ) + } +// endregion + +// region Assertions - // region Assertions + private fun assertApiUsageMatchesInternalEvent( + actual: TelemetryUsageEvent, + internalUsageEvent: InternalTelemetryEvent.ApiUsage, + rumContext: RumContext, + time: Long + ) { + assertThat(actual) + .hasDate(time + fakeServerOffset) + .hasSource(TelemetryUsageEvent.Source.ANDROID) + .hasService(TelemetryEventHandler.TELEMETRY_SERVICE_NAME) + .hasVersion(fakeDatadogContext.sdkVersion) + .hasApplicationId(rumContext.applicationId) + .hasSessionId(rumContext.sessionId) + .hasViewId(rumContext.viewId) + .hasActionId(rumContext.actionId) + .hasAdditionalProperties(internalUsageEvent.additionalProperties) + .hasDeviceArchitecture(fakeDeviceArchitecture) + .hasDeviceBrand(fakeDeviceBrand) + .hasDeviceModel(fakeDeviceModel) + .hasOsBuild(fakeOsBuildId) + .hasOsName(fakeOsName) + .hasOsVersion(fakeOsVersion) + .hasUsage(internalUsageEvent) + } - private fun assertDebugEventMatchesRawEvent( + private fun assertDebugEventMatchesInternalEvent( actual: TelemetryDebugEvent, - rawEvent: RumRawEvent.SendTelemetry, - rumContext: RumContext + internalDebugEvent: InternalTelemetryEvent.Log.Debug, + rumContext: RumContext, + time: Long ) { assertThat(actual) - .hasDate(rawEvent.eventTime.timestamp + fakeServerOffset) + .hasDate(time + fakeServerOffset) .hasSource(TelemetryDebugEvent.Source.ANDROID) - .hasMessage(rawEvent.message) + .hasMessage(internalDebugEvent.message) .hasService(TelemetryEventHandler.TELEMETRY_SERVICE_NAME) .hasVersion(fakeDatadogContext.sdkVersion) .hasApplicationId(rumContext.applicationId) .hasSessionId(rumContext.sessionId) .hasViewId(rumContext.viewId) .hasActionId(rumContext.actionId) - .hasAdditionalProperties(rawEvent.additionalProperties ?: emptyMap()) + .hasAdditionalProperties(internalDebugEvent.additionalProperties ?: emptyMap()) .hasDeviceArchitecture(fakeDeviceArchitecture) .hasDeviceBrand(fakeDeviceBrand) .hasDeviceModel(fakeDeviceModel) @@ -821,39 +1135,86 @@ internal class TelemetryEventHandlerTest { .hasOsVersion(fakeOsVersion) } - private fun assertErrorEventMatchesRawEvent( + private fun assertDebugEventMatchesMetricInternalEvent( + actual: TelemetryDebugEvent, + internalMetricEvent: InternalTelemetryEvent.Metric, + rumContext: RumContext, + time: Long + ) { + assertThat(actual) + .hasDate(time + fakeServerOffset) + .hasSource(TelemetryDebugEvent.Source.ANDROID) + .hasMessage(internalMetricEvent.message) + .hasService(TelemetryEventHandler.TELEMETRY_SERVICE_NAME) + .hasVersion(fakeDatadogContext.sdkVersion) + .hasApplicationId(rumContext.applicationId) + .hasSessionId(rumContext.sessionId) + .hasViewId(rumContext.viewId) + .hasActionId(rumContext.actionId) + .hasAdditionalProperties(internalMetricEvent.additionalProperties ?: emptyMap()) + .hasDeviceArchitecture(fakeDeviceArchitecture) + .hasDeviceBrand(fakeDeviceBrand) + .hasDeviceModel(fakeDeviceModel) + .hasOsBuild(fakeOsBuildId) + .hasOsName(fakeOsName) + .hasOsVersion(fakeOsVersion) + } + + private fun assertErrorEventMatchesInternalEvent( actual: TelemetryErrorEvent, - rawEvent: RumRawEvent.SendTelemetry, - rumContext: RumContext + internalErrorEvent: InternalTelemetryEvent.Log.Error, + rumContext: RumContext, + time: Long, + stacktrace: String?, + kind: String? ) { assertThat(actual) - .hasDate(rawEvent.eventTime.timestamp + fakeServerOffset) + .hasDate(time + fakeServerOffset) .hasSource(TelemetryErrorEvent.Source.ANDROID) - .hasMessage(rawEvent.message) + .hasMessage(internalErrorEvent.message) .hasService(TelemetryEventHandler.TELEMETRY_SERVICE_NAME) .hasVersion(fakeDatadogContext.sdkVersion) .hasApplicationId(rumContext.applicationId) .hasSessionId(rumContext.sessionId) .hasViewId(rumContext.viewId) .hasActionId(rumContext.actionId) - .hasErrorStack(rawEvent.stack) - .hasErrorKind(rawEvent.kind) + .hasErrorStack(stacktrace) + .hasErrorKind(kind) .hasDeviceArchitecture(fakeDeviceArchitecture) .hasDeviceBrand(fakeDeviceBrand) .hasDeviceModel(fakeDeviceModel) .hasOsBuild(fakeOsBuildId) .hasOsName(fakeOsName) .hasOsVersion(fakeOsVersion) - .hasAdditionalProperties(rawEvent.additionalProperties ?: emptyMap()) + .hasAdditionalProperties(internalErrorEvent.additionalProperties ?: emptyMap()) } - private fun assertConfigEventMatchesRawEvent( + private fun assertErrorEventMatchesInternalEvent( + actual: TelemetryErrorEvent, + internalErrorEvent: InternalTelemetryEvent.Log.Error, + rumContext: RumContext, + time: Long + ) { + val expectedStacktrace = internalErrorEvent.resolveStacktrace() + val expectedKind = internalErrorEvent.resolveKind() + assertErrorEventMatchesInternalEvent( + actual, + internalErrorEvent, + rumContext, + time, + expectedStacktrace, + expectedKind + ) + } + + private fun assertConfigEventMatchesInternalEvent( actual: TelemetryConfigurationEvent, - rawEvent: RumRawEvent.SendTelemetry, - rumContext: RumContext + internalConfigurationEvent: InternalTelemetryEvent.Configuration, + rumContext: RumContext, + time: Long ) { assertThat(actual) - .hasDate(rawEvent.eventTime.timestamp + fakeServerOffset) + .hasDate(time + fakeServerOffset) .hasSource(TelemetryConfigurationEvent.Source.ANDROID) .hasService(TelemetryEventHandler.TELEMETRY_SERVICE_NAME) .hasVersion(fakeDatadogContext.sdkVersion) @@ -861,71 +1222,42 @@ internal class TelemetryEventHandlerTest { .hasSessionId(rumContext.sessionId) .hasViewId(rumContext.viewId) .hasActionId(rumContext.actionId) + .hasBatchSize(internalConfigurationEvent.batchSize) + .hasBatchUploadFrequency(internalConfigurationEvent.batchUploadFrequency) + .hasBatchProcessingLevel(internalConfigurationEvent.batchProcessingLevel) + .hasTrackErrors(internalConfigurationEvent.trackErrors) + .hasUseProxy(internalConfigurationEvent.useProxy) + .hasUseLocalEncryption(internalConfigurationEvent.useLocalEncryption) } - private fun assertConfigEventMatchesRawEvent( + private fun assertConfigEventMatchesInternalEvent( actual: TelemetryConfigurationEvent, - rawEvent: RumRawEvent.SendTelemetry + internalConfigurationEvent: InternalTelemetryEvent.Configuration, + time: Long ) { assertThat(actual) - .hasDate(rawEvent.eventTime.timestamp + fakeServerOffset) + .hasDate(time + fakeServerOffset) .hasSource(TelemetryConfigurationEvent.Source.ANDROID) .hasService(TelemetryEventHandler.TELEMETRY_SERVICE_NAME) .hasVersion(fakeDatadogContext.sdkVersion) + .hasBatchSize(internalConfigurationEvent.batchSize) + .hasBatchUploadFrequency(internalConfigurationEvent.batchUploadFrequency) + .hasBatchProcessingLevel(internalConfigurationEvent.batchProcessingLevel) + .hasTrackErrors(internalConfigurationEvent.trackErrors) + .hasUseProxy(internalConfigurationEvent.useProxy) + .hasUseLocalEncryption(internalConfigurationEvent.useLocalEncryption) } - // endregion - - // region Forgeries - - private fun Forge.createRumRawTelemetryEvent(): RumRawEvent.SendTelemetry { + private fun Forge.forgeWritableInternalTelemetryEvent(): InternalTelemetryEvent { return anElementFrom( - createRumRawTelemetryDebugEvent(), - createRumRawTelemetryErrorEvent(), - createRumRawTelemetryConfigurationEvent() - ) - } - - private fun Forge.createRumRawTelemetryDebugEvent(): RumRawEvent.SendTelemetry { - return RumRawEvent.SendTelemetry( - TelemetryType.DEBUG, - aString(), - null, - null, - coreConfiguration = null, - additionalProperties = aNullable { exhaustiveAttributes() }, - isMetric = aBool() + getForgery(), + getForgery(), + getForgery(), + getForgery() ) } - private fun Forge.createRumRawTelemetryErrorEvent(): RumRawEvent.SendTelemetry { - val throwable = aNullable { aThrowable() } - return RumRawEvent.SendTelemetry( - TelemetryType.ERROR, - aString(), - throwable?.loggableStackTrace(), - throwable?.javaClass?.canonicalName, - coreConfiguration = null, - additionalProperties = aNullable { exhaustiveAttributes() }, - isMetric = aBool() - ) - } - - private fun Forge.createRumRawTelemetryConfigurationEvent( - configuration: TelemetryCoreConfiguration? = null - ): RumRawEvent.SendTelemetry { - return RumRawEvent.SendTelemetry( - TelemetryType.CONFIGURATION, - "", - null, - null, - coreConfiguration = (configuration ?: getForgery()), - additionalProperties = null, - isMetric = aBool() - ) - } - - // endregion +// endregion companion object { diff --git a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/telemetry/internal/TelemetryTest.kt b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/telemetry/internal/TelemetryTest.kt deleted file mode 100644 index 783505680d..0000000000 --- a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/telemetry/internal/TelemetryTest.kt +++ /dev/null @@ -1,104 +0,0 @@ -/* - * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. - * This product includes software developed at Datadog (https://www.datadoghq.com/). - * Copyright 2016-Present Datadog, Inc. - */ - -package com.datadog.android.telemetry.internal - -import com.datadog.android.rum.internal.monitor.AdvancedRumMonitor -import com.datadog.android.rum.utils.config.GlobalRumMonitorTestConfiguration -import com.datadog.android.rum.utils.forge.Configurator -import com.datadog.tools.unit.annotations.TestConfigurationsProvider -import com.datadog.tools.unit.extensions.TestConfigurationExtension -import com.datadog.tools.unit.extensions.config.TestConfiguration -import com.datadog.tools.unit.forge.aThrowable -import com.datadog.tools.unit.forge.exhaustiveAttributes -import fr.xgouchet.elmyr.Forge -import fr.xgouchet.elmyr.annotation.StringForgery -import fr.xgouchet.elmyr.junit5.ForgeConfiguration -import fr.xgouchet.elmyr.junit5.ForgeExtension -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.extension.ExtendWith -import org.junit.jupiter.api.extension.Extensions -import org.mockito.junit.jupiter.MockitoExtension -import org.mockito.junit.jupiter.MockitoSettings -import org.mockito.kotlin.verify -import org.mockito.quality.Strictness - -@Extensions( - ExtendWith(MockitoExtension::class), - ExtendWith(ForgeExtension::class), - ExtendWith(TestConfigurationExtension::class) -) -@MockitoSettings(strictness = Strictness.LENIENT) -@ForgeConfiguration(Configurator::class) -internal class TelemetryTest { - - private lateinit var testedTelemetry: Telemetry - - @BeforeEach - fun `set up`() { - testedTelemetry = Telemetry(rumMonitor.mockSdkCore) - } - - @Test - fun `M report error event W error()`( - @StringForgery message: String, - forge: Forge - ) { - // Given - val throwable = forge.aNullable { forge.aThrowable() } - val fakeAdditionalProperties = forge.aNullable { exhaustiveAttributes() } - - // When - testedTelemetry.error(message, throwable, fakeAdditionalProperties) - - // Then - verify(rumMonitor.mockInstance as AdvancedRumMonitor) - .sendErrorTelemetryEvent(message, throwable, fakeAdditionalProperties) - } - - @Test - fun `M report debug event W debug()`( - @StringForgery message: String, - forge: Forge - ) { - // Given - val fakeAdditionalProperties = forge.aNullable { exhaustiveAttributes() } - - // When - testedTelemetry.debug(message, fakeAdditionalProperties) - - // Then - verify(rumMonitor.mockInstance as AdvancedRumMonitor) - .sendDebugTelemetryEvent(message, fakeAdditionalProperties) - } - - @Test - fun `M report metric event W metric()`( - @StringForgery message: String, - forge: Forge - ) { - // Given - val fakeAdditionalProperties = forge.aNullable { exhaustiveAttributes() } - - // When - testedTelemetry.metric(message, fakeAdditionalProperties) - - // Then - verify(rumMonitor.mockInstance as AdvancedRumMonitor) - .sendMetricEvent(message, fakeAdditionalProperties) - } - - companion object { - val rumMonitor = GlobalRumMonitorTestConfiguration() - - @TestConfigurationsProvider - @JvmStatic - fun getTestConfigurations(): List { - return listOf(rumMonitor) - } - } -} diff --git a/features/dd-sdk-android-session-replay-compose/api/apiSurface b/features/dd-sdk-android-session-replay-compose/api/apiSurface index 6675fb63c4..c20033a850 100644 --- a/features/dd-sdk-android-session-replay-compose/api/apiSurface +++ b/features/dd-sdk-android-session-replay-compose/api/apiSurface @@ -1,3 +1,4 @@ class com.datadog.android.sessionreplay.compose.ComposeExtensionSupport : com.datadog.android.sessionreplay.ExtensionSupport override fun getCustomViewMappers(): List> override fun getOptionSelectorDetectors(): List + override fun getCustomDrawableMapper(): List diff --git a/features/dd-sdk-android-session-replay-compose/api/dd-sdk-android-session-replay-compose.api b/features/dd-sdk-android-session-replay-compose/api/dd-sdk-android-session-replay-compose.api index 419db28518..c009bfe501 100644 --- a/features/dd-sdk-android-session-replay-compose/api/dd-sdk-android-session-replay-compose.api +++ b/features/dd-sdk-android-session-replay-compose/api/dd-sdk-android-session-replay-compose.api @@ -1,6 +1,7 @@ public final class com/datadog/android/sessionreplay/compose/ComposeExtensionSupport : com/datadog/android/sessionreplay/ExtensionSupport { public static final field $stable I public fun ()V + public fun getCustomDrawableMapper ()Ljava/util/List; public fun getCustomViewMappers ()Ljava/util/List; public fun getOptionSelectorDetectors ()Ljava/util/List; } diff --git a/features/dd-sdk-android-session-replay-compose/src/main/kotlin/com/datadog/android/sessionreplay/compose/ComposeExtensionSupport.kt b/features/dd-sdk-android-session-replay-compose/src/main/kotlin/com/datadog/android/sessionreplay/compose/ComposeExtensionSupport.kt index a33292578d..077c066fda 100644 --- a/features/dd-sdk-android-session-replay-compose/src/main/kotlin/com/datadog/android/sessionreplay/compose/ComposeExtensionSupport.kt +++ b/features/dd-sdk-android-session-replay-compose/src/main/kotlin/com/datadog/android/sessionreplay/compose/ComposeExtensionSupport.kt @@ -47,4 +47,8 @@ class ComposeExtensionSupport : ExtensionSupport { override fun getOptionSelectorDetectors(): List { return emptyList() } + + override fun getCustomDrawableMapper(): List { + return emptyList() + } } diff --git a/features/dd-sdk-android-session-replay-compose/src/main/kotlin/com/datadog/android/sessionreplay/compose/internal/mappers/ComposeWireframeMapper.kt b/features/dd-sdk-android-session-replay-compose/src/main/kotlin/com/datadog/android/sessionreplay/compose/internal/mappers/ComposeWireframeMapper.kt index 3411744492..67645893a8 100644 --- a/features/dd-sdk-android-session-replay-compose/src/main/kotlin/com/datadog/android/sessionreplay/compose/internal/mappers/ComposeWireframeMapper.kt +++ b/features/dd-sdk-android-session-replay-compose/src/main/kotlin/com/datadog/android/sessionreplay/compose/internal/mappers/ComposeWireframeMapper.kt @@ -63,7 +63,8 @@ internal class ComposeWireframeMapper( internalLogger: InternalLogger ): List { val density = mappingContext.systemInformation.screenDensity.let { if (it == 0.0f) 1.0f else it } - val privacy = mappingContext.privacy + // TODO RUM 6192: Apply FGM for compose + val privacy = SessionReplayPrivacy.ALLOW val composer = findComposer(view) return if (composer == null) { createPlaceholderWireframe(view, density) diff --git a/features/dd-sdk-android-session-replay-compose/src/main/kotlin/com/datadog/android/sessionreplay/compose/internal/mappers/semantics/SemanticsWireframeMapper.kt b/features/dd-sdk-android-session-replay-compose/src/main/kotlin/com/datadog/android/sessionreplay/compose/internal/mappers/semantics/SemanticsWireframeMapper.kt index f408f451b1..61b6f7e8de 100644 --- a/features/dd-sdk-android-session-replay-compose/src/main/kotlin/com/datadog/android/sessionreplay/compose/internal/mappers/semantics/SemanticsWireframeMapper.kt +++ b/features/dd-sdk-android-session-replay-compose/src/main/kotlin/com/datadog/android/sessionreplay/compose/internal/mappers/semantics/SemanticsWireframeMapper.kt @@ -50,7 +50,8 @@ internal class SemanticsWireframeMapper( internalLogger: InternalLogger ): List { val density = mappingContext.systemInformation.screenDensity.let { if (it == 0.0f) 1.0f else it } - val privacy = mappingContext.privacy + // TODO RUM 6192: Apply FGM for compose + val privacy = SessionReplayPrivacy.ALLOW return semanticsUtils.findRootSemanticsNode(view)?.let { node -> createComposeWireframes(node, density, mappingContext, privacy, asyncJobStatusCallback) } ?: emptyList() diff --git a/features/dd-sdk-android-session-replay-compose/src/test/kotlin/com/datadog/android/sessionreplay/compose/test/elmyr/MappingContextForgeryFactory.kt b/features/dd-sdk-android-session-replay-compose/src/test/kotlin/com/datadog/android/sessionreplay/compose/test/elmyr/MappingContextForgeryFactory.kt index 403ea7e830..df1a3d91ec 100644 --- a/features/dd-sdk-android-session-replay-compose/src/test/kotlin/com/datadog/android/sessionreplay/compose/test/elmyr/MappingContextForgeryFactory.kt +++ b/features/dd-sdk-android-session-replay-compose/src/test/kotlin/com/datadog/android/sessionreplay/compose/test/elmyr/MappingContextForgeryFactory.kt @@ -17,8 +17,8 @@ internal class MappingContextForgeryFactory : ForgeryFactory { systemInformation = forge.getForgery(), imageWireframeHelper = mock(), hasOptionSelectorParent = forge.aBool(), - privacy = forge.getForgery(), - imagePrivacy = forge.getForgery() + imagePrivacy = forge.getForgery(), + textAndInputPrivacy = forge.getForgery() ) } } diff --git a/features/dd-sdk-android-session-replay-material/api/apiSurface b/features/dd-sdk-android-session-replay-material/api/apiSurface index f97c852a3c..51917ca056 100644 --- a/features/dd-sdk-android-session-replay-material/api/apiSurface +++ b/features/dd-sdk-android-session-replay-material/api/apiSurface @@ -1,3 +1,4 @@ class com.datadog.android.sessionreplay.material.MaterialExtensionSupport : com.datadog.android.sessionreplay.ExtensionSupport override fun getCustomViewMappers(): List> override fun getOptionSelectorDetectors(): List + override fun getCustomDrawableMapper(): List diff --git a/features/dd-sdk-android-session-replay-material/api/dd-sdk-android-session-replay-material.api b/features/dd-sdk-android-session-replay-material/api/dd-sdk-android-session-replay-material.api index ebb73f44ac..7717fbf85c 100644 --- a/features/dd-sdk-android-session-replay-material/api/dd-sdk-android-session-replay-material.api +++ b/features/dd-sdk-android-session-replay-material/api/dd-sdk-android-session-replay-material.api @@ -1,5 +1,6 @@ public final class com/datadog/android/sessionreplay/material/MaterialExtensionSupport : com/datadog/android/sessionreplay/ExtensionSupport { public fun ()V + public fun getCustomDrawableMapper ()Ljava/util/List; public fun getCustomViewMappers ()Ljava/util/List; public fun getOptionSelectorDetectors ()Ljava/util/List; } diff --git a/features/dd-sdk-android-session-replay-material/build.gradle.kts b/features/dd-sdk-android-session-replay-material/build.gradle.kts index cbafe97b61..48fae71501 100644 --- a/features/dd-sdk-android-session-replay-material/build.gradle.kts +++ b/features/dd-sdk-android-session-replay-material/build.gradle.kts @@ -6,6 +6,7 @@ import com.datadog.gradle.config.androidLibraryConfig import com.datadog.gradle.config.dependencyUpdateConfig +import com.datadog.gradle.config.detektCustomConfig import com.datadog.gradle.config.javadocConfig import com.datadog.gradle.config.junitConfig import com.datadog.gradle.config.kotlinConfig @@ -69,3 +70,4 @@ dependencyUpdateConfig() publishingConfig( "Session Replay Extension Support for Material UI components." ) +detektCustomConfig(":dd-sdk-android-core", ":dd-sdk-android-internal", ":features:dd-sdk-android-session-replay") diff --git a/features/dd-sdk-android-session-replay-material/src/main/kotlin/com/datadog/android/sessionreplay/material/MaterialExtensionSupport.kt b/features/dd-sdk-android-session-replay-material/src/main/kotlin/com/datadog/android/sessionreplay/material/MaterialExtensionSupport.kt index 1061a614a9..3ad195a3b7 100644 --- a/features/dd-sdk-android-session-replay-material/src/main/kotlin/com/datadog/android/sessionreplay/material/MaterialExtensionSupport.kt +++ b/features/dd-sdk-android-session-replay-material/src/main/kotlin/com/datadog/android/sessionreplay/material/MaterialExtensionSupport.kt @@ -6,8 +6,11 @@ package com.datadog.android.sessionreplay.material +import androidx.cardview.widget.CardView import com.datadog.android.sessionreplay.ExtensionSupport import com.datadog.android.sessionreplay.MapperTypeWrapper +import com.datadog.android.sessionreplay.material.internal.CardWireframeMapper +import com.datadog.android.sessionreplay.material.internal.MaterialDrawableToColorMapper import com.datadog.android.sessionreplay.material.internal.MaterialOptionSelectorDetector import com.datadog.android.sessionreplay.material.internal.SliderWireframeMapper import com.datadog.android.sessionreplay.material.internal.TabWireframeMapper @@ -32,7 +35,9 @@ class MaterialExtensionSupport : ExtensionSupport { private val viewIdentifierResolver: ViewIdentifierResolver = DefaultViewIdentifierResolver private val colorStringFormatter: ColorStringFormatter = DefaultColorStringFormatter private val viewBoundsResolver: ViewBoundsResolver = DefaultViewBoundsResolver - private val drawableToColorMapper: DrawableToColorMapper = DrawableToColorMapper.getDefault() + private val materialDrawableToColorMapper = MaterialDrawableToColorMapper() + private val drawableToColorMapper: DrawableToColorMapper = + DrawableToColorMapper.getDefault(listOf(materialDrawableToColorMapper)) override fun getCustomViewMappers(): List> { val sliderWireframeMapper = SliderWireframeMapper( @@ -52,13 +57,25 @@ class MaterialExtensionSupport : ExtensionSupport { ) ) + val cardWireframeMapper = CardWireframeMapper( + viewIdentifierResolver, + colorStringFormatter, + viewBoundsResolver, + drawableToColorMapper + ) + return listOf( MapperTypeWrapper(Slider::class.java, sliderWireframeMapper), - MapperTypeWrapper(TabLayout.TabView::class.java, tabWireframeMapper) + MapperTypeWrapper(TabLayout.TabView::class.java, tabWireframeMapper), + MapperTypeWrapper(CardView::class.java, cardWireframeMapper) ) } override fun getOptionSelectorDetectors(): List { return listOf(MaterialOptionSelectorDetector()) } + + override fun getCustomDrawableMapper(): List { + return listOf(materialDrawableToColorMapper) + } } diff --git a/features/dd-sdk-android-session-replay-material/src/main/kotlin/com/datadog/android/sessionreplay/material/internal/CardWireframeMapper.kt b/features/dd-sdk-android-session-replay-material/src/main/kotlin/com/datadog/android/sessionreplay/material/internal/CardWireframeMapper.kt new file mode 100644 index 0000000000..1d355e3bed --- /dev/null +++ b/features/dd-sdk-android-session-replay-material/src/main/kotlin/com/datadog/android/sessionreplay/material/internal/CardWireframeMapper.kt @@ -0,0 +1,87 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.datadog.android.sessionreplay.material.internal + +import androidx.cardview.widget.CardView +import com.datadog.android.api.InternalLogger +import com.datadog.android.sessionreplay.model.MobileSegment +import com.datadog.android.sessionreplay.recorder.MappingContext +import com.datadog.android.sessionreplay.recorder.mapper.BaseViewGroupMapper +import com.datadog.android.sessionreplay.utils.AsyncJobStatusCallback +import com.datadog.android.sessionreplay.utils.ColorStringFormatter +import com.datadog.android.sessionreplay.utils.DrawableToColorMapper +import com.datadog.android.sessionreplay.utils.ViewBoundsResolver +import com.datadog.android.sessionreplay.utils.ViewIdentifierResolver +import com.google.android.material.card.MaterialCardView + +internal class CardWireframeMapper( + viewIdentifierResolver: ViewIdentifierResolver, + colorStringFormatter: ColorStringFormatter, + viewBoundsResolver: ViewBoundsResolver, + drawableToColorMapper: DrawableToColorMapper +) : BaseViewGroupMapper( + viewIdentifierResolver, + colorStringFormatter, + viewBoundsResolver, + drawableToColorMapper +) { + override fun map( + view: CardView, + mappingContext: MappingContext, + asyncJobStatusCallback: AsyncJobStatusCallback, + internalLogger: InternalLogger + ): List { + val viewGlobalBounds = viewBoundsResolver.resolveViewGlobalBounds( + view, + mappingContext.systemInformation.screenDensity + ) + val shapeStyle = resolveShapeStyle(view, mappingContext) + + // Only MaterialCardView can have a built-in border. + val shapeBorder = if (view is MaterialCardView) { + resolveShapeBorder(view, mappingContext) + } else { + null + } + + return listOf( + MobileSegment.Wireframe.ShapeWireframe( + resolveViewId(view), + viewGlobalBounds.x, + viewGlobalBounds.y, + viewGlobalBounds.width, + viewGlobalBounds.height, + shapeStyle = shapeStyle, + border = shapeBorder + ) + ) + } + + private fun resolveShapeBorder( + view: MaterialCardView, + mappingContext: MappingContext + ): MobileSegment.ShapeBorder { + @Suppress("DEPRECATION") + val strokeColor = view.strokeColorStateList?.defaultColor ?: view.strokeColor + return MobileSegment.ShapeBorder( + color = colorStringFormatter.formatColorAsHexString(strokeColor), + width = view.strokeWidth.toLong().densityNormalized(mappingContext.systemInformation.screenDensity) + ) + } + + private fun resolveShapeStyle( + view: CardView, + mappingContext: MappingContext + ): MobileSegment.ShapeStyle { + val backgroundColor = view.cardBackgroundColor.defaultColor + return MobileSegment.ShapeStyle( + backgroundColor = colorStringFormatter.formatColorAsHexString(backgroundColor), + opacity = view.alpha, + cornerRadius = view.radius.toLong().densityNormalized(mappingContext.systemInformation.screenDensity) + ) + } +} diff --git a/features/dd-sdk-android-session-replay-material/src/main/kotlin/com/datadog/android/sessionreplay/material/internal/MaterialDrawableToColorMapper.kt b/features/dd-sdk-android-session-replay-material/src/main/kotlin/com/datadog/android/sessionreplay/material/internal/MaterialDrawableToColorMapper.kt new file mode 100644 index 0000000000..bbf000b34c --- /dev/null +++ b/features/dd-sdk-android-session-replay-material/src/main/kotlin/com/datadog/android/sessionreplay/material/internal/MaterialDrawableToColorMapper.kt @@ -0,0 +1,28 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.datadog.android.sessionreplay.material.internal + +import android.graphics.drawable.Drawable +import com.datadog.android.api.InternalLogger +import com.datadog.android.sessionreplay.utils.DrawableToColorMapper +import com.google.android.material.shape.MaterialShapeDrawable + +internal class MaterialDrawableToColorMapper : DrawableToColorMapper { + + override fun mapDrawableToColor(drawable: Drawable, internalLogger: InternalLogger): Int? { + return when (drawable) { + is MaterialShapeDrawable -> resolveMaterialShapeDrawable(drawable) + else -> null + } + } + + private fun resolveMaterialShapeDrawable( + shapeDrawable: MaterialShapeDrawable + ): Int? { + return shapeDrawable.fillColor?.defaultColor + } +} diff --git a/features/dd-sdk-android-session-replay-material/src/main/kotlin/com/datadog/android/sessionreplay/material/internal/SliderWireframeMapper.kt b/features/dd-sdk-android-session-replay-material/src/main/kotlin/com/datadog/android/sessionreplay/material/internal/SliderWireframeMapper.kt index 847fbc7ffd..4be50ba2d6 100644 --- a/features/dd-sdk-android-session-replay-material/src/main/kotlin/com/datadog/android/sessionreplay/material/internal/SliderWireframeMapper.kt +++ b/features/dd-sdk-android-session-replay-material/src/main/kotlin/com/datadog/android/sessionreplay/material/internal/SliderWireframeMapper.kt @@ -9,7 +9,7 @@ package com.datadog.android.sessionreplay.material.internal import android.content.res.ColorStateList import androidx.annotation.UiThread import com.datadog.android.api.InternalLogger -import com.datadog.android.sessionreplay.SessionReplayPrivacy +import com.datadog.android.sessionreplay.TextAndInputPrivacy import com.datadog.android.sessionreplay.model.MobileSegment import com.datadog.android.sessionreplay.recorder.MappingContext import com.datadog.android.sessionreplay.recorder.mapper.WireframeMapper @@ -122,7 +122,7 @@ internal open class SliderWireframeMapper( ) ) - return if (mappingContext.privacy == SessionReplayPrivacy.ALLOW) { + return if (mappingContext.textAndInputPrivacy == TextAndInputPrivacy.MASK_SENSITIVE_INPUTS) { listOf(trackNonActiveWireframe, trackActiveWireframe, thumbWireframe) } else { listOf(trackNonActiveWireframe) diff --git a/features/dd-sdk-android-session-replay-material/src/test/kotlin/com/datadog/android/sessionreplay/material/CardWireframeMapperTest.kt b/features/dd-sdk-android-session-replay-material/src/test/kotlin/com/datadog/android/sessionreplay/material/CardWireframeMapperTest.kt new file mode 100644 index 0000000000..514702c13a --- /dev/null +++ b/features/dd-sdk-android-session-replay-material/src/test/kotlin/com/datadog/android/sessionreplay/material/CardWireframeMapperTest.kt @@ -0,0 +1,250 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.datadog.android.sessionreplay.material + +import android.content.res.ColorStateList +import androidx.cardview.widget.CardView +import com.datadog.android.api.InternalLogger +import com.datadog.android.sessionreplay.material.forge.ForgeConfigurator +import com.datadog.android.sessionreplay.material.internal.CardWireframeMapper +import com.datadog.android.sessionreplay.model.MobileSegment +import com.datadog.android.sessionreplay.recorder.MappingContext +import com.datadog.android.sessionreplay.utils.AsyncJobStatusCallback +import com.datadog.android.sessionreplay.utils.ColorStringFormatter +import com.datadog.android.sessionreplay.utils.DrawableToColorMapper +import com.datadog.android.sessionreplay.utils.GlobalBounds +import com.datadog.android.sessionreplay.utils.ViewBoundsResolver +import com.datadog.android.sessionreplay.utils.ViewIdentifierResolver +import com.google.android.material.card.MaterialCardView +import fr.xgouchet.elmyr.annotation.FloatForgery +import fr.xgouchet.elmyr.annotation.Forgery +import fr.xgouchet.elmyr.annotation.IntForgery +import fr.xgouchet.elmyr.annotation.LongForgery +import fr.xgouchet.elmyr.annotation.StringForgery +import fr.xgouchet.elmyr.junit5.ForgeConfiguration +import fr.xgouchet.elmyr.junit5.ForgeExtension +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.junit.jupiter.api.extension.Extensions +import org.mockito.Mock +import org.mockito.junit.jupiter.MockitoExtension +import org.mockito.junit.jupiter.MockitoSettings +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.mockito.quality.Strictness + +@Extensions( + ExtendWith(MockitoExtension::class), + ExtendWith(ForgeExtension::class) +) +@MockitoSettings(strictness = Strictness.LENIENT) +@ForgeConfiguration(value = ForgeConfigurator::class) +class CardWireframeMapperTest { + + @Forgery + lateinit var fakeMappingContext: MappingContext + + @Forgery + lateinit var fakeGlobalBounds: GlobalBounds + + internal lateinit var testedCardWireframeMapper: CardWireframeMapper + + @Mock + lateinit var mockCardView: CardView + + @Mock + lateinit var mockMaterialCardView: MaterialCardView + + @IntForgery(min = 0, max = 10) + var fakePaddingStart: Int = 0 + + @IntForgery(min = 0, max = 10) + var fakePaddingEnd: Int = 0 + + @FloatForgery(min = 0f, max = 10f) + var fakeCornerRadius: Float = 0f + + @IntForgery(min = 0, max = 10) + var fakeStrokeWidth: Int = 0 + + @LongForgery + var fakeViewId: Long = 0L + + @IntForgery(min = 0, max = 0xffffff) + var fakeBackgroundColor: Int = 0 + + @IntForgery(min = 0, max = 0xffffff) + var fakeStrokeColor: Int = 0 + + @StringForgery(regex = "#[0-9A-F]{8}") + lateinit var fakeBgColorHexString: String + + @StringForgery(regex = "#[0-9A-F]{8}") + lateinit var fakeStrokeColorHexString: String + + @FloatForgery(min = 0.0f, max = 1.0f) + var fakeAlpha: Float = 0f + + @Mock + lateinit var mockBackgroundColorStateList: ColorStateList + + @Mock + lateinit var mockStrokeColorStateList: ColorStateList + + @Mock + lateinit var mockViewIdentifierResolver: ViewIdentifierResolver + + @Mock + lateinit var mockColorStringFormatter: ColorStringFormatter + + @Mock + lateinit var mockViewBoundsResolver: ViewBoundsResolver + + @Mock + lateinit var mockDrawableToColorMapper: DrawableToColorMapper + + @Mock + lateinit var mockAsyncJobStatusCallback: AsyncJobStatusCallback + + @Mock + lateinit var mockInternalLogger: InternalLogger + + @BeforeEach + fun `set up`() { + mockCardView = mockCardView() + whenever( + mockViewBoundsResolver.resolveViewGlobalBounds( + mockCardView, + fakeMappingContext.systemInformation.screenDensity + ) + ).thenReturn(fakeGlobalBounds) + whenever( + mockViewIdentifierResolver.resolveViewId( + mockCardView + ) + ).thenReturn(fakeViewId) + + mockMaterialCardView = mockMaterialCardView() + whenever( + mockViewBoundsResolver.resolveViewGlobalBounds( + mockMaterialCardView, + fakeMappingContext.systemInformation.screenDensity + ) + ).thenReturn(fakeGlobalBounds) + whenever( + mockViewIdentifierResolver.resolveViewId( + mockMaterialCardView + ) + ).thenReturn(fakeViewId) + whenever(mockColorStringFormatter.formatColorAsHexString(fakeBackgroundColor)) + .thenReturn(fakeBgColorHexString) + whenever(mockColorStringFormatter.formatColorAsHexString(fakeStrokeColor)) + .thenReturn(fakeStrokeColorHexString) + whenever(mockBackgroundColorStateList.defaultColor).thenReturn(fakeBackgroundColor) + whenever(mockStrokeColorStateList.defaultColor).thenReturn(fakeStrokeColor) + testedCardWireframeMapper = CardWireframeMapper( + viewIdentifierResolver = mockViewIdentifierResolver, + colorStringFormatter = mockColorStringFormatter, + viewBoundsResolver = mockViewBoundsResolver, + drawableToColorMapper = mockDrawableToColorMapper + ) + } + + @Test + fun `M resolves material card view wireframe W map`() { + // Given + val expected = listOf( + MobileSegment.Wireframe.ShapeWireframe( + fakeViewId, + fakeGlobalBounds.x, + fakeGlobalBounds.y, + fakeGlobalBounds.width, + fakeGlobalBounds.height, + shapeStyle = MobileSegment.ShapeStyle( + backgroundColor = fakeBgColorHexString, + opacity = fakeAlpha, + cornerRadius = ( + fakeCornerRadius.toLong() / + fakeMappingContext.systemInformation.screenDensity + ).toLong() + ), + border = MobileSegment.ShapeBorder( + color = fakeStrokeColorHexString, + width = (fakeStrokeWidth / fakeMappingContext.systemInformation.screenDensity).toLong() + ) + ) + ) + + // When + val actual = testedCardWireframeMapper.map( + mockMaterialCardView, + fakeMappingContext, + mockAsyncJobStatusCallback, + mockInternalLogger + ) + + // Then + assertThat(actual).isEqualTo(expected) + } + + @Test + fun `M resolves card view wireframe W map`() { + // Given + val expected = listOf( + MobileSegment.Wireframe.ShapeWireframe( + fakeViewId, + fakeGlobalBounds.x, + fakeGlobalBounds.y, + fakeGlobalBounds.width, + fakeGlobalBounds.height, + shapeStyle = MobileSegment.ShapeStyle( + backgroundColor = fakeBgColorHexString, + opacity = fakeAlpha, + cornerRadius = ( + fakeCornerRadius.toLong() / + fakeMappingContext.systemInformation.screenDensity + ).toLong() + ) + ) + ) + + // When + val actual = testedCardWireframeMapper.map( + mockCardView, + fakeMappingContext, + mockAsyncJobStatusCallback, + mockInternalLogger + ) + + // Then + assertThat(actual).isEqualTo(expected) + } + + private fun mockCardView(): CardView { + return mock { + whenever(it.alpha).thenReturn(fakeAlpha) + whenever(it.paddingStart).thenReturn(fakePaddingStart) + whenever(it.paddingEnd).thenReturn(fakePaddingEnd) + whenever(it.cardBackgroundColor).thenReturn(mockBackgroundColorStateList) + whenever(it.radius).thenReturn(fakeCornerRadius) + } + } + + private fun mockMaterialCardView(): MaterialCardView { + return mock { + whenever(it.alpha).thenReturn(fakeAlpha) + whenever(it.paddingStart).thenReturn(fakePaddingStart) + whenever(it.paddingEnd).thenReturn(fakePaddingEnd) + whenever(it.cardBackgroundColor).thenReturn(mockBackgroundColorStateList) + whenever(it.strokeColorStateList).thenReturn(mockStrokeColorStateList) + whenever(it.strokeWidth).thenReturn(fakeStrokeWidth) + whenever(it.radius).thenReturn(fakeCornerRadius) + } + } +} diff --git a/features/dd-sdk-android-session-replay-material/src/test/kotlin/com/datadog/android/sessionreplay/material/SliderWireframeMapperTest.kt b/features/dd-sdk-android-session-replay-material/src/test/kotlin/com/datadog/android/sessionreplay/material/SliderWireframeMapperTest.kt index d4596dd23a..d6c34f4bdd 100644 --- a/features/dd-sdk-android-session-replay-material/src/test/kotlin/com/datadog/android/sessionreplay/material/SliderWireframeMapperTest.kt +++ b/features/dd-sdk-android-session-replay-material/src/test/kotlin/com/datadog/android/sessionreplay/material/SliderWireframeMapperTest.kt @@ -6,7 +6,7 @@ package com.datadog.android.sessionreplay.material -import com.datadog.android.sessionreplay.SessionReplayPrivacy +import com.datadog.android.sessionreplay.TextAndInputPrivacy import com.datadog.android.sessionreplay.material.forge.ForgeConfigurator import com.datadog.android.sessionreplay.material.internal.SliderWireframeMapper import com.datadog.android.sessionreplay.model.MobileSegment @@ -35,7 +35,7 @@ internal class SliderWireframeMapperTest : BaseSliderWireframeMapperTest() { @Test fun `M map the Slider to a list of wireframes W map() {ALLOW}`() { // Given - fakeMappingContext = fakeMappingContext.copy(privacy = SessionReplayPrivacy.ALLOW) + fakeMappingContext = fakeMappingContext.copy(textAndInputPrivacy = TextAndInputPrivacy.MASK_SENSITIVE_INPUTS) val expectedInactiveTrackWireframe = MobileSegment.Wireframe.ShapeWireframe( id = fakeInactiveTrackId, x = fakeExpectedInactiveTrackXPos, @@ -92,7 +92,7 @@ internal class SliderWireframeMapperTest : BaseSliderWireframeMapperTest() { @Test fun `M map the Slider to a list of wireframes W map() {MASK}`() { // Given - fakeMappingContext = fakeMappingContext.copy(privacy = SessionReplayPrivacy.MASK) + fakeMappingContext = fakeMappingContext.copy(textAndInputPrivacy = TextAndInputPrivacy.MASK_ALL) val expectedInactiveTrackWireframe = MobileSegment.Wireframe.ShapeWireframe( id = fakeInactiveTrackId, x = fakeExpectedInactiveTrackXPos, @@ -124,7 +124,7 @@ internal class SliderWireframeMapperTest : BaseSliderWireframeMapperTest() { @Test fun `M map the Slider to a list of wireframes W map() {MASK_USER_INPUT}`() { // Given - fakeMappingContext = fakeMappingContext.copy(privacy = SessionReplayPrivacy.MASK_USER_INPUT) + fakeMappingContext = fakeMappingContext.copy(textAndInputPrivacy = TextAndInputPrivacy.MASK_ALL_INPUTS) val expectedInactiveTrackWireframe = MobileSegment.Wireframe.ShapeWireframe( id = fakeInactiveTrackId, x = fakeExpectedInactiveTrackXPos, diff --git a/features/dd-sdk-android-session-replay-material/src/test/kotlin/com/datadog/android/sessionreplay/material/forge/MappingContextForgeryFactory.kt b/features/dd-sdk-android-session-replay-material/src/test/kotlin/com/datadog/android/sessionreplay/material/forge/MappingContextForgeryFactory.kt index e103acd79f..b92a0dc098 100644 --- a/features/dd-sdk-android-session-replay-material/src/test/kotlin/com/datadog/android/sessionreplay/material/forge/MappingContextForgeryFactory.kt +++ b/features/dd-sdk-android-session-replay-material/src/test/kotlin/com/datadog/android/sessionreplay/material/forge/MappingContextForgeryFactory.kt @@ -16,7 +16,7 @@ internal class MappingContextForgeryFactory : ForgeryFactory { return MappingContext( systemInformation = forge.getForgery(), imageWireframeHelper = mock(), - privacy = forge.getForgery(), + textAndInputPrivacy = forge.getForgery(), imagePrivacy = forge.getForgery(), hasOptionSelectorParent = forge.aBool() ) diff --git a/features/dd-sdk-android-session-replay/api/apiSurface b/features/dd-sdk-android-session-replay/api/apiSurface index cd7869fc61..a455fcb496 100644 --- a/features/dd-sdk-android-session-replay/api/apiSurface +++ b/features/dd-sdk-android-session-replay/api/apiSurface @@ -1,7 +1,8 @@ interface com.datadog.android.sessionreplay.ExtensionSupport fun getCustomViewMappers(): List> fun getOptionSelectorDetectors(): List -enum com.datadog.android.sessionreplay.ImagePrivacy + fun getCustomDrawableMapper(): List +enum com.datadog.android.sessionreplay.ImagePrivacy : PrivacyLevel - MASK_NONE - MASK_LARGE_ONLY - MASK_ALL @@ -9,25 +10,57 @@ data class com.datadog.android.sessionreplay.MapperTypeWrapper, com.datadog.android.sessionreplay.recorder.mapper.WireframeMapper) fun supportsView(android.view.View): Boolean fun getUnsafeMapper(): com.datadog.android.sessionreplay.recorder.mapper.WireframeMapper +interface com.datadog.android.sessionreplay.PrivacyLevel +fun android.view.View.setSessionReplayHidden(Boolean) +fun android.view.View.setSessionReplayImagePrivacy(ImagePrivacy?) +fun android.view.View.setSessionReplayTextAndInputPrivacy(TextAndInputPrivacy?) +fun android.view.View.setSessionReplayTouchPrivacy(TouchPrivacy?) object com.datadog.android.sessionreplay.SessionReplay fun enable(SessionReplayConfiguration, com.datadog.android.api.SdkCore = Datadog.getInstance()) + fun startRecording(com.datadog.android.api.SdkCore = Datadog.getInstance()) + fun stopRecording(com.datadog.android.api.SdkCore = Datadog.getInstance()) data class com.datadog.android.sessionreplay.SessionReplayConfiguration class Builder - constructor(Float) + constructor(Float = SAMPLE_IN_ALL_SESSIONS) fun addExtensionSupport(ExtensionSupport): Builder fun useCustomEndpoint(String): Builder - fun setPrivacy(SessionReplayPrivacy): Builder + DEPRECATED fun setPrivacy(SessionReplayPrivacy): Builder + fun setImagePrivacy(ImagePrivacy): Builder + fun setTouchPrivacy(TouchPrivacy): Builder + fun startRecordingImmediately(Boolean): Builder + fun setTextAndInputPrivacy(TextAndInputPrivacy): Builder + fun setDynamicOptimizationEnabled(Boolean): Builder + fun setSystemRequirements(SystemRequirementsConfiguration): Builder fun build(): SessionReplayConfiguration enum com.datadog.android.sessionreplay.SessionReplayPrivacy - ALLOW - MASK - MASK_USER_INPUT +class com.datadog.android.sessionreplay.SystemRequirementsConfiguration + class Builder + fun setMinCPUCoreNumber(Int): Builder + fun setMinRAMSizeMb(Int): Builder + fun build(): SystemRequirementsConfiguration + companion object + val BASIC: SystemRequirementsConfiguration + val NONE: SystemRequirementsConfiguration +enum com.datadog.android.sessionreplay.TextAndInputPrivacy : PrivacyLevel + - MASK_SENSITIVE_INPUTS + - MASK_ALL_INPUTS + - MASK_ALL +enum com.datadog.android.sessionreplay.TouchPrivacy : PrivacyLevel + - SHOW + - HIDE interface com.datadog.android.sessionreplay.internal.recorder.obfuscator.StringObfuscator fun obfuscate(String): String companion object fun getStringObfuscator(): StringObfuscator +class com.datadog.android.sessionreplay.internal.recorder.resources.DefaultDrawableCopier : DrawableCopier + override fun copy(android.graphics.drawable.Drawable, android.content.res.Resources): android.graphics.drawable.Drawable? +interface com.datadog.android.sessionreplay.internal.recorder.resources.DrawableCopier + fun copy(android.graphics.drawable.Drawable, android.content.res.Resources): android.graphics.drawable.Drawable? data class com.datadog.android.sessionreplay.recorder.MappingContext - constructor(SystemInformation, com.datadog.android.sessionreplay.utils.ImageWireframeHelper, com.datadog.android.sessionreplay.SessionReplayPrivacy, com.datadog.android.sessionreplay.ImagePrivacy, Boolean = false) + constructor(SystemInformation, com.datadog.android.sessionreplay.utils.ImageWireframeHelper, com.datadog.android.sessionreplay.TextAndInputPrivacy, com.datadog.android.sessionreplay.ImagePrivacy, Boolean = false) interface com.datadog.android.sessionreplay.recorder.OptionSelectorDetector fun isOptionSelector(android.view.ViewGroup): Boolean data class com.datadog.android.sessionreplay.recorder.SystemInformation @@ -43,19 +76,21 @@ abstract class com.datadog.android.sessionreplay.recorder.mapper.BaseWireframeMa protected fun resolveShapeStyle(android.graphics.drawable.Drawable, Float, com.datadog.android.api.InternalLogger): com.datadog.android.sessionreplay.model.MobileSegment.ShapeStyle? class com.datadog.android.sessionreplay.recorder.mapper.EditTextMapper : TextViewMapper constructor(com.datadog.android.sessionreplay.utils.ViewIdentifierResolver, com.datadog.android.sessionreplay.utils.ColorStringFormatter, com.datadog.android.sessionreplay.utils.ViewBoundsResolver, com.datadog.android.sessionreplay.utils.DrawableToColorMapper) - override fun resolveCapturedText(android.widget.EditText, com.datadog.android.sessionreplay.SessionReplayPrivacy, Boolean): String + override fun resolveCapturedText(android.widget.EditText, com.datadog.android.sessionreplay.TextAndInputPrivacy, Boolean): String companion object open class com.datadog.android.sessionreplay.recorder.mapper.TextViewMapper : BaseAsyncBackgroundWireframeMapper constructor(com.datadog.android.sessionreplay.utils.ViewIdentifierResolver, com.datadog.android.sessionreplay.utils.ColorStringFormatter, com.datadog.android.sessionreplay.utils.ViewBoundsResolver, com.datadog.android.sessionreplay.utils.DrawableToColorMapper) override fun map(T, com.datadog.android.sessionreplay.recorder.MappingContext, com.datadog.android.sessionreplay.utils.AsyncJobStatusCallback, com.datadog.android.api.InternalLogger): List - protected open fun resolveCapturedText(T, com.datadog.android.sessionreplay.SessionReplayPrivacy, Boolean): String + protected open fun resolveCapturedText(T, com.datadog.android.sessionreplay.TextAndInputPrivacy, Boolean): String interface com.datadog.android.sessionreplay.recorder.mapper.TraverseAllChildrenMapper : WireframeMapper interface com.datadog.android.sessionreplay.recorder.mapper.WireframeMapper fun map(T, com.datadog.android.sessionreplay.recorder.MappingContext, com.datadog.android.sessionreplay.utils.AsyncJobStatusCallback, com.datadog.android.api.InternalLogger): List open class com.datadog.android.sessionreplay.utils.AndroidMDrawableToColorMapper : LegacyDrawableToColorMapper + constructor(List = emptyList()) override fun resolveRippleDrawable(android.graphics.drawable.RippleDrawable, com.datadog.android.api.InternalLogger): Int? override fun resolveInsetDrawable(android.graphics.drawable.InsetDrawable, com.datadog.android.api.InternalLogger): Int? open class com.datadog.android.sessionreplay.utils.AndroidQDrawableToColorMapper : AndroidMDrawableToColorMapper + constructor(List = emptyList()) override fun resolveGradientDrawable(android.graphics.drawable.GradientDrawable, com.datadog.android.api.InternalLogger): Int? companion object interface com.datadog.android.sessionreplay.utils.AsyncJobStatusCallback @@ -78,16 +113,18 @@ object com.datadog.android.sessionreplay.utils.DefaultViewIdentifierResolver : V interface com.datadog.android.sessionreplay.utils.DrawableToColorMapper fun mapDrawableToColor(android.graphics.drawable.Drawable, com.datadog.android.api.InternalLogger): Int? companion object - fun getDefault(): DrawableToColorMapper + fun getDefault(List = emptyList()): DrawableToColorMapper data class com.datadog.android.sessionreplay.utils.GlobalBounds constructor(Long, Long, Long, Long) interface com.datadog.android.sessionreplay.utils.ImageWireframeHelper fun createImageWireframeByBitmap(Long, GlobalBounds, android.graphics.Bitmap, Float, Boolean, com.datadog.android.sessionreplay.ImagePrivacy, AsyncJobStatusCallback, com.datadog.android.sessionreplay.model.MobileSegment.WireframeClip? = null, com.datadog.android.sessionreplay.model.MobileSegment.ShapeStyle? = null, com.datadog.android.sessionreplay.model.MobileSegment.ShapeBorder? = null): com.datadog.android.sessionreplay.model.MobileSegment.Wireframe? - fun createImageWireframeByDrawable(android.view.View, com.datadog.android.sessionreplay.ImagePrivacy, Int, Long, Long, Int, Int, Boolean, android.graphics.drawable.Drawable, AsyncJobStatusCallback, com.datadog.android.sessionreplay.model.MobileSegment.WireframeClip? = null, com.datadog.android.sessionreplay.model.MobileSegment.ShapeStyle? = null, com.datadog.android.sessionreplay.model.MobileSegment.ShapeBorder? = null, String? = DRAWABLE_CHILD_NAME): com.datadog.android.sessionreplay.model.MobileSegment.Wireframe? + fun createImageWireframeByDrawable(android.view.View, com.datadog.android.sessionreplay.ImagePrivacy, Int, Long, Long, Int, Int, Boolean, android.graphics.drawable.Drawable, com.datadog.android.sessionreplay.internal.recorder.resources.DrawableCopier = DefaultDrawableCopier(), AsyncJobStatusCallback, com.datadog.android.sessionreplay.model.MobileSegment.WireframeClip? = null, com.datadog.android.sessionreplay.model.MobileSegment.ShapeStyle? = null, com.datadog.android.sessionreplay.model.MobileSegment.ShapeBorder? = null, String? = DRAWABLE_CHILD_NAME): com.datadog.android.sessionreplay.model.MobileSegment.Wireframe? fun createCompoundDrawableWireframes(android.widget.TextView, com.datadog.android.sessionreplay.recorder.MappingContext, Int, AsyncJobStatusCallback): MutableList companion object open class com.datadog.android.sessionreplay.utils.LegacyDrawableToColorMapper : DrawableToColorMapper + constructor(List = emptyList()) override fun mapDrawableToColor(android.graphics.drawable.Drawable, com.datadog.android.api.InternalLogger): Int? + protected open fun resolveShapeDrawable(android.graphics.drawable.ShapeDrawable, com.datadog.android.api.InternalLogger): Int protected open fun resolveColorDrawable(android.graphics.drawable.ColorDrawable): Int? protected open fun resolveRippleDrawable(android.graphics.drawable.RippleDrawable, com.datadog.android.api.InternalLogger): Int? protected open fun resolveLayerDrawable(android.graphics.drawable.LayerDrawable, com.datadog.android.api.InternalLogger, (Int, android.graphics.drawable.Drawable) -> Boolean = { _, _ -> true }): Int? @@ -375,6 +412,7 @@ data class com.datadog.android.sessionreplay.model.MobileSegment - IOS - FLUTTER - REACT_NATIVE + - KOTLIN_MULTIPLATFORM fun toJson(): com.google.gson.JsonElement companion object fun fromJson(kotlin.String): Source diff --git a/features/dd-sdk-android-session-replay/api/dd-sdk-android-session-replay.api b/features/dd-sdk-android-session-replay/api/dd-sdk-android-session-replay.api index d234155dc1..1f9bee114e 100644 --- a/features/dd-sdk-android-session-replay/api/dd-sdk-android-session-replay.api +++ b/features/dd-sdk-android-session-replay/api/dd-sdk-android-session-replay.api @@ -1,9 +1,10 @@ public abstract interface class com/datadog/android/sessionreplay/ExtensionSupport { + public abstract fun getCustomDrawableMapper ()Ljava/util/List; public abstract fun getCustomViewMappers ()Ljava/util/List; public abstract fun getOptionSelectorDetectors ()Ljava/util/List; } -public final class com/datadog/android/sessionreplay/ImagePrivacy : java/lang/Enum { +public final class com/datadog/android/sessionreplay/ImagePrivacy : java/lang/Enum, com/datadog/android/sessionreplay/PrivacyLevel { public static final field MASK_ALL Lcom/datadog/android/sessionreplay/ImagePrivacy; public static final field MASK_LARGE_ONLY Lcom/datadog/android/sessionreplay/ImagePrivacy; public static final field MASK_NONE Lcom/datadog/android/sessionreplay/ImagePrivacy; @@ -22,26 +23,48 @@ public final class com/datadog/android/sessionreplay/MapperTypeWrapper { public fun toString ()Ljava/lang/String; } +public abstract interface class com/datadog/android/sessionreplay/PrivacyLevel { +} + +public final class com/datadog/android/sessionreplay/PrivacyOverrideExtensionsKt { + public static final fun setSessionReplayHidden (Landroid/view/View;Z)V + public static final fun setSessionReplayImagePrivacy (Landroid/view/View;Lcom/datadog/android/sessionreplay/ImagePrivacy;)V + public static final fun setSessionReplayTextAndInputPrivacy (Landroid/view/View;Lcom/datadog/android/sessionreplay/TextAndInputPrivacy;)V + public static final fun setSessionReplayTouchPrivacy (Landroid/view/View;Lcom/datadog/android/sessionreplay/TouchPrivacy;)V +} + public final class com/datadog/android/sessionreplay/SessionReplay { public static final field INSTANCE Lcom/datadog/android/sessionreplay/SessionReplay; public static final fun enable (Lcom/datadog/android/sessionreplay/SessionReplayConfiguration;)V public static final fun enable (Lcom/datadog/android/sessionreplay/SessionReplayConfiguration;Lcom/datadog/android/api/SdkCore;)V public static synthetic fun enable$default (Lcom/datadog/android/sessionreplay/SessionReplayConfiguration;Lcom/datadog/android/api/SdkCore;ILjava/lang/Object;)V + public final fun startRecording (Lcom/datadog/android/api/SdkCore;)V + public static synthetic fun startRecording$default (Lcom/datadog/android/sessionreplay/SessionReplay;Lcom/datadog/android/api/SdkCore;ILjava/lang/Object;)V + public final fun stopRecording (Lcom/datadog/android/api/SdkCore;)V + public static synthetic fun stopRecording$default (Lcom/datadog/android/sessionreplay/SessionReplay;Lcom/datadog/android/api/SdkCore;ILjava/lang/Object;)V } public final class com/datadog/android/sessionreplay/SessionReplayConfiguration { - public final fun copy (Ljava/lang/String;Lcom/datadog/android/sessionreplay/SessionReplayPrivacy;Ljava/util/List;Ljava/util/List;FLcom/datadog/android/sessionreplay/ImagePrivacy;)Lcom/datadog/android/sessionreplay/SessionReplayConfiguration; - public static synthetic fun copy$default (Lcom/datadog/android/sessionreplay/SessionReplayConfiguration;Ljava/lang/String;Lcom/datadog/android/sessionreplay/SessionReplayPrivacy;Ljava/util/List;Ljava/util/List;FLcom/datadog/android/sessionreplay/ImagePrivacy;ILjava/lang/Object;)Lcom/datadog/android/sessionreplay/SessionReplayConfiguration; + public final fun copy (Ljava/lang/String;Lcom/datadog/android/sessionreplay/SessionReplayPrivacy;Ljava/util/List;Ljava/util/List;Ljava/util/List;FLcom/datadog/android/sessionreplay/ImagePrivacy;ZLcom/datadog/android/sessionreplay/TouchPrivacy;Lcom/datadog/android/sessionreplay/TextAndInputPrivacy;ZLcom/datadog/android/sessionreplay/SystemRequirementsConfiguration;)Lcom/datadog/android/sessionreplay/SessionReplayConfiguration; + public static synthetic fun copy$default (Lcom/datadog/android/sessionreplay/SessionReplayConfiguration;Ljava/lang/String;Lcom/datadog/android/sessionreplay/SessionReplayPrivacy;Ljava/util/List;Ljava/util/List;Ljava/util/List;FLcom/datadog/android/sessionreplay/ImagePrivacy;ZLcom/datadog/android/sessionreplay/TouchPrivacy;Lcom/datadog/android/sessionreplay/TextAndInputPrivacy;ZLcom/datadog/android/sessionreplay/SystemRequirementsConfiguration;ILjava/lang/Object;)Lcom/datadog/android/sessionreplay/SessionReplayConfiguration; public fun equals (Ljava/lang/Object;)Z public fun hashCode ()I public fun toString ()Ljava/lang/String; } public final class com/datadog/android/sessionreplay/SessionReplayConfiguration$Builder { + public fun ()V public fun (F)V + public synthetic fun (FILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun addExtensionSupport (Lcom/datadog/android/sessionreplay/ExtensionSupport;)Lcom/datadog/android/sessionreplay/SessionReplayConfiguration$Builder; public final fun build ()Lcom/datadog/android/sessionreplay/SessionReplayConfiguration; + public final fun setDynamicOptimizationEnabled (Z)Lcom/datadog/android/sessionreplay/SessionReplayConfiguration$Builder; + public final fun setImagePrivacy (Lcom/datadog/android/sessionreplay/ImagePrivacy;)Lcom/datadog/android/sessionreplay/SessionReplayConfiguration$Builder; public final fun setPrivacy (Lcom/datadog/android/sessionreplay/SessionReplayPrivacy;)Lcom/datadog/android/sessionreplay/SessionReplayConfiguration$Builder; + public final fun setSystemRequirements (Lcom/datadog/android/sessionreplay/SystemRequirementsConfiguration;)Lcom/datadog/android/sessionreplay/SessionReplayConfiguration$Builder; + public final fun setTextAndInputPrivacy (Lcom/datadog/android/sessionreplay/TextAndInputPrivacy;)Lcom/datadog/android/sessionreplay/SessionReplayConfiguration$Builder; + public final fun setTouchPrivacy (Lcom/datadog/android/sessionreplay/TouchPrivacy;)Lcom/datadog/android/sessionreplay/SessionReplayConfiguration$Builder; + public final fun startRecordingImmediately (Z)Lcom/datadog/android/sessionreplay/SessionReplayConfiguration$Builder; public final fun useCustomEndpoint (Ljava/lang/String;)Lcom/datadog/android/sessionreplay/SessionReplayConfiguration$Builder; } @@ -53,6 +76,37 @@ public final class com/datadog/android/sessionreplay/SessionReplayPrivacy : java public static fun values ()[Lcom/datadog/android/sessionreplay/SessionReplayPrivacy; } +public final class com/datadog/android/sessionreplay/SystemRequirementsConfiguration { + public static final field Companion Lcom/datadog/android/sessionreplay/SystemRequirementsConfiguration$Companion; +} + +public final class com/datadog/android/sessionreplay/SystemRequirementsConfiguration$Builder { + public fun ()V + public final fun build ()Lcom/datadog/android/sessionreplay/SystemRequirementsConfiguration; + public final fun setMinCPUCoreNumber (I)Lcom/datadog/android/sessionreplay/SystemRequirementsConfiguration$Builder; + public final fun setMinRAMSizeMb (I)Lcom/datadog/android/sessionreplay/SystemRequirementsConfiguration$Builder; +} + +public final class com/datadog/android/sessionreplay/SystemRequirementsConfiguration$Companion { + public final fun getBASIC ()Lcom/datadog/android/sessionreplay/SystemRequirementsConfiguration; + public final fun getNONE ()Lcom/datadog/android/sessionreplay/SystemRequirementsConfiguration; +} + +public final class com/datadog/android/sessionreplay/TextAndInputPrivacy : java/lang/Enum, com/datadog/android/sessionreplay/PrivacyLevel { + public static final field MASK_ALL Lcom/datadog/android/sessionreplay/TextAndInputPrivacy; + public static final field MASK_ALL_INPUTS Lcom/datadog/android/sessionreplay/TextAndInputPrivacy; + public static final field MASK_SENSITIVE_INPUTS Lcom/datadog/android/sessionreplay/TextAndInputPrivacy; + public static fun valueOf (Ljava/lang/String;)Lcom/datadog/android/sessionreplay/TextAndInputPrivacy; + public static fun values ()[Lcom/datadog/android/sessionreplay/TextAndInputPrivacy; +} + +public final class com/datadog/android/sessionreplay/TouchPrivacy : java/lang/Enum, com/datadog/android/sessionreplay/PrivacyLevel { + public static final field HIDE Lcom/datadog/android/sessionreplay/TouchPrivacy; + public static final field SHOW Lcom/datadog/android/sessionreplay/TouchPrivacy; + public static fun valueOf (Ljava/lang/String;)Lcom/datadog/android/sessionreplay/TouchPrivacy; + public static fun values ()[Lcom/datadog/android/sessionreplay/TouchPrivacy; +} + public abstract interface class com/datadog/android/sessionreplay/internal/recorder/obfuscator/StringObfuscator { public static final field Companion Lcom/datadog/android/sessionreplay/internal/recorder/obfuscator/StringObfuscator$Companion; public abstract fun obfuscate (Ljava/lang/String;)Ljava/lang/String; @@ -62,6 +116,15 @@ public final class com/datadog/android/sessionreplay/internal/recorder/obfuscato public final fun getStringObfuscator ()Lcom/datadog/android/sessionreplay/internal/recorder/obfuscator/StringObfuscator; } +public final class com/datadog/android/sessionreplay/internal/recorder/resources/DefaultDrawableCopier : com/datadog/android/sessionreplay/internal/recorder/resources/DrawableCopier { + public fun ()V + public fun copy (Landroid/graphics/drawable/Drawable;Landroid/content/res/Resources;)Landroid/graphics/drawable/Drawable; +} + +public abstract interface class com/datadog/android/sessionreplay/internal/recorder/resources/DrawableCopier { + public abstract fun copy (Landroid/graphics/drawable/Drawable;Landroid/content/res/Resources;)Landroid/graphics/drawable/Drawable; +} + public final class com/datadog/android/sessionreplay/model/MobileSegment { public static final field Companion Lcom/datadog/android/sessionreplay/model/MobileSegment$Companion; public fun (Lcom/datadog/android/sessionreplay/model/MobileSegment$Application;Lcom/datadog/android/sessionreplay/model/MobileSegment$Session;Lcom/datadog/android/sessionreplay/model/MobileSegment$View;JJJLjava/lang/Long;Ljava/lang/Boolean;Lcom/datadog/android/sessionreplay/model/MobileSegment$Source;Ljava/util/List;)V @@ -731,6 +794,7 @@ public final class com/datadog/android/sessionreplay/model/MobileSegment$Source public static final field Companion Lcom/datadog/android/sessionreplay/model/MobileSegment$Source$Companion; public static final field FLUTTER Lcom/datadog/android/sessionreplay/model/MobileSegment$Source; public static final field IOS Lcom/datadog/android/sessionreplay/model/MobileSegment$Source; + public static final field KOTLIN_MULTIPLATFORM Lcom/datadog/android/sessionreplay/model/MobileSegment$Source; public static final field REACT_NATIVE Lcom/datadog/android/sessionreplay/model/MobileSegment$Source; public static final fun fromJson (Ljava/lang/String;)Lcom/datadog/android/sessionreplay/model/MobileSegment$Source; public final fun toJson ()Lcom/google/gson/JsonElement; @@ -1345,21 +1409,21 @@ public final class com/datadog/android/sessionreplay/model/ResourceMetadata$Comp } public final class com/datadog/android/sessionreplay/recorder/MappingContext { - public fun (Lcom/datadog/android/sessionreplay/recorder/SystemInformation;Lcom/datadog/android/sessionreplay/utils/ImageWireframeHelper;Lcom/datadog/android/sessionreplay/SessionReplayPrivacy;Lcom/datadog/android/sessionreplay/ImagePrivacy;Z)V - public synthetic fun (Lcom/datadog/android/sessionreplay/recorder/SystemInformation;Lcom/datadog/android/sessionreplay/utils/ImageWireframeHelper;Lcom/datadog/android/sessionreplay/SessionReplayPrivacy;Lcom/datadog/android/sessionreplay/ImagePrivacy;ZILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Lcom/datadog/android/sessionreplay/recorder/SystemInformation;Lcom/datadog/android/sessionreplay/utils/ImageWireframeHelper;Lcom/datadog/android/sessionreplay/TextAndInputPrivacy;Lcom/datadog/android/sessionreplay/ImagePrivacy;Z)V + public synthetic fun (Lcom/datadog/android/sessionreplay/recorder/SystemInformation;Lcom/datadog/android/sessionreplay/utils/ImageWireframeHelper;Lcom/datadog/android/sessionreplay/TextAndInputPrivacy;Lcom/datadog/android/sessionreplay/ImagePrivacy;ZILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Lcom/datadog/android/sessionreplay/recorder/SystemInformation; public final fun component2 ()Lcom/datadog/android/sessionreplay/utils/ImageWireframeHelper; - public final fun component3 ()Lcom/datadog/android/sessionreplay/SessionReplayPrivacy; + public final fun component3 ()Lcom/datadog/android/sessionreplay/TextAndInputPrivacy; public final fun component4 ()Lcom/datadog/android/sessionreplay/ImagePrivacy; public final fun component5 ()Z - public final fun copy (Lcom/datadog/android/sessionreplay/recorder/SystemInformation;Lcom/datadog/android/sessionreplay/utils/ImageWireframeHelper;Lcom/datadog/android/sessionreplay/SessionReplayPrivacy;Lcom/datadog/android/sessionreplay/ImagePrivacy;Z)Lcom/datadog/android/sessionreplay/recorder/MappingContext; - public static synthetic fun copy$default (Lcom/datadog/android/sessionreplay/recorder/MappingContext;Lcom/datadog/android/sessionreplay/recorder/SystemInformation;Lcom/datadog/android/sessionreplay/utils/ImageWireframeHelper;Lcom/datadog/android/sessionreplay/SessionReplayPrivacy;Lcom/datadog/android/sessionreplay/ImagePrivacy;ZILjava/lang/Object;)Lcom/datadog/android/sessionreplay/recorder/MappingContext; + public final fun copy (Lcom/datadog/android/sessionreplay/recorder/SystemInformation;Lcom/datadog/android/sessionreplay/utils/ImageWireframeHelper;Lcom/datadog/android/sessionreplay/TextAndInputPrivacy;Lcom/datadog/android/sessionreplay/ImagePrivacy;Z)Lcom/datadog/android/sessionreplay/recorder/MappingContext; + public static synthetic fun copy$default (Lcom/datadog/android/sessionreplay/recorder/MappingContext;Lcom/datadog/android/sessionreplay/recorder/SystemInformation;Lcom/datadog/android/sessionreplay/utils/ImageWireframeHelper;Lcom/datadog/android/sessionreplay/TextAndInputPrivacy;Lcom/datadog/android/sessionreplay/ImagePrivacy;ZILjava/lang/Object;)Lcom/datadog/android/sessionreplay/recorder/MappingContext; public fun equals (Ljava/lang/Object;)Z public final fun getHasOptionSelectorParent ()Z public final fun getImagePrivacy ()Lcom/datadog/android/sessionreplay/ImagePrivacy; public final fun getImageWireframeHelper ()Lcom/datadog/android/sessionreplay/utils/ImageWireframeHelper; - public final fun getPrivacy ()Lcom/datadog/android/sessionreplay/SessionReplayPrivacy; public final fun getSystemInformation ()Lcom/datadog/android/sessionreplay/recorder/SystemInformation; + public final fun getTextAndInputPrivacy ()Lcom/datadog/android/sessionreplay/TextAndInputPrivacy; public fun hashCode ()I public fun toString ()Ljava/lang/String; } @@ -1411,7 +1475,7 @@ public abstract class com/datadog/android/sessionreplay/recorder/mapper/BaseWire public final class com/datadog/android/sessionreplay/recorder/mapper/EditTextMapper : com/datadog/android/sessionreplay/recorder/mapper/TextViewMapper { public static final field Companion Lcom/datadog/android/sessionreplay/recorder/mapper/EditTextMapper$Companion; public fun (Lcom/datadog/android/sessionreplay/utils/ViewIdentifierResolver;Lcom/datadog/android/sessionreplay/utils/ColorStringFormatter;Lcom/datadog/android/sessionreplay/utils/ViewBoundsResolver;Lcom/datadog/android/sessionreplay/utils/DrawableToColorMapper;)V - public synthetic fun resolveCapturedText (Landroid/widget/TextView;Lcom/datadog/android/sessionreplay/SessionReplayPrivacy;Z)Ljava/lang/String; + public synthetic fun resolveCapturedText (Landroid/widget/TextView;Lcom/datadog/android/sessionreplay/TextAndInputPrivacy;Z)Ljava/lang/String; } public final class com/datadog/android/sessionreplay/recorder/mapper/EditTextMapper$Companion { @@ -1421,7 +1485,7 @@ public class com/datadog/android/sessionreplay/recorder/mapper/TextViewMapper : public fun (Lcom/datadog/android/sessionreplay/utils/ViewIdentifierResolver;Lcom/datadog/android/sessionreplay/utils/ColorStringFormatter;Lcom/datadog/android/sessionreplay/utils/ViewBoundsResolver;Lcom/datadog/android/sessionreplay/utils/DrawableToColorMapper;)V public synthetic fun map (Landroid/view/View;Lcom/datadog/android/sessionreplay/recorder/MappingContext;Lcom/datadog/android/sessionreplay/utils/AsyncJobStatusCallback;Lcom/datadog/android/api/InternalLogger;)Ljava/util/List; public fun map (Landroid/widget/TextView;Lcom/datadog/android/sessionreplay/recorder/MappingContext;Lcom/datadog/android/sessionreplay/utils/AsyncJobStatusCallback;Lcom/datadog/android/api/InternalLogger;)Ljava/util/List; - protected fun resolveCapturedText (Landroid/widget/TextView;Lcom/datadog/android/sessionreplay/SessionReplayPrivacy;Z)Ljava/lang/String; + protected fun resolveCapturedText (Landroid/widget/TextView;Lcom/datadog/android/sessionreplay/TextAndInputPrivacy;Z)Ljava/lang/String; } public abstract interface class com/datadog/android/sessionreplay/recorder/mapper/TraverseAllChildrenMapper : com/datadog/android/sessionreplay/recorder/mapper/WireframeMapper { @@ -1433,6 +1497,8 @@ public abstract interface class com/datadog/android/sessionreplay/recorder/mappe public class com/datadog/android/sessionreplay/utils/AndroidMDrawableToColorMapper : com/datadog/android/sessionreplay/utils/LegacyDrawableToColorMapper { public fun ()V + public fun (Ljava/util/List;)V + public synthetic fun (Ljava/util/List;ILkotlin/jvm/internal/DefaultConstructorMarker;)V protected fun resolveInsetDrawable (Landroid/graphics/drawable/InsetDrawable;Lcom/datadog/android/api/InternalLogger;)Ljava/lang/Integer; protected fun resolveRippleDrawable (Landroid/graphics/drawable/RippleDrawable;Lcom/datadog/android/api/InternalLogger;)Ljava/lang/Integer; } @@ -1440,6 +1506,8 @@ public class com/datadog/android/sessionreplay/utils/AndroidMDrawableToColorMapp public class com/datadog/android/sessionreplay/utils/AndroidQDrawableToColorMapper : com/datadog/android/sessionreplay/utils/AndroidMDrawableToColorMapper { public static final field Companion Lcom/datadog/android/sessionreplay/utils/AndroidQDrawableToColorMapper$Companion; public fun ()V + public fun (Ljava/util/List;)V + public synthetic fun (Ljava/util/List;ILkotlin/jvm/internal/DefaultConstructorMarker;)V protected fun resolveGradientDrawable (Landroid/graphics/drawable/GradientDrawable;Lcom/datadog/android/api/InternalLogger;)Ljava/lang/Integer; } @@ -1485,7 +1553,8 @@ public abstract interface class com/datadog/android/sessionreplay/utils/Drawable } public final class com/datadog/android/sessionreplay/utils/DrawableToColorMapper$Companion { - public final fun getDefault ()Lcom/datadog/android/sessionreplay/utils/DrawableToColorMapper; + public final fun getDefault (Ljava/util/List;)Lcom/datadog/android/sessionreplay/utils/DrawableToColorMapper; + public static synthetic fun getDefault$default (Lcom/datadog/android/sessionreplay/utils/DrawableToColorMapper$Companion;Ljava/util/List;ILjava/lang/Object;)Lcom/datadog/android/sessionreplay/utils/DrawableToColorMapper; } public final class com/datadog/android/sessionreplay/utils/GlobalBounds { @@ -1509,7 +1578,7 @@ public abstract interface class com/datadog/android/sessionreplay/utils/ImageWir public static final field Companion Lcom/datadog/android/sessionreplay/utils/ImageWireframeHelper$Companion; public abstract fun createCompoundDrawableWireframes (Landroid/widget/TextView;Lcom/datadog/android/sessionreplay/recorder/MappingContext;ILcom/datadog/android/sessionreplay/utils/AsyncJobStatusCallback;)Ljava/util/List; public abstract fun createImageWireframeByBitmap (JLcom/datadog/android/sessionreplay/utils/GlobalBounds;Landroid/graphics/Bitmap;FZLcom/datadog/android/sessionreplay/ImagePrivacy;Lcom/datadog/android/sessionreplay/utils/AsyncJobStatusCallback;Lcom/datadog/android/sessionreplay/model/MobileSegment$WireframeClip;Lcom/datadog/android/sessionreplay/model/MobileSegment$ShapeStyle;Lcom/datadog/android/sessionreplay/model/MobileSegment$ShapeBorder;)Lcom/datadog/android/sessionreplay/model/MobileSegment$Wireframe; - public abstract fun createImageWireframeByDrawable (Landroid/view/View;Lcom/datadog/android/sessionreplay/ImagePrivacy;IJJIIZLandroid/graphics/drawable/Drawable;Lcom/datadog/android/sessionreplay/utils/AsyncJobStatusCallback;Lcom/datadog/android/sessionreplay/model/MobileSegment$WireframeClip;Lcom/datadog/android/sessionreplay/model/MobileSegment$ShapeStyle;Lcom/datadog/android/sessionreplay/model/MobileSegment$ShapeBorder;Ljava/lang/String;)Lcom/datadog/android/sessionreplay/model/MobileSegment$Wireframe; + public abstract fun createImageWireframeByDrawable (Landroid/view/View;Lcom/datadog/android/sessionreplay/ImagePrivacy;IJJIIZLandroid/graphics/drawable/Drawable;Lcom/datadog/android/sessionreplay/internal/recorder/resources/DrawableCopier;Lcom/datadog/android/sessionreplay/utils/AsyncJobStatusCallback;Lcom/datadog/android/sessionreplay/model/MobileSegment$WireframeClip;Lcom/datadog/android/sessionreplay/model/MobileSegment$ShapeStyle;Lcom/datadog/android/sessionreplay/model/MobileSegment$ShapeBorder;Ljava/lang/String;)Lcom/datadog/android/sessionreplay/model/MobileSegment$Wireframe; } public final class com/datadog/android/sessionreplay/utils/ImageWireframeHelper$Companion { @@ -1517,12 +1586,14 @@ public final class com/datadog/android/sessionreplay/utils/ImageWireframeHelper$ public final class com/datadog/android/sessionreplay/utils/ImageWireframeHelper$DefaultImpls { public static synthetic fun createImageWireframeByBitmap$default (Lcom/datadog/android/sessionreplay/utils/ImageWireframeHelper;JLcom/datadog/android/sessionreplay/utils/GlobalBounds;Landroid/graphics/Bitmap;FZLcom/datadog/android/sessionreplay/ImagePrivacy;Lcom/datadog/android/sessionreplay/utils/AsyncJobStatusCallback;Lcom/datadog/android/sessionreplay/model/MobileSegment$WireframeClip;Lcom/datadog/android/sessionreplay/model/MobileSegment$ShapeStyle;Lcom/datadog/android/sessionreplay/model/MobileSegment$ShapeBorder;ILjava/lang/Object;)Lcom/datadog/android/sessionreplay/model/MobileSegment$Wireframe; - public static synthetic fun createImageWireframeByDrawable$default (Lcom/datadog/android/sessionreplay/utils/ImageWireframeHelper;Landroid/view/View;Lcom/datadog/android/sessionreplay/ImagePrivacy;IJJIIZLandroid/graphics/drawable/Drawable;Lcom/datadog/android/sessionreplay/utils/AsyncJobStatusCallback;Lcom/datadog/android/sessionreplay/model/MobileSegment$WireframeClip;Lcom/datadog/android/sessionreplay/model/MobileSegment$ShapeStyle;Lcom/datadog/android/sessionreplay/model/MobileSegment$ShapeBorder;Ljava/lang/String;ILjava/lang/Object;)Lcom/datadog/android/sessionreplay/model/MobileSegment$Wireframe; + public static synthetic fun createImageWireframeByDrawable$default (Lcom/datadog/android/sessionreplay/utils/ImageWireframeHelper;Landroid/view/View;Lcom/datadog/android/sessionreplay/ImagePrivacy;IJJIIZLandroid/graphics/drawable/Drawable;Lcom/datadog/android/sessionreplay/internal/recorder/resources/DrawableCopier;Lcom/datadog/android/sessionreplay/utils/AsyncJobStatusCallback;Lcom/datadog/android/sessionreplay/model/MobileSegment$WireframeClip;Lcom/datadog/android/sessionreplay/model/MobileSegment$ShapeStyle;Lcom/datadog/android/sessionreplay/model/MobileSegment$ShapeBorder;Ljava/lang/String;ILjava/lang/Object;)Lcom/datadog/android/sessionreplay/model/MobileSegment$Wireframe; } public class com/datadog/android/sessionreplay/utils/LegacyDrawableToColorMapper : com/datadog/android/sessionreplay/utils/DrawableToColorMapper { public static final field Companion Lcom/datadog/android/sessionreplay/utils/LegacyDrawableToColorMapper$Companion; public fun ()V + public fun (Ljava/util/List;)V + public synthetic fun (Ljava/util/List;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public fun mapDrawableToColor (Landroid/graphics/drawable/Drawable;Lcom/datadog/android/api/InternalLogger;)Ljava/lang/Integer; protected final fun mergeColorAndAlpha (II)I protected fun resolveColorDrawable (Landroid/graphics/drawable/ColorDrawable;)Ljava/lang/Integer; @@ -1531,6 +1602,7 @@ public class com/datadog/android/sessionreplay/utils/LegacyDrawableToColorMapper protected fun resolveLayerDrawable (Landroid/graphics/drawable/LayerDrawable;Lcom/datadog/android/api/InternalLogger;Lkotlin/jvm/functions/Function2;)Ljava/lang/Integer; public static synthetic fun resolveLayerDrawable$default (Lcom/datadog/android/sessionreplay/utils/LegacyDrawableToColorMapper;Landroid/graphics/drawable/LayerDrawable;Lcom/datadog/android/api/InternalLogger;Lkotlin/jvm/functions/Function2;ILjava/lang/Object;)Ljava/lang/Integer; protected fun resolveRippleDrawable (Landroid/graphics/drawable/RippleDrawable;Lcom/datadog/android/api/InternalLogger;)Ljava/lang/Integer; + protected fun resolveShapeDrawable (Landroid/graphics/drawable/ShapeDrawable;Lcom/datadog/android/api/InternalLogger;)I } public final class com/datadog/android/sessionreplay/utils/LegacyDrawableToColorMapper$Companion { diff --git a/features/dd-sdk-android-session-replay/build.gradle.kts b/features/dd-sdk-android-session-replay/build.gradle.kts index 25ed55372e..37b4ae17ff 100644 --- a/features/dd-sdk-android-session-replay/build.gradle.kts +++ b/features/dd-sdk-android-session-replay/build.gradle.kts @@ -3,9 +3,11 @@ * This product includes software developed at Datadog (https://www.datadoghq.com/). * Copyright 2016-Present Datadog, Inc. */ +@file:Suppress("StringLiteralDuplication") import com.datadog.gradle.config.androidLibraryConfig import com.datadog.gradle.config.dependencyUpdateConfig +import com.datadog.gradle.config.detektCustomConfig import com.datadog.gradle.config.javadocConfig import com.datadog.gradle.config.junitConfig import com.datadog.gradle.config.kotlinConfig @@ -88,3 +90,4 @@ publishingConfig( "The Session Replay feature to use with the Datadog monitoring " + "library for Android applications." ) +detektCustomConfig(":dd-sdk-android-core", ":dd-sdk-android-internal") diff --git a/features/dd-sdk-android-session-replay/consumer-rules.pro b/features/dd-sdk-android-session-replay/consumer-rules.pro index e511394ccc..0276df9fec 100644 --- a/features/dd-sdk-android-session-replay/consumer-rules.pro +++ b/features/dd-sdk-android-session-replay/consumer-rules.pro @@ -7,3 +7,6 @@ -keepnames class com.datadog.android.sessionreplay.internal.recorder.listener.WindowsOnDrawListener -keepnames class * extends com.datadog.android.sessionreplay.recorder.mapper.WireframeMapper -keepnames class * extends com.datadog.android.sessionreplay.internal.async.RecordedDataQueueItem + +# Keep the fine grained masking level enums +-keepnames enum * extends com.datadog.android.sessionreplay.PrivacyLevel { *; } diff --git a/features/dd-sdk-android-session-replay/src/main/json/schemas/session-replay/mobile/segment-metadata-schema.json b/features/dd-sdk-android-session-replay/src/main/json/schemas/session-replay/mobile/segment-metadata-schema.json index 8966e96a0e..5966a2e571 100644 --- a/features/dd-sdk-android-session-replay/src/main/json/schemas/session-replay/mobile/segment-metadata-schema.json +++ b/features/dd-sdk-android-session-replay/src/main/json/schemas/session-replay/mobile/segment-metadata-schema.json @@ -17,7 +17,7 @@ "source": { "type": "string", "description": "The source of this record", - "enum": ["android", "ios", "flutter", "react-native"] + "enum": ["android", "ios", "flutter", "react-native", "kotlin-multiplatform"] } } } diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/ExtensionSupport.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/ExtensionSupport.kt index e8acc963c3..1b002e7e48 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/ExtensionSupport.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/ExtensionSupport.kt @@ -9,6 +9,7 @@ package com.datadog.android.sessionreplay import android.view.View import com.datadog.android.sessionreplay.recorder.OptionSelectorDetector import com.datadog.android.sessionreplay.recorder.mapper.WireframeMapper +import com.datadog.android.sessionreplay.utils.DrawableToColorMapper /** * In case you need to provide different configuration for a specific Android UI framework that @@ -30,4 +31,10 @@ interface ExtensionSupport { * @return a list of custom [OptionSelectorDetector]. */ fun getOptionSelectorDetectors(): List + + /** + * Implement this method if you need to add some specific mapper of drawable for extensions. + * @return a list of custom [DrawableToColorMapper] implementation. + */ + fun getCustomDrawableMapper(): List } diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/ImagePrivacy.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/ImagePrivacy.kt index 9d4c535c49..13f5f83f07 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/ImagePrivacy.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/ImagePrivacy.kt @@ -12,7 +12,7 @@ package com.datadog.android.sessionreplay * @see ImagePrivacy.MASK_LARGE_ONLY * @see ImagePrivacy.MASK_ALL */ -enum class ImagePrivacy { +enum class ImagePrivacy : PrivacyLevel { /** * All images will be recorded, including those downloaded from the Internet during app runtime. */ diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/PrivacyLevel.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/PrivacyLevel.kt new file mode 100644 index 0000000000..6b9899172a --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/PrivacyLevel.kt @@ -0,0 +1,12 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.datadog.android.sessionreplay + +/** + * Base interface for privacy masking levels. + */ +interface PrivacyLevel diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/PrivacyOverrideExtensions.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/PrivacyOverrideExtensions.kt new file mode 100644 index 0000000000..115f711761 --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/PrivacyOverrideExtensions.kt @@ -0,0 +1,66 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.datadog.android.sessionreplay + +import android.view.View + +/** + * Allows setting a view to be "hidden" in the hierarchy in Session Replay. + * When hidden the view will be replaced with a placeholder in the replay and + * no attempt will be made to record it's children. + * + * @param hide pass `true` to hide the view, or `false` to remove the override + */ +fun View.setSessionReplayHidden(hide: Boolean) { + if (hide) { + this.setTag(R.id.datadog_hidden, true) + } else { + this.setTag(R.id.datadog_hidden, null) + } +} + +/** + * Allows overriding the image privacy for a view in Session Replay. + * + * @param privacy the new privacy level to use for the view + * or null to remove the override. + */ +fun View.setSessionReplayImagePrivacy(privacy: ImagePrivacy?) { + if (privacy == null) { + this.setTag(R.id.datadog_image_privacy, null) + } else { + this.setTag(R.id.datadog_image_privacy, privacy.toString()) + } +} + +/** + * Allows overriding the text and input privacy for a view in Session Replay. + * + * @param privacy the new privacy level to use for the view + * or null to remove the override. + */ +fun View.setSessionReplayTextAndInputPrivacy(privacy: TextAndInputPrivacy?) { + if (privacy == null) { + this.setTag(R.id.datadog_text_and_input_privacy, null) + } else { + this.setTag(R.id.datadog_text_and_input_privacy, privacy.toString()) + } +} + +/** + * Allows overriding the touch privacy for a view in Session Replay. + * + * @param privacy the new privacy level to use for the view + * or null to remove the override. + */ +fun View.setSessionReplayTouchPrivacy(privacy: TouchPrivacy?) { + if (privacy == null) { + this.setTag(R.id.datadog_touch_privacy, null) + } else { + this.setTag(R.id.datadog_touch_privacy, privacy.toString()) + } +} diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/SessionReplay.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/SessionReplay.kt index ba1492a830..285da1468e 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/SessionReplay.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/SessionReplay.kt @@ -6,18 +6,31 @@ package com.datadog.android.sessionreplay +import androidx.annotation.VisibleForTesting import com.datadog.android.Datadog +import com.datadog.android.api.InternalLogger import com.datadog.android.api.SdkCore +import com.datadog.android.api.feature.Feature.Companion.SESSION_REPLAY_FEATURE_NAME import com.datadog.android.api.feature.FeatureSdkCore import com.datadog.android.sessionreplay.internal.SessionReplayFeature +import com.datadog.android.sessionreplay.internal.TouchPrivacyManager +import java.lang.ref.WeakReference /** * An entry point to Datadog Session Replay feature. */ object SessionReplay { + @VisibleForTesting internal var currentRegisteredCore: WeakReference? = null + + internal const val IS_ALREADY_REGISTERED_WARNING = + "Session Replay is already enabled and does not support multiple instances. " + + "The existing instance will continue to be used." + /** * Enables a SessionReplay feature based on the configuration provided. + * It is recommended to invoke this function as early as possible in the app's lifecycle, + * ideally within the `Application#onCreate` callback, to ensure proper initialization. * * @param sessionReplayConfiguration Configuration to use for the feature. * @param sdkCore SDK instance to register feature in. If not provided, default SDK instance @@ -29,16 +42,74 @@ object SessionReplay { sessionReplayConfiguration: SessionReplayConfiguration, sdkCore: SdkCore = Datadog.getInstance() ) { - val sessionReplayFeature = SessionReplayFeature( - sdkCore = sdkCore as FeatureSdkCore, - customEndpointUrl = sessionReplayConfiguration.customEndpointUrl, - privacy = sessionReplayConfiguration.privacy, - imagePrivacy = sessionReplayConfiguration.imagePrivacy, - customMappers = sessionReplayConfiguration.customMappers, - customOptionSelectorDetectors = sessionReplayConfiguration.customOptionSelectorDetectors, - sampleRate = sessionReplayConfiguration.sampleRate - ) + val featureSdkCore = sdkCore as FeatureSdkCore + sessionReplayConfiguration.systemRequirementsConfiguration + .runIfRequirementsMet(featureSdkCore.internalLogger) { + val touchPrivacyManager = TouchPrivacyManager(sessionReplayConfiguration.touchPrivacy) + val sessionReplayFeature = SessionReplayFeature( + sdkCore = featureSdkCore, + customEndpointUrl = sessionReplayConfiguration.customEndpointUrl, + privacy = sessionReplayConfiguration.privacy, + imagePrivacy = sessionReplayConfiguration.imagePrivacy, + touchPrivacy = sessionReplayConfiguration.touchPrivacy, + touchPrivacyManager = touchPrivacyManager, + textAndInputPrivacy = sessionReplayConfiguration.textAndInputPrivacy, + customMappers = sessionReplayConfiguration.customMappers, + customOptionSelectorDetectors = sessionReplayConfiguration.customOptionSelectorDetectors, + customDrawableMappers = sessionReplayConfiguration.customDrawableMappers, + sampleRate = sessionReplayConfiguration.sampleRate, + startRecordingImmediately = sessionReplayConfiguration.startRecordingImmediately, + dynamicOptimizationEnabled = sessionReplayConfiguration.dynamicOptimizationEnabled + ) + + if (isAlreadyRegistered()) { + logAlreadyRegisteredWarning(sdkCore.internalLogger) + } else { + currentRegisteredCore = WeakReference(sdkCore) + sdkCore.registerFeature(sessionReplayFeature) + } + } + } + + /** + * Start recording session replay data. + * @param sdkCore SDK instance to get the feature from. If not provided, default SDK instance + * will be used. + */ + fun startRecording( + sdkCore: SdkCore = Datadog.getInstance() + ) { + val sessionReplayFeature = (sdkCore as? FeatureSdkCore) + ?.getFeature(SESSION_REPLAY_FEATURE_NAME)?.let { + it.unwrap() as? SessionReplayFeature + } - sdkCore.registerFeature(sessionReplayFeature) + sessionReplayFeature?.manuallyStartRecording() } + + /** + * Stop recording session replay data. + * @param sdkCore SDK instance to get the feature from. If not provided, default SDK instance + * will be used. + */ + fun stopRecording( + sdkCore: SdkCore = Datadog.getInstance() + ) { + val sessionReplayFeature = (sdkCore as? FeatureSdkCore) + ?.getFeature(SESSION_REPLAY_FEATURE_NAME)?.let { + it.unwrap() as? SessionReplayFeature + } + + sessionReplayFeature?.manuallyStopRecording() + } + + private fun isAlreadyRegistered() = + currentRegisteredCore?.get()?.isCoreActive() == true + + private fun logAlreadyRegisteredWarning(internalLogger: InternalLogger) = + internalLogger.log( + level = InternalLogger.Level.WARN, + targets = listOf(InternalLogger.Target.MAINTAINER, InternalLogger.Target.TELEMETRY), + messageBuilder = { IS_ALREADY_REGISTERED_WARNING } + ) } diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/SessionReplayConfiguration.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/SessionReplayConfiguration.kt index 0f7905a650..7c7600f55d 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/SessionReplayConfiguration.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/SessionReplayConfiguration.kt @@ -9,6 +9,7 @@ package com.datadog.android.sessionreplay import androidx.annotation.FloatRange import com.datadog.android.sessionreplay.internal.NoOpExtensionSupport import com.datadog.android.sessionreplay.recorder.OptionSelectorDetector +import com.datadog.android.sessionreplay.utils.DrawableToColorMapper /** * Describes configuration to be used for the Session Replay feature. @@ -18,20 +19,36 @@ data class SessionReplayConfiguration internal constructor( internal val privacy: SessionReplayPrivacy, internal val customMappers: List>, internal val customOptionSelectorDetectors: List, + internal val customDrawableMappers: List, internal val sampleRate: Float, - internal val imagePrivacy: ImagePrivacy + internal val imagePrivacy: ImagePrivacy, + internal val startRecordingImmediately: Boolean, + internal val touchPrivacy: TouchPrivacy, + internal val textAndInputPrivacy: TextAndInputPrivacy, + internal val dynamicOptimizationEnabled: Boolean, + internal val systemRequirementsConfiguration: SystemRequirementsConfiguration ) { /** * A Builder class for a [SessionReplayConfiguration]. * @param sampleRate must be a value between 0 and 100. A value of 0 * means no session will be recorded, 100 means all sessions will be recorded. + * If this value is not provided then Session Replay will default to a 100 sample rate. */ - class Builder(@FloatRange(from = 0.0, to = 100.0) private val sampleRate: Float) { + class Builder(@FloatRange(from = 0.0, to = 100.0) private val sampleRate: Float = SAMPLE_IN_ALL_SESSIONS) { private var customEndpointUrl: String? = null private var privacy = SessionReplayPrivacy.MASK - private var imagePrivacy = ImagePrivacy.MASK_LARGE_ONLY + + // indicates whether fine grained masking levels have been explicitly set + private var fineGrainedMaskingSet = false + + private var imagePrivacy = ImagePrivacy.MASK_ALL + private var startRecordingImmediately = true + private var touchPrivacy = TouchPrivacy.HIDE + private var textAndInputPrivacy = TextAndInputPrivacy.MASK_ALL private var extensionSupport: ExtensionSupport = NoOpExtensionSupport() + private var dynamicOptimizationEnabled = true + private var systemRequirementsConfiguration = SystemRequirementsConfiguration.NONE /** * Adds an extension support implementation. This is mostly used when you want to provide @@ -59,8 +76,35 @@ data class SessionReplayConfiguration internal constructor( * @see SessionReplayPrivacy.MASK * @see SessionReplayPrivacy.MASK_USER_INPUT */ + @Deprecated( + message = "This method is deprecated and will be removed in future versions. " + + "Use the new fine grained masking apis instead: " + + "[setImagePrivacy], [setTouchPrivacy], [setTextAndInputPrivacy]." + ) fun setPrivacy(privacy: SessionReplayPrivacy): Builder { - this.privacy = privacy + // if fgm levels have already been explicitly set then ignore legacy privacy. + if (fineGrainedMaskingSet) return this + + when (privacy) { + SessionReplayPrivacy.ALLOW -> { + this.touchPrivacy = TouchPrivacy.SHOW + this.imagePrivacy = ImagePrivacy.MASK_NONE + this.textAndInputPrivacy = TextAndInputPrivacy.MASK_SENSITIVE_INPUTS + } + + SessionReplayPrivacy.MASK_USER_INPUT -> { + this.touchPrivacy = TouchPrivacy.HIDE + this.imagePrivacy = ImagePrivacy.MASK_LARGE_ONLY + this.textAndInputPrivacy = TextAndInputPrivacy.MASK_ALL_INPUTS + } + + SessionReplayPrivacy.MASK -> { + this.touchPrivacy = TouchPrivacy.HIDE + this.imagePrivacy = ImagePrivacy.MASK_ALL + this.textAndInputPrivacy = TextAndInputPrivacy.MASK_ALL + } + } + return this } @@ -71,11 +115,68 @@ data class SessionReplayConfiguration internal constructor( * @see ImagePrivacy.MASK_LARGE_ONLY * @see ImagePrivacy.MASK_ALL */ - internal fun setImagePrivacy(level: ImagePrivacy): Builder { + fun setImagePrivacy(level: ImagePrivacy): Builder { + fineGrainedMaskingSet = true this.imagePrivacy = level return this } + /** + * Sets the touch recording level for the Session Replay feature. + * If not specified then all touches will be hidden by default. + * @see TouchPrivacy.HIDE + * @see TouchPrivacy.SHOW + */ + fun setTouchPrivacy(level: TouchPrivacy): Builder { + fineGrainedMaskingSet = true + this.touchPrivacy = level + return this + } + + /** + * Should recording start automatically (or be manually started). + * If not specified then by default it starts automatically. + * @param enabled whether recording should start automatically or not. + */ + fun startRecordingImmediately(enabled: Boolean): Builder { + this.startRecordingImmediately = enabled + return this + } + + /** + * Sets the text and input recording level for the Session Replay feature. + * If not specified then sensitive text will be masked by default. + * @see TextAndInputPrivacy.MASK_SENSITIVE_INPUTS + * @see TextAndInputPrivacy.MASK_ALL_INPUTS + * @see TextAndInputPrivacy.MASK_ALL + */ + fun setTextAndInputPrivacy(level: TextAndInputPrivacy): Builder { + fineGrainedMaskingSet = true + this.textAndInputPrivacy = level + return this + } + + /** + * This option controls whether optimization is enabled or disabled for recording Session Replay data. + * By default the value is true, meaning the dynamic optimization is enabled. + */ + fun setDynamicOptimizationEnabled(dynamicOptimizationEnabled: Boolean): Builder { + this.dynamicOptimizationEnabled = dynamicOptimizationEnabled + return this + } + + /** + * Defines the minimum system requirements for enabling the Session Replay feature. + * When [SessionReplay.enable] is invoked, the system configuration is verified against these requirements. + * If the system meets the specified criteria, Session Replay will be successfully enabled. + * If this function is not invoked, no minimum requirements will be enforced, and Session Replay will be + * enabled on all devices. + */ + fun setSystemRequirements(systemRequirementsConfiguration: SystemRequirementsConfiguration): Builder { + this.systemRequirementsConfiguration = systemRequirementsConfiguration + return this + } + /** * Builds a [SessionReplayConfiguration] based on the current state of this Builder. */ @@ -84,14 +185,24 @@ data class SessionReplayConfiguration internal constructor( customEndpointUrl = customEndpointUrl, privacy = privacy, imagePrivacy = imagePrivacy, + touchPrivacy = touchPrivacy, + textAndInputPrivacy = textAndInputPrivacy, customMappers = customMappers(), customOptionSelectorDetectors = extensionSupport.getOptionSelectorDetectors(), - sampleRate = sampleRate + customDrawableMappers = extensionSupport.getCustomDrawableMapper(), + sampleRate = sampleRate, + startRecordingImmediately = startRecordingImmediately, + dynamicOptimizationEnabled = dynamicOptimizationEnabled, + systemRequirementsConfiguration = systemRequirementsConfiguration ) } private fun customMappers(): List> { return extensionSupport.getCustomViewMappers() } + + internal companion object { + internal const val SAMPLE_IN_ALL_SESSIONS = 100.0f + } } } diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/SystemRequirementsConfiguration.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/SystemRequirementsConfiguration.kt new file mode 100644 index 0000000000..7553e033e9 --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/SystemRequirementsConfiguration.kt @@ -0,0 +1,126 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.datadog.android.sessionreplay + +import com.datadog.android.api.InternalLogger +import com.datadog.android.sessionreplay.internal.prerequisite.CPURequirementChecker +import com.datadog.android.sessionreplay.internal.prerequisite.MemoryRequirementChecker +import com.datadog.android.sessionreplay.internal.prerequisite.SystemRequirementChecker + +/** + * This class defines the system requirements necessary to enable the Session Replay feature. + */ +class SystemRequirementsConfiguration internal constructor( + internal val minCPUCores: Int, + internal val minRAMSizeMb: Int +) { + + internal fun runIfRequirementsMet( + internalLogger: InternalLogger, + runnable: () -> Unit + ) { + val checkers = listOf( + CPURequirementChecker(minCPUCores, internalLogger = internalLogger), + MemoryRequirementChecker(minRAMSizeMb, internalLogger = internalLogger) + ) + val checkResult = checkers.all { + it.checkMinimumRequirement() + } + + if (checkResult) { + runnable() + } else { + internalLogger.log( + level = InternalLogger.Level.INFO, + listOf(InternalLogger.Target.TELEMETRY, InternalLogger.Target.USER), + messageBuilder = { + "Session replay is disabled because the system doesn't meet the minimum " + + "Session Replay requirements" + }, + onlyOnce = true, + additionalProperties = getCheckerReport(checkers) + ) + } + } + + private fun getCheckerReport(checkers: List): Map { + return mapOf( + ATTRIBUTE_DEVICE_STATS_KEY to checkers.associate { it.name() to it.checkedValue() } + ) + } + + /** + * Builder class for configuring and creating instances of the [SystemRequirementsConfiguration] needed to + * enable the Session Replay feature. + * + */ + class Builder { + private var minCPUCoreNumber: Int = 0 + private var minRAMSizeMb: Int = 0 + + /** + * Sets the minimum CPU core number requirement. + * + * @param cpuCoreNumber The minimum CPU core number. + */ + fun setMinCPUCoreNumber(cpuCoreNumber: Int): Builder { + this.minCPUCoreNumber = cpuCoreNumber + return this + } + + /** + * Sets the minimum requirement of total RAM of the device in megabytes. + * + * @param minRAMSizeMb The minimum RAM in megabytes. + */ + fun setMinRAMSizeMb(minRAMSizeMb: Int): Builder { + this.minRAMSizeMb = minRAMSizeMb + return this + } + + /** + * Builds and returns an instance of the [SystemRequirementsConfiguration] with the configured parameters. + * + */ + fun build(): SystemRequirementsConfiguration { + return SystemRequirementsConfiguration( + minCPUCores = minCPUCoreNumber, + minRAMSizeMb = minRAMSizeMb + ) + } + } + + companion object { + + private const val ATTRIBUTE_DEVICE_STATS_KEY = "device_stats" + + /** + * A preconfigured instance representing the basic system requirements for enabling the Session Replay feature. + * + * This instance provides reasonable basic values for most systems: + * - Minimum RAM: 1024 MB + * - Minimum CPU core number: 2 + * + * Use this instance when standard system requirements are sufficient. + */ + val BASIC: SystemRequirementsConfiguration = SystemRequirementsConfiguration( + minCPUCores = 2, + minRAMSizeMb = 1024 + ) + + /** + * A special instance representing no system requirements for enabling the Session Replay feature. + * + * With this instance, Session Replay will be enabled regardless of system specifications. + * This is useful in cases where system requirements should not restrict functionality. + */ + val NONE: SystemRequirementsConfiguration = SystemRequirementsConfiguration( + minCPUCores = 0, + minRAMSizeMb = 0 + ) + } +} diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/TextAndInputPrivacy.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/TextAndInputPrivacy.kt new file mode 100644 index 0000000000..2bcd4d22bd --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/TextAndInputPrivacy.kt @@ -0,0 +1,32 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.datadog.android.sessionreplay + +/** + * Defines the Session Replay privacy policy when recording text and inputs. + * @see TextAndInputPrivacy.MASK_SENSITIVE_INPUTS + * @see TextAndInputPrivacy.MASK_ALL_INPUTS + * @see TextAndInputPrivacy.MASK_ALL + */ +enum class TextAndInputPrivacy : PrivacyLevel { + + /** + * All text and inputs considered sensitive will be masked. + * Sensitive text includes passwords, emails and phone numbers. + */ + MASK_SENSITIVE_INPUTS, + + /** + * All inputs will be masked. + */ + MASK_ALL_INPUTS, + + /** + * All text and inputs will be masked. + */ + MASK_ALL +} diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/TouchPrivacy.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/TouchPrivacy.kt new file mode 100644 index 0000000000..b89dfc4e57 --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/TouchPrivacy.kt @@ -0,0 +1,24 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.datadog.android.sessionreplay + +/** + * Defines the Session Replay privacy policy when recording touch interactions. + * @see TouchPrivacy.SHOW + * @see TouchPrivacy.HIDE + */ +enum class TouchPrivacy : PrivacyLevel { + /** + * All touch interactions will be recorded. + */ + SHOW, + + /** + * No touch interactions will be recorded. + */ + HIDE +} diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/DefaultRecorderProvider.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/DefaultRecorderProvider.kt index dd0cbf63cb..10dd2384e6 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/DefaultRecorderProvider.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/DefaultRecorderProvider.kt @@ -24,7 +24,7 @@ import androidx.appcompat.widget.SwitchCompat import com.datadog.android.api.feature.FeatureSdkCore import com.datadog.android.sessionreplay.ImagePrivacy import com.datadog.android.sessionreplay.MapperTypeWrapper -import com.datadog.android.sessionreplay.SessionReplayPrivacy +import com.datadog.android.sessionreplay.TextAndInputPrivacy import com.datadog.android.sessionreplay.internal.recorder.Recorder import com.datadog.android.sessionreplay.internal.recorder.SessionReplayRecorder import com.datadog.android.sessionreplay.internal.recorder.mapper.ActionBarContainerMapper @@ -57,10 +57,13 @@ import com.datadog.android.sessionreplay.utils.ViewIdentifierResolver internal class DefaultRecorderProvider( private val sdkCore: FeatureSdkCore, - private val privacy: SessionReplayPrivacy, + private val textAndInputPrivacy: TextAndInputPrivacy, private val imagePrivacy: ImagePrivacy, + private val touchPrivacyManager: TouchPrivacyManager, private val customMappers: List>, - private val customOptionSelectorDetectors: List + private val customOptionSelectorDetectors: List, + private val customDrawableMappers: List, + private val dynamicOptimizationEnabled: Boolean ) : RecorderProvider { override fun provideSessionReplayRecorder( @@ -74,13 +77,16 @@ internal class DefaultRecorderProvider( resourceDataStoreManager = resourceDataStoreManager, resourcesWriter = resourceWriter, rumContextProvider = SessionReplayRumContextProvider(sdkCore), - privacy = privacy, imagePrivacy = imagePrivacy, + touchPrivacyManager = touchPrivacyManager, + textAndInputPrivacy = textAndInputPrivacy, recordWriter = recordWriter, timeProvider = SessionReplayTimeProvider(sdkCore), mappers = customMappers + builtInMappers(), customOptionSelectorDetectors = customOptionSelectorDetectors, - sdkCore = sdkCore + customDrawableMappers = customDrawableMappers, + sdkCore = sdkCore, + dynamicOptimizationEnabled = dynamicOptimizationEnabled ) } diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/NoOpExtensionSupport.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/NoOpExtensionSupport.kt index 378f9b5fd4..4f623cc1ab 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/NoOpExtensionSupport.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/NoOpExtensionSupport.kt @@ -9,6 +9,7 @@ package com.datadog.android.sessionreplay.internal import com.datadog.android.sessionreplay.ExtensionSupport import com.datadog.android.sessionreplay.MapperTypeWrapper import com.datadog.android.sessionreplay.recorder.OptionSelectorDetector +import com.datadog.android.sessionreplay.utils.DrawableToColorMapper internal class NoOpExtensionSupport : ExtensionSupport { @@ -19,4 +20,8 @@ internal class NoOpExtensionSupport : ExtensionSupport { override fun getOptionSelectorDetectors(): List { return emptyList() } + + override fun getCustomDrawableMapper(): List { + return emptyList() + } } diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/SessionReplayFeature.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/SessionReplayFeature.kt index f3c3f0e225..7c75f44eff 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/SessionReplayFeature.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/SessionReplayFeature.kt @@ -21,6 +21,8 @@ import com.datadog.android.core.sampling.Sampler import com.datadog.android.sessionreplay.ImagePrivacy import com.datadog.android.sessionreplay.MapperTypeWrapper import com.datadog.android.sessionreplay.SessionReplayPrivacy +import com.datadog.android.sessionreplay.TextAndInputPrivacy +import com.datadog.android.sessionreplay.TouchPrivacy import com.datadog.android.sessionreplay.internal.net.BatchesToSegmentsMapper import com.datadog.android.sessionreplay.internal.net.SegmentRequestFactory import com.datadog.android.sessionreplay.internal.recorder.NoOpRecorder @@ -32,6 +34,7 @@ import com.datadog.android.sessionreplay.internal.storage.NoOpRecordWriter import com.datadog.android.sessionreplay.internal.storage.RecordWriter import com.datadog.android.sessionreplay.internal.storage.SessionReplayRecordWriter import com.datadog.android.sessionreplay.recorder.OptionSelectorDetector +import com.datadog.android.sessionreplay.utils.DrawableToColorMapper import java.util.Locale import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicReference @@ -39,42 +42,72 @@ import java.util.concurrent.atomic.AtomicReference /** * Session Replay feature class, which needs to be registered with Datadog SDK instance. */ +@Suppress("TooManyFunctions") internal class SessionReplayFeature( private val sdkCore: FeatureSdkCore, private val customEndpointUrl: String?, internal val privacy: SessionReplayPrivacy, - internal val imagePrivacy: ImagePrivacy?, + internal val textAndInputPrivacy: TextAndInputPrivacy, + internal val touchPrivacy: TouchPrivacy, + internal val imagePrivacy: ImagePrivacy, private val rateBasedSampler: Sampler, + private val startRecordingImmediately: Boolean, private val recorderProvider: RecorderProvider ) : StorageBackedFeature, FeatureEventReceiver { private val currentRumSessionId = AtomicReference() + @Suppress("LongParameterList") internal constructor( sdkCore: FeatureSdkCore, customEndpointUrl: String?, privacy: SessionReplayPrivacy, + textAndInputPrivacy: TextAndInputPrivacy, + touchPrivacy: TouchPrivacy, + touchPrivacyManager: TouchPrivacyManager, imagePrivacy: ImagePrivacy, customMappers: List>, customOptionSelectorDetectors: List, - sampleRate: Float + customDrawableMappers: List, + sampleRate: Float, + startRecordingImmediately: Boolean, + dynamicOptimizationEnabled: Boolean ) : this( sdkCore, customEndpointUrl, privacy, + textAndInputPrivacy, + touchPrivacy, imagePrivacy, RateBasedSampler(sampleRate), + startRecordingImmediately, DefaultRecorderProvider( sdkCore, - privacy, + textAndInputPrivacy, imagePrivacy, + touchPrivacyManager, customMappers, - customOptionSelectorDetectors + customOptionSelectorDetectors, + customDrawableMappers, + dynamicOptimizationEnabled ) ) private lateinit var appContext: Context + + // should we record the session - a combination of rum sampling, sr sampling + // and sr stop/start state + private var shouldRecord = AtomicBoolean(startRecordingImmediately) + + // used to monitor changes to an active session due to manual stop/start + private var recordingStateChanged = AtomicBoolean(false) + + // are we recording at the moment private var isRecording = AtomicBoolean(false) + + // is the current session sampled in + private var isSessionSampledIn = AtomicBoolean(false) + internal var sessionReplayRecorder: Recorder = NoOpRecorder() internal var dataWriter: RecordWriter = NoOpRecordWriter() internal val initialized = AtomicBoolean(false) @@ -85,11 +118,7 @@ internal class SessionReplayFeature( override fun onInitialize(appContext: Context) { if (appContext !is Application) { - sdkCore.internalLogger.log( - InternalLogger.Level.WARN, - InternalLogger.Target.USER, - { REQUIRES_APPLICATION_CONTEXT_WARN_MESSAGE } - ) + logMissingApplicationContextError() return } @@ -116,11 +145,10 @@ internal class SessionReplayFeature( initialized.set(true) sdkCore.updateFeatureContext(SESSION_REPLAY_FEATURE_NAME) { it[SESSION_REPLAY_SAMPLE_RATE_KEY] = rateBasedSampler.getSampleRate()?.toLong() - it[SESSION_REPLAY_PRIVACY_KEY] = privacy.toString().lowercase(Locale.US) - // False by default. This will be changed once we will conform to the browser SR - // implementation where a parameter will be passed in the Configuration constructor - // to enable manual recording. - it[SESSION_REPLAY_MANUAL_RECORDING_KEY] = false + it[SESSION_REPLAY_START_IMMEDIATE_RECORDING_KEY] = startRecordingImmediately + it[SESSION_REPLAY_TOUCH_PRIVACY_KEY] = touchPrivacy.toString().lowercase(Locale.US) + it[SESSION_REPLAY_IMAGE_PRIVACY_KEY] = imagePrivacy.toString().lowercase(Locale.US) + it[SESSION_REPLAY_TEXT_AND_INPUT_PRIVACY_KEY] = textAndInputPrivacy.toString().lowercase(Locale.US) } } @@ -156,18 +184,46 @@ internal class SessionReplayFeature( return } + if (!checkIfInitialized()) { + return + } + handleRumSession(event) } // endregion + // region Manual Recording + + internal fun manuallyStopRecording() { + if (shouldRecord.compareAndSet(true, false)) { + recordingStateChanged.set(true) + } + } + + internal fun manuallyStartRecording() { + if (shouldRecord.compareAndSet(false, true)) { + recordingStateChanged.set(true) + } + } + + // endregion + // region Internal private fun handleRumSession(sessionMetadata: Map<*, *>) { if (sessionMetadata[SESSION_REPLAY_BUS_MESSAGE_TYPE_KEY] == RUM_SESSION_RENEWED_BUS_MESSAGE ) { - checkStatusAndApplySample(sessionMetadata) + parseSessionMetadata(sessionMetadata) + ?.let { sessionData -> + val alreadySeenSession = currentRumSessionId.get() == sessionData.sessionId + if (shouldHandleSession(alreadySeenSession)) { + applySampling(alreadySeenSession) + modifyShouldRecordState(sessionData) + handleRecording(sessionData) + } + } } else { sdkCore.internalLogger.log( InternalLogger.Level.WARN, @@ -182,50 +238,88 @@ internal class SessionReplayFeature( } } - @Suppress("ReturnCount") - private fun checkStatusAndApplySample(sessionMetadata: Map<*, *>) { + private data class SessionData( + val keepSession: Boolean, + val sessionId: String + ) + + private fun parseSessionMetadata(sessionMetadata: Map<*, *>): SessionData? { val keepSession = sessionMetadata[RUM_KEEP_SESSION_BUS_MESSAGE_KEY] as? Boolean val sessionId = sessionMetadata[RUM_SESSION_ID_BUS_MESSAGE_KEY] as? String if (keepSession == null || sessionId == null) { - sdkCore.internalLogger.log( - InternalLogger.Level.WARN, - InternalLogger.Target.USER, - { EVENT_MISSING_MANDATORY_FIELDS } - ) - return + logEventMissingMandatoryFieldsError() + return null } - if (currentRumSessionId.get() == sessionId) { - // we already handled this session - return + return SessionData(keepSession, sessionId) + } + + private fun shouldHandleSession(alreadySeenSession: Boolean): Boolean { + return !alreadySeenSession || recordingStateChanged.get() + } + + private fun applySampling(alreadySeenSession: Boolean) { + if (!alreadySeenSession) { + isSessionSampledIn.set(rateBasedSampler.sample()) } + } - if (!checkIfInitialized()) { - return + private fun modifyShouldRecordState(sessionData: SessionData) { + val isSampledIn = sessionData.keepSession && isSessionSampledIn.get() + if (!isSampledIn) { + if (shouldRecord.compareAndSet(true, false)) { + logSampledOutMessage() + } } + } - if (keepSession && rateBasedSampler.sample()) { + private fun logMissingApplicationContextError() { + sdkCore.internalLogger.log( + InternalLogger.Level.WARN, + InternalLogger.Target.USER, + { REQUIRES_APPLICATION_CONTEXT_WARN_MESSAGE } + ) + } + + private fun logEventMissingMandatoryFieldsError() { + sdkCore.internalLogger.log( + InternalLogger.Level.WARN, + InternalLogger.Target.USER, + { EVENT_MISSING_MANDATORY_FIELDS } + ) + } + + private fun logSampledOutMessage() { + sdkCore.internalLogger.log( + InternalLogger.Level.INFO, + InternalLogger.Target.USER, + { SESSION_SAMPLED_OUT_MESSAGE } + ) + } + + private fun logNotInitializedError() { + sdkCore.internalLogger.log( + InternalLogger.Level.WARN, + InternalLogger.Target.USER, + { CANNOT_START_RECORDING_NOT_INITIALIZED } + ) + } + + private fun handleRecording(sessionData: SessionData) { + if (shouldRecord.get()) { startRecording() } else { - sdkCore.internalLogger.log( - InternalLogger.Level.INFO, - InternalLogger.Target.USER, - { SESSION_SAMPLED_OUT_MESSAGE } - ) stopRecording() } - currentRumSessionId.set(sessionId) + recordingStateChanged.set(false) + currentRumSessionId.set(sessionData.sessionId) } private fun checkIfInitialized(): Boolean { if (!initialized.get()) { - sdkCore.internalLogger.log( - InternalLogger.Level.WARN, - InternalLogger.Target.USER, - { CANNOT_START_RECORDING_NOT_INITIALIZED } - ) + logNotInitializedError() return false } return true @@ -311,9 +405,11 @@ internal class SessionReplayFeature( const val RUM_KEEP_SESSION_BUS_MESSAGE_KEY = "keepSession" const val RUM_SESSION_ID_BUS_MESSAGE_KEY = "sessionId" internal const val SESSION_REPLAY_SAMPLE_RATE_KEY = "session_replay_sample_rate" - internal const val SESSION_REPLAY_PRIVACY_KEY = "session_replay_privacy" - internal const val SESSION_REPLAY_MANUAL_RECORDING_KEY = - "session_replay_requires_manual_recording" + internal const val SESSION_REPLAY_TEXT_AND_INPUT_PRIVACY_KEY = "session_replay_text_and_input_privacy" + internal const val SESSION_REPLAY_IMAGE_PRIVACY_KEY = "session_replay_image_privacy" + internal const val SESSION_REPLAY_TOUCH_PRIVACY_KEY = "session_replay_touch_privacy" + internal const val SESSION_REPLAY_START_IMMEDIATE_RECORDING_KEY = + "session_replay_start_immediate_recording" internal const val SESSION_REPLAY_ENABLED_KEY = "session_replay_is_enabled" } diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/TouchPrivacyManager.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/TouchPrivacyManager.kt new file mode 100644 index 0000000000..045d497633 --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/TouchPrivacyManager.kt @@ -0,0 +1,72 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.datadog.android.sessionreplay.internal + +import android.graphics.Point +import android.graphics.Rect +import androidx.annotation.UiThread +import androidx.annotation.VisibleForTesting +import com.datadog.android.sessionreplay.TouchPrivacy + +internal class TouchPrivacyManager( + private val globalTouchPrivacy: TouchPrivacy +) { + // areas on screen where overrides are applied + private val currentOverrideAreas = HashMap() + + // Built during the view traversal and copied to currentOverrideAreas at the end + // We use two hashmaps because touch handling happens in parallel to the view traversal + // and we don't know which will happen first. + // Secondly, because we don't want to have to keep track of the lifecycle of the overridden views in order to remove + // the overrides when they are no longer needed. + private val nextOverrideAreas = HashMap() + + @UiThread + internal fun addTouchOverrideArea(bounds: Rect, touchPrivacy: TouchPrivacy) { + nextOverrideAreas[bounds] = touchPrivacy + } + + @UiThread + internal fun updateCurrentTouchOverrideAreas() { + currentOverrideAreas.clear() + // NPE cannot happen here + @Suppress("UnsafeThirdPartyFunctionCall") + currentOverrideAreas.putAll(nextOverrideAreas) + nextOverrideAreas.clear() + } + + @UiThread + internal fun shouldRecordTouch(touchLocation: Point): Boolean { + var isOverriddenToShowTouch = false + + // Everything is UiThread, so ConcurrentModification cannot happen here + @Suppress("UnsafeThirdPartyFunctionCall") + currentOverrideAreas.forEach { entry -> + val area = entry.key + val overrideValue = entry.value + + if (area.contains(touchLocation.x, touchLocation.y)) { + when (overrideValue) { + TouchPrivacy.HIDE -> return false + TouchPrivacy.SHOW -> isOverriddenToShowTouch = true + } + } + } + + return if (isOverriddenToShowTouch) true else globalTouchPrivacy == TouchPrivacy.SHOW + } + + @VisibleForTesting + internal fun getCurrentOverrideAreas(): Map { + return currentOverrideAreas + } + + @VisibleForTesting + internal fun getNextOverrideAreas(): Map { + return nextOverrideAreas + } +} diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/net/BatchesToSegmentsMapper.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/net/BatchesToSegmentsMapper.kt index 72cdd65501..af310a3081 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/net/BatchesToSegmentsMapper.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/net/BatchesToSegmentsMapper.kt @@ -7,10 +7,12 @@ package com.datadog.android.sessionreplay.internal.net import com.datadog.android.api.InternalLogger +import com.datadog.android.api.context.DatadogContext import com.datadog.android.sessionreplay.internal.gson.safeGetAsJsonArray import com.datadog.android.sessionreplay.internal.gson.safeGetAsJsonObject import com.datadog.android.sessionreplay.internal.gson.safeGetAsLong import com.datadog.android.sessionreplay.internal.processor.EnrichedRecord +import com.datadog.android.sessionreplay.internal.processor.tryFromSource import com.datadog.android.sessionreplay.internal.utils.SessionReplayRumContext import com.datadog.android.sessionreplay.model.MobileSegment import com.google.gson.JsonArray @@ -24,13 +26,16 @@ import com.google.gson.JsonParser */ internal class BatchesToSegmentsMapper(private val internalLogger: InternalLogger) { - fun map(batchData: List): List> { - return groupBatchDataIntoSegments(batchData) + fun map(datadogContext: DatadogContext, batchData: List): List> { + return groupBatchDataIntoSegments(datadogContext, batchData) } // region Internal - private fun groupBatchDataIntoSegments(batchData: List): List> { + private fun groupBatchDataIntoSegments( + datadogContext: DatadogContext, + batchData: List + ): List> { return batchData .asSequence() .mapNotNull { @@ -73,12 +78,13 @@ internal class BatchesToSegmentsMapper(private val internalLogger: InternalLogge } .filter { !it.value.isEmpty } .mapNotNull { - mapToSegment(it.key, it.value) + mapToSegment(datadogContext, it.key, it.value) } } @Suppress("ReturnCount") private fun mapToSegment( + datadogContext: DatadogContext, rumContext: SessionReplayRumContext, records: JsonArray ): Pair? { @@ -132,7 +138,7 @@ internal class BatchesToSegmentsMapper(private val internalLogger: InternalLogge // TODO RUM-861 Find a way or alternative to provide a reliable indexInView indexInView = null, hasFullSnapshot = hasFullSnapshotRecord, - source = MobileSegment.Source.ANDROID, + source = MobileSegment.Source.tryFromSource(datadogContext.source, internalLogger), records = emptyList() ) val segmentAsJsonObject = segment.toJson().safeGetAsJsonObject(internalLogger) diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/net/ResourcesRequestFactory.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/net/ResourcesRequestFactory.kt index 47a84a6721..6e820290fe 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/net/ResourcesRequestFactory.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/net/ResourcesRequestFactory.kt @@ -9,6 +9,7 @@ package com.datadog.android.sessionreplay.internal.net import com.datadog.android.api.InternalLogger import com.datadog.android.api.context.DatadogContext import com.datadog.android.api.net.Request +import com.datadog.android.api.net.RequestExecutionContext import com.datadog.android.api.net.RequestFactory import com.datadog.android.api.storage.RawBatchEvent import okhttp3.RequestBody @@ -28,6 +29,7 @@ internal class ResourcesRequestFactory( @Suppress("ThrowingInternalException") override fun create( context: DatadogContext, + executionContext: RequestExecutionContext, batchData: List, batchMetadata: ByteArray? ): Request? { diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/net/SegmentRequestFactory.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/net/SegmentRequestFactory.kt index db8966af37..5f9ad7dc0e 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/net/SegmentRequestFactory.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/net/SegmentRequestFactory.kt @@ -8,6 +8,7 @@ package com.datadog.android.sessionreplay.internal.net import com.datadog.android.api.context.DatadogContext import com.datadog.android.api.net.Request +import com.datadog.android.api.net.RequestExecutionContext import com.datadog.android.api.net.RequestFactory import com.datadog.android.api.storage.RawBatchEvent import com.datadog.android.sessionreplay.internal.exception.InvalidPayloadFormatException @@ -24,10 +25,11 @@ internal class SegmentRequestFactory( override fun create( context: DatadogContext, + executionContext: RequestExecutionContext, batchData: List, batchMetadata: ByteArray? ): Request { - val serializedSegmentPair = batchToSegmentsMapper.map(batchData.map { it.data }) + val serializedSegmentPair = batchToSegmentsMapper.map(context, batchData.map { it.data }) if (serializedSegmentPair.isEmpty()) { @Suppress("ThrowingInternalException") throw InvalidPayloadFormatException( diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/prerequisite/CPURequirementChecker.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/prerequisite/CPURequirementChecker.kt new file mode 100644 index 0000000000..1ef6d9168f --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/prerequisite/CPURequirementChecker.kt @@ -0,0 +1,56 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.datadog.android.sessionreplay.internal.prerequisite + +import com.datadog.android.api.InternalLogger +import com.datadog.android.core.allowThreadDiskReads +import com.datadog.android.core.internal.persistence.file.listFilesSafe +import java.io.File + +internal class CPURequirementChecker( + private val minCPUCores: Int, + private val cpuDirFile: File = File(DIR_PATH), + private val internalLogger: InternalLogger +) : SystemRequirementChecker { + + private var checkedValue: Int? = null + + override fun checkMinimumRequirement(): Boolean { + if (minCPUCores == 0) { + return true + } + val actualCPUCoreNumber = allowThreadDiskReads { + readCPUCoreNumber() + } + return actualCPUCoreNumber >= minCPUCores + } + + override fun name(): String = CPU_CHECK_NAME + + override fun checkedValue(): Any? = checkedValue + + private fun readCPUCoreNumber(): Int { + val files = cpuDirFile.listFilesSafe(internalLogger) { _, name -> name.matches(REGEX_CPU_CORE_FILE) } + val value = files?.size ?: fallbackReadCpuCoreNumber() + checkedValue = value + return value + } + + private fun fallbackReadCpuCoreNumber(): Int { + return Runtime.getRuntime().availableProcessors() + } + + companion object { + private const val DIR_PATH = "/sys/devices/system/cpu/" + private val REGEX_CPU_CORE_FILE: Regex = Regex("cpu[0-9]+") + + /** + * This name will be used in telemetry as the attribute key. + */ + private const val CPU_CHECK_NAME = "cpu" + } +} diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/prerequisite/MemoryRequirementChecker.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/prerequisite/MemoryRequirementChecker.kt new file mode 100644 index 0000000000..8659423c2c --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/prerequisite/MemoryRequirementChecker.kt @@ -0,0 +1,76 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.datadog.android.sessionreplay.internal.prerequisite + +import com.datadog.android.api.InternalLogger +import com.datadog.android.core.allowThreadDiskReads +import com.datadog.android.core.internal.persistence.file.canReadSafe +import com.datadog.android.core.internal.persistence.file.existsSafe +import com.datadog.android.core.internal.persistence.file.readLinesSafe +import java.io.File + +internal class MemoryRequirementChecker( + private val minRamSizeMb: Int, + private val memInfoFile: File = File(MEM_INFO_PATH), + private val internalLogger: InternalLogger +) : SystemRequirementChecker { + + private var checkedValue: Long? = null + + override fun checkMinimumRequirement(): Boolean { + if (minRamSizeMb == 0) { + return true + } + val actualMaxRamSizeMb = allowThreadDiskReads { + getMaxRAMSize() + } + return actualMaxRamSizeMb >= minRamSizeMb + } + + override fun name(): String = MEMORY_CHECK_NAME + + override fun checkedValue(): Any? = checkedValue + + private fun getMaxRAMSize(): Long { + if (!(memInfoFile.existsSafe(internalLogger) && memInfoFile.canReadSafe(internalLogger))) { + return 0L + } + + val memorySizeKb = memInfoFile.readLinesSafe(internalLogger = internalLogger)?.mapNotNull { line -> + if (line.startsWith(MEM_TOTAL_REGEX)) { + val tokens = line.split(Regex("\\s+")) + if (tokens.size > 1) { + tokens[1].toLongOrNull() // The memory value is in kB + } else { + null + } + } else { + null + } + }?.firstOrNull() + + val value = if (memorySizeKb == null) { + 0L + } else { + memorySizeKb / KB_IN_MB + } + checkedValue = value + return value + } + + companion object { + private const val KB_IN_MB = 1000 + + private const val MEM_INFO_PATH = "/proc/meminfo" + private const val MEM_TOTAL_REGEX = "MemTotal:" + + /** + * This name will be used in telemetry as the attribute key. + */ + private const val MEMORY_CHECK_NAME = "ram" + } +} diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/prerequisite/SystemRequirementChecker.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/prerequisite/SystemRequirementChecker.kt new file mode 100644 index 0000000000..9c9a3dde40 --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/prerequisite/SystemRequirementChecker.kt @@ -0,0 +1,19 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.datadog.android.sessionreplay.internal.prerequisite + +import com.datadog.tools.annotation.NoOpImplementation + +@NoOpImplementation +internal interface SystemRequirementChecker { + + fun checkMinimumRequirement(): Boolean + + fun name(): String + + fun checkedValue(): Any? +} diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/processor/MobileSegmentExt.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/processor/MobileSegmentExt.kt index 433ed400ac..554f7e7070 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/processor/MobileSegmentExt.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/processor/MobileSegmentExt.kt @@ -6,6 +6,7 @@ package com.datadog.android.sessionreplay.internal.processor +import com.datadog.android.api.InternalLogger import com.datadog.android.sessionreplay.model.MobileSegment internal fun MobileSegment.Wireframe.copy(clip: MobileSegment.WireframeClip?): MobileSegment.Wireframe { @@ -17,3 +18,23 @@ internal fun MobileSegment.Wireframe.copy(clip: MobileSegment.WireframeClip?): M is MobileSegment.Wireframe.WebviewWireframe -> this.copy(clip = clip) } } + +internal fun MobileSegment.Source.Companion.tryFromSource( + source: String, + internalLogger: InternalLogger +): MobileSegment.Source { + return try { + fromJson(source) + } catch (e: NoSuchElementException) { + internalLogger.log( + InternalLogger.Level.ERROR, + InternalLogger.Target.MAINTAINER, + { UNKNOWN_MOBILE_SEGMENT_SOURCE_WARNING_MESSAGE_FORMAT.format(java.util.Locale.US, source) }, + e + ) + MobileSegment.Source.ANDROID + } +} + +internal const val UNKNOWN_MOBILE_SEGMENT_SOURCE_WARNING_MESSAGE_FORMAT = "You are using an unknown " + + "source %s for MobileSegment.Source enum." diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/BenchmarkExt.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/BenchmarkExt.kt new file mode 100644 index 0000000000..de8297b53c --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/BenchmarkExt.kt @@ -0,0 +1,27 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.datadog.android.sessionreplay.internal.recorder + +import com.datadog.android.internal.profiler.BenchmarkSpan +import com.datadog.android.internal.profiler.withinBenchmarkSpan + +private const val ATTRIBUTE_CONTAINER = "attribute.container" + +/** + * A wrap function of [withinSRBenchmarkSpan] dedicated to session replay span recording. + */ +internal inline fun withinSRBenchmarkSpan( + spanName: String, + isContainer: Boolean = false, + block: BenchmarkSpan.() -> T +): T { + return withinBenchmarkSpan( + spanName, + mapOf(ATTRIBUTE_CONTAINER to isContainer.toString()), + block + ) +} diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/Debouncer.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/Debouncer.kt index 749219efbd..f2cab2e730 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/Debouncer.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/Debouncer.kt @@ -8,11 +8,16 @@ package com.datadog.android.sessionreplay.internal.recorder import android.os.Handler import android.os.Looper +import com.datadog.android.api.feature.Feature +import com.datadog.android.api.feature.FeatureSdkCore import java.util.concurrent.TimeUnit internal class Debouncer( private val handler: Handler = Handler(Looper.getMainLooper()), - private val maxRecordDelayInNs: Long = MAX_DELAY_THRESHOLD_NS + private val maxRecordDelayInNs: Long = MAX_DELAY_THRESHOLD_NS, + private val timeBank: TimeBank = RecordingTimeBank(), + private val sdkCore: FeatureSdkCore, + private val dynamicOptimizationEnabled: Boolean ) { private var lastTimeRecordWasPerformed = 0L @@ -37,15 +42,41 @@ internal class Debouncer( } private fun executeRunnable(runnable: Runnable) { - runnable.run() + if (dynamicOptimizationEnabled) { + runInTimeBalance { + runnable.run() + } + } else { + runnable.run() + } lastTimeRecordWasPerformed = System.nanoTime() } + private fun runInTimeBalance(block: () -> Unit) { + if (timeBank.updateAndCheck(System.nanoTime())) { + val startTimeInNano = System.nanoTime() + block() + val endTimeInNano = System.nanoTime() + timeBank.consume(endTimeInNano - startTimeInNano) + } else { + logSkippedFrame() + } + } + + private fun logSkippedFrame() { + val rumFeature = sdkCore.getFeature(Feature.RUM_FEATURE_NAME) ?: return + val telemetryEvent = mapOf(TYPE_KEY to TYPE_VALUE) + rumFeature.sendEvent(telemetryEvent) + } + companion object { // one frame time private val MAX_DELAY_THRESHOLD_NS: Long = TimeUnit.MILLISECONDS.toNanos(64) // one frame time internal const val DEBOUNCE_TIME_IN_MS: Long = 64 + + private const val TYPE_VALUE = "sr_skipped_frame" + private const val TYPE_KEY = "type" } } diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/DefaultOnDrawListenerProducer.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/DefaultOnDrawListenerProducer.kt index 0338fa8df7..92a7010a37 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/DefaultOnDrawListenerProducer.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/DefaultOnDrawListenerProducer.kt @@ -11,29 +11,34 @@ import android.view.ViewTreeObserver import com.datadog.android.api.feature.FeatureSdkCore import com.datadog.android.core.metrics.MethodCallSamplingRate import com.datadog.android.sessionreplay.ImagePrivacy -import com.datadog.android.sessionreplay.SessionReplayPrivacy +import com.datadog.android.sessionreplay.TextAndInputPrivacy +import com.datadog.android.sessionreplay.internal.TouchPrivacyManager import com.datadog.android.sessionreplay.internal.async.RecordedDataQueueHandler import com.datadog.android.sessionreplay.internal.recorder.listener.WindowsOnDrawListener internal class DefaultOnDrawListenerProducer( private val snapshotProducer: SnapshotProducer, private val recordedDataQueueHandler: RecordedDataQueueHandler, - private val sdkCore: FeatureSdkCore + private val sdkCore: FeatureSdkCore, + private val dynamicOptimizationEnabled: Boolean ) : OnDrawListenerProducer { override fun create( decorViews: List, - privacy: SessionReplayPrivacy, - imagePrivacy: ImagePrivacy + textAndInputPrivacy: TextAndInputPrivacy, + imagePrivacy: ImagePrivacy, + touchPrivacyManager: TouchPrivacyManager ): ViewTreeObserver.OnDrawListener { return WindowsOnDrawListener( zOrderedDecorViews = decorViews, recordedDataQueueHandler = recordedDataQueueHandler, snapshotProducer = snapshotProducer, - privacy = privacy, + textAndInputPrivacy = textAndInputPrivacy, imagePrivacy = imagePrivacy, - internalLogger = sdkCore.internalLogger, - methodCallSamplingRate = MethodCallSamplingRate.LOW.rate + sdkCore = sdkCore, + methodCallSamplingRate = MethodCallSamplingRate.LOW.rate, + dynamicOptimizationEnabled = dynamicOptimizationEnabled, + touchPrivacyManager = touchPrivacyManager ) } } diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/OnDrawListenerProducer.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/OnDrawListenerProducer.kt index 5aad16b0bb..6bba481d39 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/OnDrawListenerProducer.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/OnDrawListenerProducer.kt @@ -9,12 +9,14 @@ package com.datadog.android.sessionreplay.internal.recorder import android.view.View import android.view.ViewTreeObserver import com.datadog.android.sessionreplay.ImagePrivacy -import com.datadog.android.sessionreplay.SessionReplayPrivacy +import com.datadog.android.sessionreplay.TextAndInputPrivacy +import com.datadog.android.sessionreplay.internal.TouchPrivacyManager internal fun interface OnDrawListenerProducer { fun create( decorViews: List, - privacy: SessionReplayPrivacy, - imagePrivacy: ImagePrivacy + textAndInputPrivacy: TextAndInputPrivacy, + imagePrivacy: ImagePrivacy, + touchPrivacyManager: TouchPrivacyManager ): ViewTreeObserver.OnDrawListener } diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/RecordingTimeBank.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/RecordingTimeBank.kt new file mode 100644 index 0000000000..1d9b377896 --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/RecordingTimeBank.kt @@ -0,0 +1,53 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.datadog.android.sessionreplay.internal.recorder + +import java.util.concurrent.TimeUnit +import kotlin.math.min + +/** + * Time Bank is a concept representing an allocated execution quota per second. For example, if the quota is set to + * 100 milliseconds per second, it means that within any given second, no more than 100 milliseconds can be used for + * executing operations. If the full quota of 100 milliseconds has already been used within a second, further execution + * is not permitted until the next second begins and the quota is recharged. Conversely, if less than 100 milliseconds + * has been used and the second has not yet elapsed, execution may continue until the quota is reached. + */ +internal class RecordingTimeBank( + private val maxTimeBalancePerSecondInMs: Long = DEFAULT_MAX_TIME_BALANCE_PER_SEC_IN_MS +) : TimeBank { + + // The normalized factor of balance increasing by time. If increasing 100ms balance in the bank takes 1000ms, + // then the factor will be 100ms/1000ms = 0.1f + private val balanceFactor = maxTimeBalancePerSecondInMs.toDouble() / TimeUnit.SECONDS.toMillis(1) + + @Volatile + private var recordingTimeBalanceInNano = TimeUnit.MILLISECONDS.toNanos(maxTimeBalancePerSecondInMs) + + @Volatile + private var lastCheckTime: Long = 0 + + override fun consume(executionTime: Long) { + recordingTimeBalanceInNano -= executionTime + } + + override fun updateAndCheck(timestamp: Long): Boolean { + increaseTimeBank(timestamp) + lastCheckTime = timestamp + return recordingTimeBalanceInNano >= 0 + } + + private fun increaseTimeBank(timestamp: Long) { + val timePassedSinceLastExecution = timestamp - lastCheckTime + recordingTimeBalanceInNano += (timePassedSinceLastExecution * balanceFactor).toLong() + recordingTimeBalanceInNano = + min(TimeUnit.MILLISECONDS.toNanos(maxTimeBalancePerSecondInMs), recordingTimeBalanceInNano) + } + + companion object { + private const val DEFAULT_MAX_TIME_BALANCE_PER_SEC_IN_MS = 100L + } +} diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/SessionReplayRecorder.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/SessionReplayRecorder.kt index ceb5782cb8..a0b30bde33 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/SessionReplayRecorder.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/SessionReplayRecorder.kt @@ -16,15 +16,17 @@ import com.datadog.android.api.InternalLogger import com.datadog.android.api.feature.FeatureSdkCore import com.datadog.android.sessionreplay.ImagePrivacy import com.datadog.android.sessionreplay.MapperTypeWrapper -import com.datadog.android.sessionreplay.SessionReplayPrivacy +import com.datadog.android.sessionreplay.TextAndInputPrivacy import com.datadog.android.sessionreplay.internal.LifecycleCallback import com.datadog.android.sessionreplay.internal.SessionReplayLifecycleCallback +import com.datadog.android.sessionreplay.internal.TouchPrivacyManager import com.datadog.android.sessionreplay.internal.async.RecordedDataQueueHandler import com.datadog.android.sessionreplay.internal.processor.MutationResolver import com.datadog.android.sessionreplay.internal.processor.RecordedDataProcessor import com.datadog.android.sessionreplay.internal.processor.RumContextDataHandler import com.datadog.android.sessionreplay.internal.recorder.callback.OnWindowRefreshedCallback import com.datadog.android.sessionreplay.internal.recorder.mapper.DecorViewMapper +import com.datadog.android.sessionreplay.internal.recorder.mapper.HiddenViewMapper import com.datadog.android.sessionreplay.internal.recorder.mapper.ViewWireframeMapper import com.datadog.android.sessionreplay.internal.recorder.resources.BitmapCachesManager import com.datadog.android.sessionreplay.internal.recorder.resources.BitmapPool @@ -54,8 +56,9 @@ internal class SessionReplayRecorder : OnWindowRefreshedCallback, Recorder { private val appContext: Application private val rumContextProvider: RumContextProvider - private val privacy: SessionReplayPrivacy + private val textAndInputPrivacy: TextAndInputPrivacy private val imagePrivacy: ImagePrivacy + private val touchPrivacyManager: TouchPrivacyManager private val recordWriter: RecordWriter private val timeProvider: TimeProvider private val mappers: List> @@ -71,19 +74,23 @@ internal class SessionReplayRecorder : OnWindowRefreshedCallback, Recorder { private val uiHandler: Handler private var shouldRecord = false + @Suppress("LongParameterList") constructor( appContext: Application, resourcesWriter: ResourcesWriter, rumContextProvider: RumContextProvider, - privacy: SessionReplayPrivacy, + textAndInputPrivacy: TextAndInputPrivacy, imagePrivacy: ImagePrivacy, + touchPrivacyManager: TouchPrivacyManager, recordWriter: RecordWriter, timeProvider: TimeProvider, mappers: List> = emptyList(), customOptionSelectorDetectors: List = emptyList(), + customDrawableMappers: List, windowInspector: WindowInspector = WindowInspector, sdkCore: FeatureSdkCore, - resourceDataStoreManager: ResourceDataStoreManager + resourceDataStoreManager: ResourceDataStoreManager, + dynamicOptimizationEnabled: Boolean ) { val internalLogger = sdkCore.internalLogger val rumContextDataHandler = RumContextDataHandler( @@ -103,8 +110,9 @@ internal class SessionReplayRecorder : OnWindowRefreshedCallback, Recorder { this.appContext = appContext this.rumContextProvider = rumContextProvider - this.privacy = privacy + this.textAndInputPrivacy = textAndInputPrivacy this.imagePrivacy = imagePrivacy + this.touchPrivacyManager = touchPrivacyManager this.recordWriter = recordWriter this.timeProvider = timeProvider this.mappers = mappers @@ -124,7 +132,8 @@ internal class SessionReplayRecorder : OnWindowRefreshedCallback, Recorder { val viewIdentifierResolver: ViewIdentifierResolver = DefaultViewIdentifierResolver val colorStringFormatter: ColorStringFormatter = DefaultColorStringFormatter val viewBoundsResolver: ViewBoundsResolver = DefaultViewBoundsResolver - val drawableToColorMapper: DrawableToColorMapper = DrawableToColorMapper.getDefault() + val drawableToColorMapper: DrawableToColorMapper = + DrawableToColorMapper.getDefault(customDrawableMappers) val defaultVWM = ViewWireframeMapper( viewIdentifierResolver, @@ -168,24 +177,33 @@ internal class SessionReplayRecorder : OnWindowRefreshedCallback, Recorder { mappers = mappers, defaultViewMapper = defaultVWM, decorViewMapper = DecorViewMapper(defaultVWM, viewIdentifierResolver), + hiddenViewMapper = HiddenViewMapper( + viewBoundsResolver = viewBoundsResolver, + viewIdentifierResolver = viewIdentifierResolver + ), viewUtilsInternal = ViewUtilsInternal(), - internalLogger = internalLogger + internalLogger = internalLogger, + touchPrivacyManager = touchPrivacyManager ), ComposedOptionSelectorDetector( customOptionSelectorDetectors + DefaultOptionSelectorDetector() - ) + ), + internalLogger = internalLogger ), recordedDataQueueHandler = recordedDataQueueHandler, - sdkCore = sdkCore - ) + sdkCore = sdkCore, + dynamicOptimizationEnabled = dynamicOptimizationEnabled + ), + touchPrivacyManager = touchPrivacyManager ) this.windowCallbackInterceptor = WindowCallbackInterceptor( recordedDataQueueHandler, viewOnDrawInterceptor, timeProvider, internalLogger, - privacy, - imagePrivacy + imagePrivacy, + textAndInputPrivacy, + touchPrivacyManager ) this.sessionReplayLifecycleCallback = SessionReplayLifecycleCallback(this) this.uiHandler = Handler(Looper.getMainLooper()) @@ -197,8 +215,9 @@ internal class SessionReplayRecorder : OnWindowRefreshedCallback, Recorder { constructor( appContext: Application, rumContextProvider: RumContextProvider, - privacy: SessionReplayPrivacy, + textAndInputPrivacy: TextAndInputPrivacy, imagePrivacy: ImagePrivacy, + touchPrivacyManager: TouchPrivacyManager, recordWriter: RecordWriter, timeProvider: TimeProvider, mappers: List> = emptyList(), @@ -214,8 +233,9 @@ internal class SessionReplayRecorder : OnWindowRefreshedCallback, Recorder { ) { this.appContext = appContext this.rumContextProvider = rumContextProvider - this.privacy = privacy + this.textAndInputPrivacy = textAndInputPrivacy this.imagePrivacy = imagePrivacy + this.touchPrivacyManager = touchPrivacyManager this.recordWriter = recordWriter this.timeProvider = timeProvider this.mappers = mappers @@ -248,7 +268,7 @@ internal class SessionReplayRecorder : OnWindowRefreshedCallback, Recorder { val windows = sessionReplayLifecycleCallback.getCurrentWindows() val decorViews = windowInspector.getGlobalWindowViews(internalLogger) windowCallbackInterceptor.intercept(windows, appContext) - viewOnDrawInterceptor.intercept(decorViews, privacy, imagePrivacy) + viewOnDrawInterceptor.intercept(decorViews, textAndInputPrivacy, imagePrivacy) } } @@ -265,7 +285,7 @@ internal class SessionReplayRecorder : OnWindowRefreshedCallback, Recorder { if (shouldRecord) { val decorViews = windowInspector.getGlobalWindowViews(internalLogger) windowCallbackInterceptor.intercept(windows, appContext) - viewOnDrawInterceptor.intercept(decorViews, privacy, imagePrivacy) + viewOnDrawInterceptor.intercept(decorViews, textAndInputPrivacy, imagePrivacy) } } @@ -274,7 +294,7 @@ internal class SessionReplayRecorder : OnWindowRefreshedCallback, Recorder { if (shouldRecord) { val decorViews = windowInspector.getGlobalWindowViews(internalLogger) windowCallbackInterceptor.stopIntercepting(windows) - viewOnDrawInterceptor.intercept(decorViews, privacy, imagePrivacy) + viewOnDrawInterceptor.intercept(decorViews, textAndInputPrivacy, imagePrivacy) } } } diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/SnapshotProducer.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/SnapshotProducer.kt index ef9f64ff82..9aa581a492 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/SnapshotProducer.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/SnapshotProducer.kt @@ -9,9 +9,10 @@ package com.datadog.android.sessionreplay.internal.recorder import android.view.View import android.view.ViewGroup import androidx.annotation.UiThread -import com.datadog.android.internal.profiler.withinBenchmarkSpan +import com.datadog.android.api.InternalLogger import com.datadog.android.sessionreplay.ImagePrivacy -import com.datadog.android.sessionreplay.SessionReplayPrivacy +import com.datadog.android.sessionreplay.R +import com.datadog.android.sessionreplay.TextAndInputPrivacy import com.datadog.android.sessionreplay.internal.async.RecordedDataQueueRefs import com.datadog.android.sessionreplay.model.MobileSegment import com.datadog.android.sessionreplay.recorder.MappingContext @@ -23,14 +24,15 @@ import java.util.LinkedList internal class SnapshotProducer( private val imageWireframeHelper: ImageWireframeHelper, private val treeViewTraversal: TreeViewTraversal, - private val optionSelectorDetector: OptionSelectorDetector + private val optionSelectorDetector: OptionSelectorDetector, + private val internalLogger: InternalLogger ) { @UiThread fun produce( rootView: View, systemInformation: SystemInformation, - privacy: SessionReplayPrivacy, + textAndInputPrivacy: TextAndInputPrivacy, imagePrivacy: ImagePrivacy, recordedDataQueueRefs: RecordedDataQueueRefs ): Node? { @@ -39,7 +41,7 @@ internal class SnapshotProducer( MappingContext( systemInformation = systemInformation, imageWireframeHelper = imageWireframeHelper, - privacy = privacy, + textAndInputPrivacy = textAndInputPrivacy, imagePrivacy = imagePrivacy ), LinkedList(), @@ -55,8 +57,9 @@ internal class SnapshotProducer( parents: LinkedList, recordedDataQueueRefs: RecordedDataQueueRefs ): Node? { - return withinBenchmarkSpan(view::class.java.simpleName) { - val traversedTreeView = treeViewTraversal.traverse(view, mappingContext, recordedDataQueueRefs) + return withinSRBenchmarkSpan(view::class.java.simpleName, view is ViewGroup) { + val localMappingContext = resolvePrivacyOverrides(view, mappingContext) + val traversedTreeView = treeViewTraversal.traverse(view, localMappingContext, recordedDataQueueRefs) val nextTraversalStrategy = traversedTreeView.nextActionStrategy val resolvedWireframes = traversedTreeView.mappedWireframes if (nextTraversalStrategy == TraversalStrategy.STOP_AND_DROP_NODE) { @@ -71,7 +74,7 @@ internal class SnapshotProducer( view.childCount > 0 && nextTraversalStrategy == TraversalStrategy.TRAVERSE_ALL_CHILDREN ) { - val childMappingContext = resolveChildMappingContext(view, mappingContext) + val childMappingContext = resolveChildMappingContext(view, localMappingContext) val parentsCopy = LinkedList(parents).apply { addAll(resolvedWireframes) } for (i in 0 until view.childCount) { val viewChild = view.getChildAt(i) ?: continue @@ -98,4 +101,50 @@ internal class SnapshotProducer( parentMappingContext } } + + private fun resolvePrivacyOverrides(view: View, mappingContext: MappingContext): MappingContext { + val imagePrivacy = + try { + val privacy = view.getTag(R.id.datadog_image_privacy) as? String + if (privacy == null) { + mappingContext.imagePrivacy + } else { + ImagePrivacy.valueOf(privacy) + } + } catch (e: IllegalArgumentException) { + logInvalidPrivacyLevelError(e) + mappingContext.imagePrivacy + } + + val textAndInputPrivacy = + try { + val privacy = view.getTag(R.id.datadog_text_and_input_privacy) as? String + if (privacy == null) { + mappingContext.textAndInputPrivacy + } else { + TextAndInputPrivacy.valueOf(privacy) + } + } catch (e: IllegalArgumentException) { + logInvalidPrivacyLevelError(e) + mappingContext.textAndInputPrivacy + } + + return mappingContext.copy( + imagePrivacy = imagePrivacy, + textAndInputPrivacy = textAndInputPrivacy + ) + } + + private fun logInvalidPrivacyLevelError(e: Exception) { + internalLogger.log( + InternalLogger.Level.ERROR, + listOf(InternalLogger.Target.USER, InternalLogger.Target.TELEMETRY), + { INVALID_PRIVACY_LEVEL_ERROR }, + e + ) + } + + internal companion object { + internal const val INVALID_PRIVACY_LEVEL_ERROR = "Invalid privacy level" + } } diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/TimeBank.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/TimeBank.kt new file mode 100644 index 0000000000..bd12f3e300 --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/TimeBank.kt @@ -0,0 +1,26 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.datadog.android.sessionreplay.internal.recorder + +import com.datadog.tools.annotation.NoOpImplementation + +@NoOpImplementation +internal interface TimeBank { + + /** + * Called to consume execution time from the bank. + */ + fun consume(executionTime: Long) + + /** + * Called to update time bank balance and check if the given timestamp + * is allowed according to the current time balance. + * + * @return true if the given timestamp is allowed by time bank to execute a task, false otherwise. + */ + fun updateAndCheck(timestamp: Long): Boolean +} diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/TreeViewTraversal.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/TreeViewTraversal.kt index cd28e7b71c..ca4c5f66ef 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/TreeViewTraversal.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/TreeViewTraversal.kt @@ -6,6 +6,7 @@ package com.datadog.android.sessionreplay.internal.recorder +import android.graphics.Rect import android.view.View import android.view.ViewGroup import androidx.annotation.UiThread @@ -13,7 +14,12 @@ import com.datadog.android.api.InternalLogger import com.datadog.android.api.feature.measureMethodCallPerf import com.datadog.android.core.metrics.MethodCallSamplingRate import com.datadog.android.sessionreplay.MapperTypeWrapper +import com.datadog.android.sessionreplay.R +import com.datadog.android.sessionreplay.TouchPrivacy +import com.datadog.android.sessionreplay.internal.TouchPrivacyManager import com.datadog.android.sessionreplay.internal.async.RecordedDataQueueRefs +import com.datadog.android.sessionreplay.internal.recorder.SnapshotProducer.Companion.INVALID_PRIVACY_LEVEL_ERROR +import com.datadog.android.sessionreplay.internal.recorder.mapper.HiddenViewMapper import com.datadog.android.sessionreplay.internal.recorder.mapper.QueueStatusCallback import com.datadog.android.sessionreplay.model.MobileSegment import com.datadog.android.sessionreplay.recorder.MappingContext @@ -21,13 +27,16 @@ import com.datadog.android.sessionreplay.recorder.mapper.TraverseAllChildrenMapp import com.datadog.android.sessionreplay.recorder.mapper.WireframeMapper import com.datadog.android.sessionreplay.utils.AsyncJobStatusCallback import com.datadog.android.sessionreplay.utils.NoOpAsyncJobStatusCallback +import java.util.Locale internal class TreeViewTraversal( private val mappers: List>, private val defaultViewMapper: WireframeMapper, + private val hiddenViewMapper: HiddenViewMapper, private val decorViewMapper: WireframeMapper, private val viewUtilsInternal: ViewUtilsInternal, - private val internalLogger: InternalLogger + private val internalLogger: InternalLogger, + private val touchPrivacyManager: TouchPrivacyManager ) { @Suppress("ReturnCount") @@ -50,8 +59,13 @@ internal class TreeViewTraversal( // try to resolve from the exhaustive type mappers var mapper = findMapperForView(view) + updateTouchOverrideAreas(view) - if (mapper != null) { + if (isHidden(view)) { + traversalStrategy = TraversalStrategy.STOP_AND_RETURN_NODE + mapper = hiddenViewMapper + jobStatusCallback = noOpCallback + } else if (mapper != null) { jobStatusCallback = QueueStatusCallback(recordedDataQueueRefs) traversalStrategy = if (mapper is TraverseAllChildrenMapper) { TraversalStrategy.TRAVERSE_ALL_CHILDREN @@ -105,6 +119,43 @@ internal class TreeViewTraversal( return mappers.firstOrNull { it.supportsView(view) }?.getUnsafeMapper() } + private fun isHidden(view: View): Boolean = + view.getTag(R.id.datadog_hidden) == true + + @UiThread + private fun updateTouchOverrideAreas(view: View) { + val touchPrivacy = view.getTag(R.id.datadog_touch_privacy) + + if (touchPrivacy != null) { + val locationOnScreen = IntArray(2) + + // this will always have size >= 2 + @Suppress("UnsafeThirdPartyFunctionCall") + view.getLocationOnScreen(locationOnScreen) + + val x = locationOnScreen[0] + val y = locationOnScreen[1] + val viewArea = Rect( + x - view.paddingLeft, + y - view.paddingTop, + x + view.width + view.paddingRight, + y + view.height + view.paddingBottom + ) + + try { + val privacyLevel = TouchPrivacy.valueOf(touchPrivacy.toString().uppercase(Locale.US)) + touchPrivacyManager.addTouchOverrideArea(viewArea, privacyLevel) + } catch (e: IllegalArgumentException) { + internalLogger.log( + InternalLogger.Level.ERROR, + listOf(InternalLogger.Target.USER, InternalLogger.Target.TELEMETRY), + { INVALID_PRIVACY_LEVEL_ERROR }, + e + ) + } + } + } + data class TraversedTreeView( val mappedWireframes: List, val nextActionStrategy: TraversalStrategy diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/ViewOnDrawInterceptor.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/ViewOnDrawInterceptor.kt index 99d7bc11ce..4686ed185e 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/ViewOnDrawInterceptor.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/ViewOnDrawInterceptor.kt @@ -10,11 +10,13 @@ import android.view.View import android.view.ViewTreeObserver.OnDrawListener import com.datadog.android.api.InternalLogger import com.datadog.android.sessionreplay.ImagePrivacy -import com.datadog.android.sessionreplay.SessionReplayPrivacy +import com.datadog.android.sessionreplay.TextAndInputPrivacy +import com.datadog.android.sessionreplay.internal.TouchPrivacyManager import java.util.WeakHashMap internal class ViewOnDrawInterceptor( private val internalLogger: InternalLogger, + private val touchPrivacyManager: TouchPrivacyManager, private val onDrawListenerProducer: OnDrawListenerProducer ) { internal val decorOnDrawListeners: WeakHashMap = @@ -22,11 +24,12 @@ internal class ViewOnDrawInterceptor( fun intercept( decorViews: List, - sessionReplayPrivacy: SessionReplayPrivacy, + textAndInputPrivacy: TextAndInputPrivacy, imagePrivacy: ImagePrivacy ) { stopInterceptingAndRemove(decorViews) - val onDrawListener = onDrawListenerProducer.create(decorViews, sessionReplayPrivacy, imagePrivacy) + val onDrawListener = + onDrawListenerProducer.create(decorViews, textAndInputPrivacy, imagePrivacy, touchPrivacyManager) decorViews.forEach { decorView -> val viewTreeObserver = decorView.viewTreeObserver if (viewTreeObserver != null && viewTreeObserver.isAlive) { diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/WindowCallbackInterceptor.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/WindowCallbackInterceptor.kt index 047160a299..d1a4989874 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/WindowCallbackInterceptor.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/WindowCallbackInterceptor.kt @@ -10,7 +10,8 @@ import android.content.Context import android.view.Window import com.datadog.android.api.InternalLogger import com.datadog.android.sessionreplay.ImagePrivacy -import com.datadog.android.sessionreplay.SessionReplayPrivacy +import com.datadog.android.sessionreplay.TextAndInputPrivacy +import com.datadog.android.sessionreplay.internal.TouchPrivacyManager import com.datadog.android.sessionreplay.internal.async.RecordedDataQueueHandler import com.datadog.android.sessionreplay.internal.recorder.callback.NoOpWindowCallback import com.datadog.android.sessionreplay.internal.recorder.callback.RecorderWindowCallback @@ -22,8 +23,9 @@ internal class WindowCallbackInterceptor( private val viewOnDrawInterceptor: ViewOnDrawInterceptor, private val timeProvider: TimeProvider, private val internalLogger: InternalLogger, - private val privacy: SessionReplayPrivacy, - private val imagePrivacy: ImagePrivacy + private val imagePrivacy: ImagePrivacy, + private val textAndInputPrivacy: TextAndInputPrivacy, + private val touchPrivacyManager: TouchPrivacyManager ) { private val wrappedWindows: WeakHashMap = WeakHashMap() @@ -57,8 +59,9 @@ internal class WindowCallbackInterceptor( timeProvider, viewOnDrawInterceptor, internalLogger, - privacy, - imagePrivacy + textAndInputPrivacy, + imagePrivacy, + touchPrivacyManager ) } diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/callback/RecorderWindowCallback.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/callback/RecorderWindowCallback.kt index 376f899251..40293a5ae4 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/callback/RecorderWindowCallback.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/callback/RecorderWindowCallback.kt @@ -7,12 +7,14 @@ package com.datadog.android.sessionreplay.internal.recorder.callback import android.content.Context +import android.graphics.Point import android.view.MotionEvent import android.view.Window import androidx.annotation.MainThread import com.datadog.android.api.InternalLogger import com.datadog.android.sessionreplay.ImagePrivacy -import com.datadog.android.sessionreplay.SessionReplayPrivacy +import com.datadog.android.sessionreplay.TextAndInputPrivacy +import com.datadog.android.sessionreplay.internal.TouchPrivacyManager import com.datadog.android.sessionreplay.internal.async.RecordedDataQueueHandler import com.datadog.android.sessionreplay.internal.recorder.ViewOnDrawInterceptor import com.datadog.android.sessionreplay.internal.recorder.WindowInspector @@ -30,8 +32,9 @@ internal class RecorderWindowCallback( private val timeProvider: TimeProvider, private val viewOnDrawInterceptor: ViewOnDrawInterceptor, private val internalLogger: InternalLogger, - private val privacy: SessionReplayPrivacy, + private val privacy: TextAndInputPrivacy, private val imagePrivacy: ImagePrivacy, + private val touchPrivacyManager: TouchPrivacyManager, private val copyEvent: (MotionEvent) -> MotionEvent = { @Suppress("UnsafeThirdPartyFunctionCall") // NPE cannot happen here MotionEvent.obtain(it) @@ -45,19 +48,27 @@ internal class RecorderWindowCallback( internal var pointerInteractions: MutableList = LinkedList() private var lastOnMoveUpdateTimeInNs: Long = 0L private var lastPerformedFlushTimeInNs: Long = System.nanoTime() + private var shouldRecordMotion: Boolean = false // region Window.Callback @MainThread override fun dispatchTouchEvent(event: MotionEvent?): Boolean { if (event != null) { - // we copy it and delegate it to the gesture detector for analysis - @Suppress("UnsafeThirdPartyFunctionCall") // internal safe call - val copy = copyEvent(event) - try { - handleEvent(copy) - } finally { - copy.recycle() + if (event.action == MotionEvent.ACTION_DOWN) { + val touchLocation = Point(event.x.toInt(), event.y.toInt()) + shouldRecordMotion = touchPrivacyManager.shouldRecordTouch(touchLocation) + } + + if (shouldRecordMotion) { + // we copy it and delegate it to the gesture detector for analysis + @Suppress("UnsafeThirdPartyFunctionCall") // internal safe call + val copy = copyEvent(event) + try { + handleEvent(copy) + } finally { + copy.recycle() + } } } else { internalLogger.log( @@ -178,7 +189,7 @@ internal class RecorderWindowCallback( viewOnDrawInterceptor.stopIntercepting() viewOnDrawInterceptor.intercept( decorViews = rootViews, - sessionReplayPrivacy = privacy, + textAndInputPrivacy = privacy, imagePrivacy = imagePrivacy ) } diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/listener/WindowsOnDrawListener.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/listener/WindowsOnDrawListener.kt index 58f53ac065..9d75b12fda 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/listener/WindowsOnDrawListener.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/listener/WindowsOnDrawListener.kt @@ -10,15 +10,16 @@ import android.view.View import android.view.ViewTreeObserver import androidx.annotation.MainThread import androidx.annotation.UiThread -import com.datadog.android.api.InternalLogger +import com.datadog.android.api.feature.FeatureSdkCore import com.datadog.android.api.feature.measureMethodCallPerf -import com.datadog.android.internal.profiler.withinBenchmarkSpan import com.datadog.android.sessionreplay.ImagePrivacy -import com.datadog.android.sessionreplay.SessionReplayPrivacy +import com.datadog.android.sessionreplay.TextAndInputPrivacy +import com.datadog.android.sessionreplay.internal.TouchPrivacyManager import com.datadog.android.sessionreplay.internal.async.RecordedDataQueueHandler import com.datadog.android.sessionreplay.internal.async.RecordedDataQueueRefs import com.datadog.android.sessionreplay.internal.recorder.Debouncer import com.datadog.android.sessionreplay.internal.recorder.SnapshotProducer +import com.datadog.android.sessionreplay.internal.recorder.withinSRBenchmarkSpan import com.datadog.android.sessionreplay.internal.utils.MiscUtils import java.lang.ref.WeakReference @@ -26,11 +27,16 @@ internal class WindowsOnDrawListener( zOrderedDecorViews: List, private val recordedDataQueueHandler: RecordedDataQueueHandler, private val snapshotProducer: SnapshotProducer, - private val privacy: SessionReplayPrivacy, + private val textAndInputPrivacy: TextAndInputPrivacy, private val imagePrivacy: ImagePrivacy, - private val debouncer: Debouncer = Debouncer(), private val miscUtils: MiscUtils = MiscUtils, - private val internalLogger: InternalLogger, + private val sdkCore: FeatureSdkCore, + dynamicOptimizationEnabled: Boolean, + private val touchPrivacyManager: TouchPrivacyManager, + private val debouncer: Debouncer = Debouncer( + sdkCore = sdkCore, + dynamicOptimizationEnabled = dynamicOptimizationEnabled + ), private val methodCallSamplingRate: Float ) : ViewTreeObserver.OnDrawListener { @@ -53,19 +59,19 @@ internal class WindowsOnDrawListener( val systemInformation = miscUtils.resolveSystemInformation(context) val item = recordedDataQueueHandler.addSnapshotItem(systemInformation) ?: return - val nodes = internalLogger.measureMethodCallPerf( + val nodes = sdkCore.internalLogger.measureMethodCallPerf( METHOD_CALL_CALLER_CLASS, METHOD_CALL_CAPTURE_RECORD, methodCallSamplingRate ) { - withinBenchmarkSpan(BENCHMARK_SPAN_SNAPSHOT_PRODUCER) { + withinSRBenchmarkSpan(BENCHMARK_SPAN_SNAPSHOT_PRODUCER, isContainer = true) { val recordedDataQueueRefs = RecordedDataQueueRefs(recordedDataQueueHandler) recordedDataQueueRefs.recordedDataQueueItem = item rootViews.mapNotNull { snapshotProducer.produce( rootView = it, systemInformation = systemInformation, - privacy = privacy, + textAndInputPrivacy = textAndInputPrivacy, imagePrivacy = imagePrivacy, recordedDataQueueRefs = recordedDataQueueRefs ) @@ -82,6 +88,8 @@ internal class WindowsOnDrawListener( if (item.isReady()) { recordedDataQueueHandler.tryToConsumeItems() } + + touchPrivacyManager.updateCurrentTouchOverrideAreas() } } diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/ButtonMapper.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/ButtonMapper.kt index a7974b50b1..6bbd6c9225 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/ButtonMapper.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/ButtonMapper.kt @@ -37,19 +37,6 @@ internal class ButtonMapper( asyncJobStatusCallback: AsyncJobStatusCallback, internalLogger: InternalLogger ): List { - return super.map(view, mappingContext, asyncJobStatusCallback, internalLogger).map { - if (it is MobileSegment.Wireframe.TextWireframe && - it.shapeStyle == null && it.border == null - ) { - // we were not able to resolve the background for this button so just add a border - it.copy(border = MobileSegment.ShapeBorder(BLACK_COLOR, 1)) - } else { - it - } - } - } - - companion object { - internal const val BLACK_COLOR = "#000000ff" + return super.map(view, mappingContext, asyncJobStatusCallback, internalLogger) } } diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/CheckableTextViewMapper.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/CheckableTextViewMapper.kt index 5cb92eff16..de8e4165a9 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/CheckableTextViewMapper.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/CheckableTextViewMapper.kt @@ -111,7 +111,7 @@ internal abstract class CheckableTextViewMapper( ) mappingContext.imageWireframeHelper.createImageWireframeByDrawable( view = view, - imagePrivacy = mappingContext.imagePrivacy, + imagePrivacy = mapInputPrivacyToImagePrivacy(mappingContext.textAndInputPrivacy), currentWireframeIndex = 0, x = checkBoxBounds.x, y = checkBoxBounds.y, diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/CheckableWireframeMapper.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/CheckableWireframeMapper.kt index 7c14b9b622..42458e289a 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/CheckableWireframeMapper.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/CheckableWireframeMapper.kt @@ -10,7 +10,8 @@ import android.view.View import android.widget.Checkable import androidx.annotation.UiThread import com.datadog.android.api.InternalLogger -import com.datadog.android.sessionreplay.SessionReplayPrivacy +import com.datadog.android.sessionreplay.ImagePrivacy +import com.datadog.android.sessionreplay.TextAndInputPrivacy import com.datadog.android.sessionreplay.model.MobileSegment import com.datadog.android.sessionreplay.recorder.MappingContext import com.datadog.android.sessionreplay.recorder.mapper.BaseWireframeMapper @@ -40,7 +41,7 @@ internal abstract class CheckableWireframeMapper( internalLogger: InternalLogger ): List { val mainWireframes = resolveMainWireframes(view, mappingContext, asyncJobStatusCallback, internalLogger) - val checkableWireframes = if (mappingContext.privacy != SessionReplayPrivacy.ALLOW) { + val checkableWireframes = if (mappingContext.textAndInputPrivacy != TextAndInputPrivacy.MASK_SENSITIVE_INPUTS) { resolveMaskedCheckable(view, mappingContext) } else { // Resolves checkable view regardless the state @@ -52,6 +53,14 @@ internal abstract class CheckableWireframeMapper( return mainWireframes } + protected fun mapInputPrivacyToImagePrivacy(inputPrivacy: TextAndInputPrivacy): ImagePrivacy { + return when (inputPrivacy) { + TextAndInputPrivacy.MASK_SENSITIVE_INPUTS -> ImagePrivacy.MASK_NONE + TextAndInputPrivacy.MASK_ALL_INPUTS, + TextAndInputPrivacy.MASK_ALL -> ImagePrivacy.MASK_ALL + } + } + @UiThread abstract fun resolveMainWireframes( view: T, diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/HiddenViewMapper.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/HiddenViewMapper.kt new file mode 100644 index 0000000000..507c1a9f06 --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/HiddenViewMapper.kt @@ -0,0 +1,50 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.datadog.android.sessionreplay.internal.recorder.mapper + +import android.view.View +import com.datadog.android.api.InternalLogger +import com.datadog.android.sessionreplay.model.MobileSegment +import com.datadog.android.sessionreplay.recorder.MappingContext +import com.datadog.android.sessionreplay.recorder.mapper.WireframeMapper +import com.datadog.android.sessionreplay.utils.AsyncJobStatusCallback +import com.datadog.android.sessionreplay.utils.ViewBoundsResolver +import com.datadog.android.sessionreplay.utils.ViewIdentifierResolver + +internal class HiddenViewMapper( + val viewIdentifierResolver: ViewIdentifierResolver, + val viewBoundsResolver: ViewBoundsResolver +) : WireframeMapper { + override fun map( + view: View, + mappingContext: MappingContext, + asyncJobStatusCallback: AsyncJobStatusCallback, + internalLogger: InternalLogger + ): List { + val id = viewIdentifierResolver.resolveChildUniqueIdentifier(view, HIDDEN_KEY_NAME) + ?: return emptyList() + + val density = mappingContext.systemInformation.screenDensity + val viewGlobalBounds = viewBoundsResolver.resolveViewGlobalBounds(view, density) + + return listOf( + MobileSegment.Wireframe.PlaceholderWireframe( + id = id, + x = viewGlobalBounds.x, + y = viewGlobalBounds.y, + width = viewGlobalBounds.width, + height = viewGlobalBounds.height, + label = HIDDEN_VIEW_PLACEHOLDER_TEXT + ) + ) + } + + internal companion object { + internal const val HIDDEN_VIEW_PLACEHOLDER_TEXT = "Hidden" + private const val HIDDEN_KEY_NAME = "hidden" + } +} diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/ImageViewMapper.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/ImageViewMapper.kt index c5b7f1d8be..e7824cb404 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/ImageViewMapper.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/ImageViewMapper.kt @@ -57,28 +57,25 @@ internal class ImageViewMapper( val contentYPosInDp = contentRect.top.densityNormalized(density).toLong() val contentWidthPx = contentRect.width() val contentHeightPx = contentRect.height() - val contentDrawable = drawable.constantState?.newDrawable(resources) - if (contentDrawable != null) { - // resolve foreground - mappingContext.imageWireframeHelper.createImageWireframeByDrawable( - view = view, - imagePrivacy = mappingContext.imagePrivacy, - currentWireframeIndex = wireframes.size, - x = contentXPosInDp, - y = contentYPosInDp, - width = contentWidthPx, - height = contentHeightPx, - usePIIPlaceholder = true, - drawable = contentDrawable, - asyncJobStatusCallback = asyncJobStatusCallback, - clipping = clipping, - shapeStyle = null, - border = null, - prefix = ImageWireframeHelper.DRAWABLE_CHILD_NAME - )?.let { - wireframes.add(it) - } + // resolve foreground + mappingContext.imageWireframeHelper.createImageWireframeByDrawable( + view = view, + imagePrivacy = mappingContext.imagePrivacy, + currentWireframeIndex = wireframes.size, + x = contentXPosInDp, + y = contentYPosInDp, + width = contentWidthPx, + height = contentHeightPx, + usePIIPlaceholder = true, + drawable = drawable, + asyncJobStatusCallback = asyncJobStatusCallback, + clipping = clipping, + shapeStyle = null, + border = null, + prefix = ImageWireframeHelper.DRAWABLE_CHILD_NAME + )?.let { + wireframes.add(it) } return wireframes diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/NumberPickerMapper.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/NumberPickerMapper.kt index e6e8960f78..292f285a14 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/NumberPickerMapper.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/NumberPickerMapper.kt @@ -11,7 +11,7 @@ import android.widget.NumberPicker import androidx.annotation.RequiresApi import androidx.annotation.UiThread import com.datadog.android.api.InternalLogger -import com.datadog.android.sessionreplay.SessionReplayPrivacy +import com.datadog.android.sessionreplay.TextAndInputPrivacy import com.datadog.android.sessionreplay.model.MobileSegment import com.datadog.android.sessionreplay.recorder.MappingContext import com.datadog.android.sessionreplay.recorder.SystemInformation @@ -73,7 +73,7 @@ internal open class NumberPickerMapper( return map( view, mappingContext.systemInformation, - mappingContext.privacy, + mappingContext.textAndInputPrivacy, prevIndexLabelId, topDividerId, selectedIndexLabelId, @@ -89,7 +89,7 @@ internal open class NumberPickerMapper( private fun map( view: NumberPicker, systemInformation: SystemInformation, - privacy: SessionReplayPrivacy, + textAndInputPrivacy: TextAndInputPrivacy, prevIndexLabelId: Long, topDividerId: Long, selectedIndexLabelId: Long, @@ -167,7 +167,7 @@ internal open class NumberPickerMapper( nextPrevLabelTextColor ) - return if (privacy == SessionReplayPrivacy.ALLOW) { + return if (textAndInputPrivacy == TextAndInputPrivacy.MASK_SENSITIVE_INPUTS) { listOf( prevValueLabelWireframe, topDividerWireframe, diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/ProgressBarWireframeMapper.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/ProgressBarWireframeMapper.kt index 8698aa168c..621cc5e6fb 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/ProgressBarWireframeMapper.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/ProgressBarWireframeMapper.kt @@ -14,7 +14,7 @@ import android.widget.ProgressBar import androidx.annotation.RequiresApi import androidx.annotation.UiThread import com.datadog.android.api.InternalLogger -import com.datadog.android.sessionreplay.SessionReplayPrivacy +import com.datadog.android.sessionreplay.TextAndInputPrivacy import com.datadog.android.sessionreplay.internal.recorder.densityNormalized import com.datadog.android.sessionreplay.model.MobileSegment import com.datadog.android.sessionreplay.recorder.MappingContext @@ -69,8 +69,12 @@ internal open class ProgressBarWireframeMapper

( buildNonActiveTrackWireframe(view, trackBounds, trackColor)?.let(wireframes::add) val hasProgress = !view.isIndeterminate - val showProgress = (mappingContext.privacy == SessionReplayPrivacy.ALLOW) || - (mappingContext.privacy == SessionReplayPrivacy.MASK_USER_INPUT && showProgressWhenMaskUserInput) + val showProgress = + (mappingContext.textAndInputPrivacy == TextAndInputPrivacy.MASK_SENSITIVE_INPUTS) || + ( + mappingContext.textAndInputPrivacy == TextAndInputPrivacy.MASK_ALL_INPUTS && + showProgressWhenMaskUserInput + ) if (hasProgress && showProgress) { val normalizedProgress = normalizedProgress(view) diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/SeekBarWireframeMapper.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/SeekBarWireframeMapper.kt index fd4d6c21c8..c2cd516288 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/SeekBarWireframeMapper.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/SeekBarWireframeMapper.kt @@ -8,7 +8,7 @@ package com.datadog.android.sessionreplay.internal.recorder.mapper import android.widget.SeekBar import com.datadog.android.api.InternalLogger -import com.datadog.android.sessionreplay.SessionReplayPrivacy +import com.datadog.android.sessionreplay.TextAndInputPrivacy import com.datadog.android.sessionreplay.internal.recorder.densityNormalized import com.datadog.android.sessionreplay.model.MobileSegment import com.datadog.android.sessionreplay.recorder.MappingContext @@ -55,7 +55,7 @@ internal open class SeekBarWireframeMapper( normalizedProgress ) - if (mappingContext.privacy == SessionReplayPrivacy.ALLOW) { + if (mappingContext.textAndInputPrivacy == TextAndInputPrivacy.MASK_SENSITIVE_INPUTS) { val screenDensity = mappingContext.systemInformation.screenDensity val trackHeight = ProgressBarWireframeMapper.TRACK_HEIGHT_IN_PX.densityNormalized(screenDensity) val thumbColor = getColor(view.thumbTintList, view.drawableState) ?: getDefaultColor(view) diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/SwitchCompatMapper.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/SwitchCompatMapper.kt index ca9e2b3151..d0f86600f3 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/SwitchCompatMapper.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/SwitchCompatMapper.kt @@ -6,10 +6,13 @@ package com.datadog.android.sessionreplay.internal.recorder.mapper +import android.content.res.Resources +import android.graphics.drawable.Drawable import androidx.annotation.UiThread import androidx.appcompat.widget.SwitchCompat import com.datadog.android.api.InternalLogger import com.datadog.android.sessionreplay.internal.recorder.densityNormalized +import com.datadog.android.sessionreplay.internal.recorder.resources.DrawableCopier import com.datadog.android.sessionreplay.model.MobileSegment import com.datadog.android.sessionreplay.recorder.MappingContext import com.datadog.android.sessionreplay.recorder.mapper.TextViewMapper @@ -71,28 +74,35 @@ internal open class SwitchCompatMapper( mappingContext.systemInformation.screenDensity ) return trackBounds?.let { - return view.trackDrawable.constantState?.newDrawable(view.resources)?.apply { - setState(view.trackDrawable.state) - bounds = view.trackDrawable.bounds - view.trackTintList?.let { - setTintList(it) + val trackDrawable = view.trackDrawable + val drawableCopier = object : DrawableCopier { + override fun copy(originalDrawable: Drawable, resources: Resources): Drawable? { + return originalDrawable.constantState?.newDrawable(view.resources)?.apply { + setState(view.trackDrawable.state) + bounds = view.trackDrawable.bounds + view.trackTintList?.let { + setTintList(it) + } + } } - }?.let { drawable -> - mappingContext.imageWireframeHelper.createImageWireframeByDrawable( - view = view, - imagePrivacy = mappingContext.imagePrivacy, - currentWireframeIndex = prevIndex + 1, - x = trackBounds.x.densityNormalized(mappingContext.systemInformation.screenDensity).toLong(), - y = trackBounds.y.densityNormalized(mappingContext.systemInformation.screenDensity).toLong(), - width = trackBounds.width, - height = trackBounds.height, - drawable = drawable, - shapeStyle = null, - border = null, - usePIIPlaceholder = true, - asyncJobStatusCallback = asyncJobStatusCallback - ) } + return mappingContext.imageWireframeHelper.createImageWireframeByDrawable( + view = view, + imagePrivacy = mapInputPrivacyToImagePrivacy(mappingContext.textAndInputPrivacy), + currentWireframeIndex = prevIndex + 1, + x = it.x.densityNormalized(mappingContext.systemInformation.screenDensity) + .toLong(), + y = it.y.densityNormalized(mappingContext.systemInformation.screenDensity) + .toLong(), + width = it.width, + height = it.height, + drawable = trackDrawable, + drawableCopier = drawableCopier, + shapeStyle = null, + border = null, + usePIIPlaceholder = true, + asyncJobStatusCallback = asyncJobStatusCallback + ) } } @@ -107,25 +117,38 @@ internal open class SwitchCompatMapper( mappingContext.systemInformation.screenDensity ) - return view.thumbDrawable?.let { drawable -> - thumbBounds?.let { thumbBounds -> - mappingContext.imageWireframeHelper.createImageWireframeByDrawable( - view = view, - imagePrivacy = mappingContext.imagePrivacy, - currentWireframeIndex = prevIndex + 1, - x = thumbBounds.x.densityNormalized(mappingContext.systemInformation.screenDensity).toLong(), - y = thumbBounds.y.densityNormalized(mappingContext.systemInformation.screenDensity).toLong(), - width = drawable.intrinsicWidth, - height = drawable.intrinsicHeight, - drawable = drawable, - shapeStyle = null, - border = null, - usePIIPlaceholder = true, - clipping = null, - asyncJobStatusCallback = asyncJobStatusCallback - ) + val thumbDrawable = view.thumbDrawable + val drawableCopier = object : DrawableCopier { + override fun copy(originalDrawable: Drawable, resources: Resources): Drawable? { + return originalDrawable.constantState?.newDrawable(view.resources)?.apply { + setState(view.thumbDrawable.state) + bounds = view.thumbDrawable.bounds + view.thumbTintList?.let { + setTintList(it) + } + } } } + return thumbBounds?.let { + mappingContext.imageWireframeHelper.createImageWireframeByDrawable( + view = view, + imagePrivacy = mapInputPrivacyToImagePrivacy(mappingContext.textAndInputPrivacy), + currentWireframeIndex = prevIndex + 1, + x = it.x.densityNormalized(mappingContext.systemInformation.screenDensity) + .toLong(), + y = it.y.densityNormalized(mappingContext.systemInformation.screenDensity) + .toLong(), + width = thumbDrawable.intrinsicWidth, + height = thumbDrawable.intrinsicHeight, + drawable = thumbDrawable, + drawableCopier = drawableCopier, + shapeStyle = null, + border = null, + usePIIPlaceholder = true, + clipping = null, + asyncJobStatusCallback = asyncJobStatusCallback + ) + } } private fun resolveThumbBounds(view: SwitchCompat, pixelsDensity: Float): GlobalBoundsInPx? { diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/resources/BitmapPool.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/resources/BitmapPool.kt index 3894e73d5f..b071489414 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/resources/BitmapPool.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/resources/BitmapPool.kt @@ -118,7 +118,7 @@ internal class BitmapPool( cacheUtils.handleTrimMemory(level, cache) } - internal fun getBitmapByProperties(width: Int, height: Int, config: Config): Bitmap? { + internal fun getBitmapByProperties(width: Int, height: Int, config: Config?): Bitmap? { val key = bitmapPoolHelper.generateKey(width, height, config) return get(key) } diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/resources/BitmapPoolHelper.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/resources/BitmapPoolHelper.kt index 63d0b91100..7c2da4df53 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/resources/BitmapPoolHelper.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/resources/BitmapPoolHelper.kt @@ -15,10 +15,10 @@ internal class BitmapPoolHelper( internal fun generateKey(bitmap: Bitmap) = generateKey(bitmap.width, bitmap.height, bitmap.config) - internal fun generateKey(width: Int, height: Int, config: Bitmap.Config) = + internal fun generateKey(width: Int, height: Int, config: Bitmap.Config?) = "$width-$height-$config" - internal fun safeCall(call: () -> R): R? = + internal fun safeCall(call: () -> R): R? = invocationUtils.safeCallWithErrorLogging( call = { call() }, failureMessage = BITMAP_OPERATION_FAILED diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/resources/DefaultDrawableCopier.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/resources/DefaultDrawableCopier.kt new file mode 100644 index 0000000000..8bd17fdba1 --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/resources/DefaultDrawableCopier.kt @@ -0,0 +1,21 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.datadog.android.sessionreplay.internal.recorder.resources + +import android.content.res.Resources +import android.graphics.drawable.Drawable +import com.datadog.android.lint.InternalApi + +/** + * Default implementation of [DrawableCopier] interface, it copies the drawable from constant state. + */ +@InternalApi +class DefaultDrawableCopier : DrawableCopier { + override fun copy(originalDrawable: Drawable, resources: Resources): Drawable? { + return originalDrawable.constantState?.newDrawable(resources) + } +} diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/resources/DefaultImageWireframeHelper.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/resources/DefaultImageWireframeHelper.kt index fb7834d2c1..2471adee7d 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/resources/DefaultImageWireframeHelper.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/resources/DefaultImageWireframeHelper.kt @@ -113,6 +113,7 @@ internal class DefaultImageWireframeHelper( height: Int, usePIIPlaceholder: Boolean, drawable: Drawable, + drawableCopier: DrawableCopier, asyncJobStatusCallback: AsyncJobStatusCallback, clipping: MobileSegment.WireframeClip?, shapeStyle: MobileSegment.ShapeStyle?, @@ -155,13 +156,18 @@ internal class DefaultImageWireframeHelper( } val density = displayMetrics.density + val drawableWidthDp = drawableProperties.drawableWidth.densityNormalized(density).toLong() + val drawableHeightDp = drawableProperties.drawableHeight.densityNormalized(density).toLong() if (imagePrivacy == ImagePrivacy.MASK_ALL) { return createContentPlaceholderWireframe( id = id, - view = view, - density = density, - label = MASK_ALL_CONTENT_LABEL + x = x, + y = y, + width = drawableWidthDp, + height = drawableHeightDp, + label = MASK_ALL_CONTENT_LABEL, + clipping = clipping ) } @@ -169,15 +175,15 @@ internal class DefaultImageWireframeHelper( if (shouldMaskContextualImage(imagePrivacy, usePIIPlaceholder, drawable, density)) { return createContentPlaceholderWireframe( id = id, - view = view, - density = density, - label = MASK_CONTEXTUAL_CONTENT_LABEL + x = x, + y = y, + width = drawableWidthDp, + height = drawableHeightDp, + label = MASK_CONTEXTUAL_CONTENT_LABEL, + clipping = clipping ) } - val drawableWidthDp = drawableProperties.drawableWidth.densityNormalized(density).toLong() - val drawableHeightDp = drawableProperties.drawableHeight.densityNormalized(density).toLong() - val imageWireframe = MobileSegment.Wireframe.ImageWireframe( id = id, @@ -197,7 +203,8 @@ internal class DefaultImageWireframeHelper( resources = resources, applicationContext = applicationContext, displayMetrics = displayMetrics, - drawable = drawableProperties.drawable, + originalDrawable = drawableProperties.drawable, + drawableCopier = drawableCopier, drawableWidth = width, drawableHeight = height, resourceResolverCallback = object : ResourceResolverCallback { @@ -318,24 +325,22 @@ internal class DefaultImageWireframeHelper( } private fun createContentPlaceholderWireframe( - view: View, id: Long, - density: Float, - label: String + x: Long, + y: Long, + width: Long, + height: Long, + label: String, + clipping: MobileSegment.WireframeClip? ): MobileSegment.Wireframe.PlaceholderWireframe { - val coordinates = IntArray(2) - @Suppress("UnsafeThirdPartyFunctionCall") // this will always have size >= 2 - view.getLocationOnScreen(coordinates) - val viewX = coordinates[0].densityNormalized(density).toLong() - val viewY = coordinates[1].densityNormalized(density).toLong() - return MobileSegment.Wireframe.PlaceholderWireframe( id, - viewX, - viewY, - view.width.densityNormalized(density).toLong(), - view.height.densityNormalized(density).toLong(), - label = label + x, + y, + width, + height, + label = label, + clip = clipping ) } diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/resources/DrawableCopier.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/resources/DrawableCopier.kt new file mode 100644 index 0000000000..83c0cbab21 --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/resources/DrawableCopier.kt @@ -0,0 +1,27 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.datadog.android.sessionreplay.internal.recorder.resources + +import android.content.res.Resources +import android.graphics.drawable.Drawable +import com.datadog.android.lint.InternalApi + +/** + * Interface of copying drawable to a new one. + */ +@InternalApi +interface DrawableCopier { + + /** + * Called to copy the drawable. + * @param originalDrawable the original drawable to copy + * @param resources resources of the view. + * + * @return New copied drawable. + */ + fun copy(originalDrawable: Drawable, resources: Resources): Drawable? +} diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/resources/MD5HashGenerator.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/resources/MD5HashGenerator.kt index c8b0a0bbce..d880255686 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/resources/MD5HashGenerator.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/resources/MD5HashGenerator.kt @@ -7,9 +7,9 @@ package com.datadog.android.sessionreplay.internal.recorder.resources import com.datadog.android.api.InternalLogger +import com.datadog.android.internal.utils.toHexString import java.security.MessageDigest import java.security.NoSuchAlgorithmException -import java.util.Locale internal class MD5HashGenerator( private val logger: InternalLogger @@ -21,7 +21,7 @@ internal class MD5HashGenerator( val hashBytes = messageDigest.digest() - hashBytes.joinToString(separator = "") { "%02x".format(Locale.US, it) } + hashBytes.toHexString() } catch (e: NoSuchAlgorithmException) { logger.log( InternalLogger.Level.ERROR, diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/resources/ResourceResolver.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/resources/ResourceResolver.kt index 3ab6b51e5a..80fd6c33c7 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/resources/ResourceResolver.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/resources/ResourceResolver.kt @@ -76,14 +76,15 @@ internal class ResourceResolver( resources: Resources, applicationContext: Context, displayMetrics: DisplayMetrics, - drawable: Drawable, + originalDrawable: Drawable, + drawableCopier: DrawableCopier, drawableWidth: Int, drawableHeight: Int, resourceResolverCallback: ResourceResolverCallback ) { bitmapCachesManager.registerCallbacks(applicationContext) - val resourceId = tryToGetResourceFromCache(drawable = drawable) + val resourceId = tryToGetResourceFromCache(drawable = originalDrawable) if (resourceId != null) { // if we got here it means we saw the bitmap before, @@ -92,9 +93,11 @@ internal class ResourceResolver( return } + val copiedDrawable = drawableCopier.copy(originalDrawable, resources) ?: return + val bitmapFromDrawable = - if (drawable is BitmapDrawable && shouldUseDrawableBitmap(drawable)) { - drawable.bitmap // cannot be null - we already checked in shouldUseDrawableBitmap + if (copiedDrawable is BitmapDrawable && shouldUseDrawableBitmap(copiedDrawable)) { + copiedDrawable.bitmap // cannot be null - we already checked in shouldUseDrawableBitmap } else { null } @@ -103,7 +106,7 @@ internal class ResourceResolver( threadPoolExecutor.executeSafe("resolveResourceId", logger) { createBitmap( resources = resources, - drawable = drawable, + drawable = originalDrawable, drawableWidth = drawableWidth, drawableHeight = drawableHeight, displayMetrics = displayMetrics, diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/utils/CacheUtils.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/utils/CacheUtils.kt index 0fdff54672..03945c3624 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/utils/CacheUtils.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/utils/CacheUtils.kt @@ -12,6 +12,10 @@ import androidx.collection.LruCache internal class CacheUtils( private val invocationUtils: InvocationUtils = InvocationUtils() ) { + + // some of this memory level are not being triggered after API 34. We still need to keep them for now + // for lower versions + @Suppress("DEPRECATION") internal fun handleTrimMemory(level: Int, cache: LruCache) { @Suppress("MagicNumber") val onLowMemorySizeBytes = cache.maxSize() / 2 // 50% diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/recorder/MappingContext.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/recorder/MappingContext.kt index cc7ef7c682..43b906b098 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/recorder/MappingContext.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/recorder/MappingContext.kt @@ -7,7 +7,7 @@ package com.datadog.android.sessionreplay.recorder import com.datadog.android.sessionreplay.ImagePrivacy -import com.datadog.android.sessionreplay.SessionReplayPrivacy +import com.datadog.android.sessionreplay.TextAndInputPrivacy import com.datadog.android.sessionreplay.utils.ImageWireframeHelper /** @@ -16,7 +16,7 @@ import com.datadog.android.sessionreplay.utils.ImageWireframeHelper * expected by Datadog. * @param systemInformation as [SystemInformation] * @param imageWireframeHelper a helper tool to capture images within a View - * @param privacy the masking configuration to use when building the wireframes + * @param textAndInputPrivacy the text and input privacy level to use when building the wireframes * @param imagePrivacy the image recording configuration to use when building the wireframes * @param hasOptionSelectorParent tells if one of the parents of the current [android.view.View] * is an option selector type (e.g. time picker, date picker, drop - down list) @@ -24,7 +24,7 @@ import com.datadog.android.sessionreplay.utils.ImageWireframeHelper data class MappingContext( val systemInformation: SystemInformation, val imageWireframeHelper: ImageWireframeHelper, - val privacy: SessionReplayPrivacy, + val textAndInputPrivacy: TextAndInputPrivacy, val imagePrivacy: ImagePrivacy, val hasOptionSelectorParent: Boolean = false ) diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/recorder/mapper/BaseAsyncBackgroundWireframeMapper.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/recorder/mapper/BaseAsyncBackgroundWireframeMapper.kt index 2b28b1134c..5bc27d4bcb 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/recorder/mapper/BaseAsyncBackgroundWireframeMapper.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/recorder/mapper/BaseAsyncBackgroundWireframeMapper.kt @@ -124,30 +124,23 @@ abstract class BaseAsyncBackgroundWireframeMapper internal construc mappingContext: MappingContext, asyncJobStatusCallback: AsyncJobStatusCallback ): MobileSegment.Wireframe? { - val resources = view.resources - - val drawableCopy = view.background?.constantState?.newDrawable(resources) - - return if (drawableCopy != null) { - mappingContext.imageWireframeHelper.createImageWireframeByDrawable( - view = view, - imagePrivacy = mappingContext.imagePrivacy, - currentWireframeIndex = 0, - x = bounds.x, - y = bounds.y, - width = width, - height = height, - usePIIPlaceholder = false, - drawable = drawableCopy, - asyncJobStatusCallback = asyncJobStatusCallback, - clipping = MobileSegment.WireframeClip(), - shapeStyle = null, - border = null, - prefix = PREFIX_BACKGROUND_DRAWABLE - ) - } else { - null - } + val background = view.background ?: return null + return mappingContext.imageWireframeHelper.createImageWireframeByDrawable( + view = view, + imagePrivacy = mappingContext.imagePrivacy, + currentWireframeIndex = 0, + x = bounds.x, + y = bounds.y, + width = width, + height = height, + usePIIPlaceholder = false, + drawable = background, + asyncJobStatusCallback = asyncJobStatusCallback, + clipping = MobileSegment.WireframeClip(), + shapeStyle = null, + border = null, + prefix = PREFIX_BACKGROUND_DRAWABLE + ) } companion object { diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/recorder/mapper/EditTextMapper.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/recorder/mapper/EditTextMapper.kt index 44a0ca3a69..04c0da0a39 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/recorder/mapper/EditTextMapper.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/recorder/mapper/EditTextMapper.kt @@ -9,7 +9,7 @@ package com.datadog.android.sessionreplay.recorder.mapper import android.text.InputType import android.widget.EditText import android.widget.TextView -import com.datadog.android.sessionreplay.SessionReplayPrivacy +import com.datadog.android.sessionreplay.TextAndInputPrivacy import com.datadog.android.sessionreplay.internal.recorder.obfuscator.StringObfuscator import com.datadog.android.sessionreplay.utils.ColorStringFormatter import com.datadog.android.sessionreplay.utils.DrawableToColorMapper @@ -34,21 +34,25 @@ class EditTextMapper( drawableToColorMapper ) { - override fun resolveCapturedText(textView: EditText, privacy: SessionReplayPrivacy, isOption: Boolean): String { + override fun resolveCapturedText( + textView: EditText, + textAndInputPrivacy: TextAndInputPrivacy, + isOption: Boolean + ): String { val text = textView.text?.toString().orEmpty() val hint = textView.hint?.toString().orEmpty() return if (text.isNotEmpty()) { - resolveCapturedText(textView, text, privacy) + resolveCapturedText(textView, text, textAndInputPrivacy) } else { - resolveCapturedHint(hint, privacy) + resolveCapturedHint(hint, textAndInputPrivacy) } } private fun resolveCapturedText( textView: TextView, text: String, - privacy: SessionReplayPrivacy + textAndInputPrivacy: TextAndInputPrivacy ): String { val inputTypeVariation = textView.inputType and InputType.TYPE_MASK_VARIATION val inputTypeClass = textView.inputType and InputType.TYPE_MASK_CLASS @@ -61,16 +65,16 @@ class EditTextMapper( val isSensitive = isSensitiveText || isSensitiveNumber || (inputTypeClass == InputType.TYPE_CLASS_PHONE) - return when (privacy) { - SessionReplayPrivacy.ALLOW -> if (isSensitive) FIXED_INPUT_MASK else text + return when (textAndInputPrivacy) { + TextAndInputPrivacy.MASK_SENSITIVE_INPUTS -> if (isSensitive) FIXED_INPUT_MASK else text - SessionReplayPrivacy.MASK, - SessionReplayPrivacy.MASK_USER_INPUT -> FIXED_INPUT_MASK + TextAndInputPrivacy.MASK_ALL, + TextAndInputPrivacy.MASK_ALL_INPUTS -> FIXED_INPUT_MASK } } - private fun resolveCapturedHint(hint: String, privacy: SessionReplayPrivacy): String { - return if (privacy == SessionReplayPrivacy.MASK) { + private fun resolveCapturedHint(hint: String, textAndInputPrivacy: TextAndInputPrivacy): String { + return if (textAndInputPrivacy == TextAndInputPrivacy.MASK_ALL) { StringObfuscator.getStringObfuscator().obfuscate(hint) } else { hint diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/recorder/mapper/TextViewMapper.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/recorder/mapper/TextViewMapper.kt index 99ebb86bdf..f744796967 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/recorder/mapper/TextViewMapper.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/recorder/mapper/TextViewMapper.kt @@ -11,7 +11,7 @@ import android.view.Gravity import android.widget.TextView import androidx.annotation.UiThread import com.datadog.android.api.InternalLogger -import com.datadog.android.sessionreplay.SessionReplayPrivacy +import com.datadog.android.sessionreplay.TextAndInputPrivacy import com.datadog.android.sessionreplay.internal.recorder.densityNormalized import com.datadog.android.sessionreplay.internal.recorder.obfuscator.StringObfuscator import com.datadog.android.sessionreplay.model.MobileSegment @@ -27,6 +27,7 @@ import com.datadog.android.sessionreplay.utils.ViewIdentifierResolver /** * A [WireframeMapper] implementation to map a [TextView] component. */ +@Suppress("TooManyFunctions") open class TextViewMapper( viewIdentifierResolver: ViewIdentifierResolver, colorStringFormatter: ColorStringFormatter, @@ -82,24 +83,24 @@ open class TextViewMapper( /** * Resolves the text to record for this TextView. * @param textView the textView being mapped - * @param privacy the current privacy setting + * @param textAndInputPrivacy the current text and input privacy setting * @param isOption whether the textview is part of an option menu */ protected open fun resolveCapturedText( textView: T, - privacy: SessionReplayPrivacy, + textAndInputPrivacy: TextAndInputPrivacy, isOption: Boolean ): String { - val originalText = textView.text?.toString().orEmpty() - return when (privacy) { - SessionReplayPrivacy.ALLOW -> originalText - SessionReplayPrivacy.MASK -> if (isOption) { + val originalText = resolveLayoutText(textView) + return when (textAndInputPrivacy) { + TextAndInputPrivacy.MASK_SENSITIVE_INPUTS -> originalText + TextAndInputPrivacy.MASK_ALL -> if (isOption) { FIXED_INPUT_MASK } else { StringObfuscator.getStringObfuscator().obfuscate(originalText) } - SessionReplayPrivacy.MASK_USER_INPUT -> if (isOption) FIXED_INPUT_MASK else originalText + TextAndInputPrivacy.MASK_ALL_INPUTS -> if (isOption) FIXED_INPUT_MASK else originalText } } @@ -107,12 +108,20 @@ open class TextViewMapper( // region Internal + private fun resolveLayoutText(textView: T): String { + return (textView.layout?.text ?: textView.text)?.toString().orEmpty() + } + private fun createTextWireframe( textView: T, mappingContext: MappingContext, viewGlobalBounds: GlobalBounds ): MobileSegment.Wireframe.TextWireframe { - val capturedText = resolveCapturedText(textView, mappingContext.privacy, mappingContext.hasOptionSelectorParent) + val capturedText = resolveCapturedText( + textView, + mappingContext.textAndInputPrivacy, + mappingContext.hasOptionSelectorParent + ) return MobileSegment.Wireframe.TextWireframe( id = resolveViewId(textView), x = viewGlobalBounds.x, diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/utils/AndroidMDrawableToColorMapper.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/utils/AndroidMDrawableToColorMapper.kt index ed58f70323..b6dc640def 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/utils/AndroidMDrawableToColorMapper.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/utils/AndroidMDrawableToColorMapper.kt @@ -19,7 +19,9 @@ import com.datadog.android.api.InternalLogger * This class is meant for internal usage so please use it carefully as it might change in time. */ @RequiresApi(Build.VERSION_CODES.M) -open class AndroidMDrawableToColorMapper : LegacyDrawableToColorMapper() { +open class AndroidMDrawableToColorMapper( + extensionMappers: List = emptyList() +) : LegacyDrawableToColorMapper(extensionMappers) { override fun resolveRippleDrawable(drawable: RippleDrawable, internalLogger: InternalLogger): Int? { // A ripple drawable can have a layer marked as mask, and which is not drawn diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/utils/AndroidQDrawableToColorMapper.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/utils/AndroidQDrawableToColorMapper.kt index fac07c8942..ad79ccbe29 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/utils/AndroidQDrawableToColorMapper.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/utils/AndroidQDrawableToColorMapper.kt @@ -21,7 +21,9 @@ import com.datadog.android.api.InternalLogger * This class is meant for internal usage so please use it carefully as it might change in time. */ @RequiresApi(Build.VERSION_CODES.Q) -open class AndroidQDrawableToColorMapper : AndroidMDrawableToColorMapper() { +open class AndroidQDrawableToColorMapper( + extensionMappers: List = emptyList() +) : AndroidMDrawableToColorMapper(extensionMappers) { override fun resolveGradientDrawable(drawable: GradientDrawable, internalLogger: InternalLogger): Int? { @Suppress("SwallowedException") diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/utils/DrawableToColorMapper.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/utils/DrawableToColorMapper.kt index a7a89d84fe..52d5018fa8 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/utils/DrawableToColorMapper.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/utils/DrawableToColorMapper.kt @@ -29,13 +29,13 @@ fun interface DrawableToColorMapper { * Provides a default implementation. * @return a default implementation based on the device API level */ - fun getDefault(): DrawableToColorMapper { + fun getDefault(customDrawableMappers: List = emptyList()): DrawableToColorMapper { return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - AndroidQDrawableToColorMapper() + AndroidQDrawableToColorMapper(customDrawableMappers) } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - AndroidMDrawableToColorMapper() + AndroidMDrawableToColorMapper(customDrawableMappers) } else { - LegacyDrawableToColorMapper() + LegacyDrawableToColorMapper(customDrawableMappers) } } } diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/utils/ImageWireframeHelper.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/utils/ImageWireframeHelper.kt index 2c777dcea9..34f0a2909d 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/utils/ImageWireframeHelper.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/utils/ImageWireframeHelper.kt @@ -12,6 +12,8 @@ import android.view.View import android.widget.TextView import androidx.annotation.UiThread import com.datadog.android.sessionreplay.ImagePrivacy +import com.datadog.android.sessionreplay.internal.recorder.resources.DefaultDrawableCopier +import com.datadog.android.sessionreplay.internal.recorder.resources.DrawableCopier import com.datadog.android.sessionreplay.model.MobileSegment import com.datadog.android.sessionreplay.recorder.MappingContext @@ -57,6 +59,7 @@ interface ImageWireframeHelper { * @param height the width of the image * @param usePIIPlaceholder whether to replace the image content with a placeholder when we suspect it contains PII * @param drawable the drawable to capture + * @param drawableCopier the callback to copy the original drawable to a new one. * @param asyncJobStatusCallback the callback for the async capture process * @param clipping the bounds of the image that are actually visible * @param shapeStyle provides a custom shape (e.g. rounded corners) to the image wireframe @@ -74,6 +77,7 @@ interface ImageWireframeHelper { height: Int, usePIIPlaceholder: Boolean, drawable: Drawable, + drawableCopier: DrawableCopier = DefaultDrawableCopier(), asyncJobStatusCallback: AsyncJobStatusCallback, clipping: MobileSegment.WireframeClip? = null, shapeStyle: MobileSegment.ShapeStyle? = null, diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/utils/LegacyDrawableToColorMapper.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/utils/LegacyDrawableToColorMapper.kt index d2b8599844..365e912011 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/utils/LegacyDrawableToColorMapper.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/utils/LegacyDrawableToColorMapper.kt @@ -7,28 +7,45 @@ package com.datadog.android.sessionreplay.utils //noinspection SuspiciousImport +import android.annotation.SuppressLint import android.graphics.Paint +import android.graphics.PorterDuffColorFilter +import android.graphics.drawable.BitmapDrawable import android.graphics.drawable.ColorDrawable import android.graphics.drawable.Drawable import android.graphics.drawable.GradientDrawable import android.graphics.drawable.InsetDrawable import android.graphics.drawable.LayerDrawable import android.graphics.drawable.RippleDrawable +import android.graphics.drawable.ShapeDrawable +import android.graphics.drawable.VectorDrawable import com.datadog.android.api.InternalLogger /** * Drawable utility object needed in the Session Replay Wireframe Mappers. * This class is meant for internal usage so please use it carefully as it might change in time. */ -open class LegacyDrawableToColorMapper : DrawableToColorMapper { +open class LegacyDrawableToColorMapper( + private val extensionMappers: List = emptyList() +) : DrawableToColorMapper { override fun mapDrawableToColor(drawable: Drawable, internalLogger: InternalLogger): Int? { + // First check if extension mappers can resolve the drawable + extensionMappers.forEach { + val result = it.mapDrawableToColor(drawable, internalLogger) + if (result != null) { + return result + } + } val result = when (drawable) { is ColorDrawable -> resolveColorDrawable(drawable) is RippleDrawable -> resolveRippleDrawable(drawable, internalLogger) is LayerDrawable -> resolveLayerDrawable(drawable, internalLogger) is InsetDrawable -> resolveInsetDrawable(drawable, internalLogger) is GradientDrawable -> resolveGradientDrawable(drawable, internalLogger) + is ShapeDrawable -> resolveShapeDrawable(drawable, internalLogger) + is BitmapDrawable, + is VectorDrawable -> null // return null without reporting them by telemetry. else -> { val drawableType = drawable.javaClass.canonicalName ?: drawable.javaClass.name internalLogger.log( @@ -48,6 +65,19 @@ open class LegacyDrawableToColorMapper : DrawableToColorMapper { return result } + /** + * Resolves the color from a [ShapeDrawable]. + * @param drawable the shape drawable + * @param internalLogger the internalLogger to report warnings + * @return the color to map to or null if not applicable + */ + protected open fun resolveShapeDrawable( + drawable: ShapeDrawable, + internalLogger: InternalLogger + ): Int { + return drawable.paint.color + } + /** * Resolves the color from a [ColorDrawable]. * @param drawable the color drawable @@ -110,15 +140,34 @@ open class LegacyDrawableToColorMapper : DrawableToColorMapper { } if (fillPaint == null) return null - - val fillColor: Int = fillPaint.color + val filterColor = try { + fillPaint.colorFilter?.let { + @Suppress("UnsafeThirdPartyFunctionCall") // Can't throw NPE here + mColorField?.get(it) as? Int + } ?: fillPaint.color + } catch (e: IllegalArgumentException) { + internalLogger.log( + InternalLogger.Level.WARN, + InternalLogger.Target.MAINTAINER, + { "Unable to read ColorFilter.mColorField field through reflection" }, + e + ) + fillPaint.color + } catch (e: IllegalAccessException) { + internalLogger.log( + InternalLogger.Level.WARN, + InternalLogger.Target.MAINTAINER, + { "Unable to read ColorFilter.mColorField field through reflection" }, + e + ) + fillPaint.color + } val fillAlpha = (fillPaint.alpha * drawable.alpha) / MAX_ALPHA_VALUE return if (fillAlpha == 0) { null } else { - // TODO RUM-3469 resolve other color filter types - mergeColorAndAlpha(fillColor, fillAlpha) + mergeColorAndAlpha(filterColor, fillAlpha) } } @@ -129,7 +178,7 @@ open class LegacyDrawableToColorMapper : DrawableToColorMapper { * @return the color to map to or null if not applicable */ protected open fun resolveInsetDrawable(drawable: InsetDrawable, internalLogger: InternalLogger): Int? { - return null + return drawable.drawable?.let { mapDrawableToColor(it, internalLogger) } } /** @@ -143,6 +192,7 @@ open class LegacyDrawableToColorMapper : DrawableToColorMapper { } companion object { + @SuppressLint("DiscouragedPrivateApi") @Suppress("PrivateAPI", "SwallowedException", "TooGenericExceptionCaught") internal val fillPaintField = try { GradientDrawable::class.java.getDeclaredField("mFillPaint").apply { @@ -155,5 +205,18 @@ open class LegacyDrawableToColorMapper : DrawableToColorMapper { } catch (e: NullPointerException) { null } + + @Suppress("PrivateAPI", "SwallowedException", "TooGenericExceptionCaught") + internal val mColorField = try { + PorterDuffColorFilter::class.java.getDeclaredField("mColor").apply { + this.isAccessible = true + } + } catch (e: NoSuchFieldException) { + null + } catch (e: SecurityException) { + null + } catch (e: NullPointerException) { + null + } } } diff --git a/features/dd-sdk-android-session-replay/src/main/res/values/ids.xml b/features/dd-sdk-android-session-replay/src/main/res/values/ids.xml new file mode 100644 index 0000000000..9aa089d8fd --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/main/res/values/ids.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/PrivacyOverrideExtensionsTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/PrivacyOverrideExtensionsTest.kt new file mode 100644 index 0000000000..c0ad310198 --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/PrivacyOverrideExtensionsTest.kt @@ -0,0 +1,153 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.datadog.android.sessionreplay + +import android.view.View +import com.datadog.android.sessionreplay.forge.ForgeConfigurator +import fr.xgouchet.elmyr.Forge +import fr.xgouchet.elmyr.junit5.ForgeConfiguration +import fr.xgouchet.elmyr.junit5.ForgeExtension +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.junit.jupiter.api.extension.Extensions +import org.mockito.Mockito.mock +import org.mockito.junit.jupiter.MockitoExtension +import org.mockito.junit.jupiter.MockitoSettings +import org.mockito.kotlin.eq +import org.mockito.kotlin.isNull +import org.mockito.kotlin.verify +import org.mockito.quality.Strictness + +@Extensions( + ExtendWith(MockitoExtension::class), + ExtendWith(ForgeExtension::class) +) +@MockitoSettings(strictness = Strictness.LENIENT) +@ForgeConfiguration(ForgeConfigurator::class) +internal class PrivacyOverrideExtensionsTest { + + // region setSessionReplayHidden + + @Test + fun `M set tag W setSessionReplayHidden() { hide is true }`() { + // Given + val mockView = mock() + + // When + mockView.setSessionReplayHidden(true) + + // Then + verify(mockView).setTag(eq(R.id.datadog_hidden), eq(true)) + } + + @Test + fun `M set tag to null W setSessionReplayHidden() { hide is false }`() { + // Given + val mockView = mock() + + // When + mockView.setSessionReplayHidden(false) + + // Then + verify(mockView).setTag(eq(R.id.datadog_hidden), isNull()) + } + + // endregion + + // region setSessionReplayImagePrivacy + + @Test + fun `M set tag W setSessionReplayImagePrivacy() { with privacy }`( + forge: Forge + ) { + // Given + val mockView = mock() + val mockPrivacy = forge.aValueFrom(ImagePrivacy::class.java) + + // When + mockView.setSessionReplayImagePrivacy(mockPrivacy) + + // Then + verify(mockView).setTag(eq(R.id.datadog_image_privacy), eq(mockPrivacy.toString())) + } + + @Test + fun `M set tag to null W setSessionReplayImagePrivacy() { privacy is null }`() { + // Given + val mockView = mock() + + // When + mockView.setSessionReplayImagePrivacy(null) + + // Then + verify(mockView).setTag(eq(R.id.datadog_image_privacy), isNull()) + } + + // endregion + + // region setSessionReplayTextAndInputPrivacy + + @Test + fun `M set tag W setSessionReplayTextAndInputPrivacy() { with privacy }`( + forge: Forge + ) { + // Given + val mockView = mock() + val mockPrivacy = forge.aValueFrom(TextAndInputPrivacy::class.java) + + // When + mockView.setSessionReplayTextAndInputPrivacy(mockPrivacy) + + // Then + verify(mockView).setTag(eq(R.id.datadog_text_and_input_privacy), eq(mockPrivacy.toString())) + } + + @Test + fun `M set tag to null W setSessionReplayTextAndInputPrivacy() { privacy is null }`() { + // Given + val mockView = mock() + + // When + mockView.setSessionReplayTextAndInputPrivacy(null) + + // Then + verify(mockView).setTag(eq(R.id.datadog_text_and_input_privacy), isNull()) + } + + // endregion + + // region setSessionReplayTouchPrivacy + + @Test + fun `M set tag W setSessionReplayTouchPrivacy() { with privacy }`( + forge: Forge + ) { + // Given + val mockView = mock() + val mockPrivacy = forge.aValueFrom(TouchPrivacy::class.java) + + // When + mockView.setSessionReplayTouchPrivacy(mockPrivacy) + + // Then + verify(mockView).setTag(eq(R.id.datadog_touch_privacy), eq(mockPrivacy.toString())) + } + + @Test + fun `M set tag to null W setSessionReplayTouchPrivacy() { privacy is null }`() { + // Given + val mockView = mock() + + // When + mockView.setSessionReplayTouchPrivacy(null) + + // Then + verify(mockView).setTag(eq(R.id.datadog_touch_privacy), isNull()) + } + + // endregion +} diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/SessionReplayConfigurationBuilderTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/SessionReplayConfigurationBuilderTest.kt index 51aa3f2ff0..f5217a4c08 100644 --- a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/SessionReplayConfigurationBuilderTest.kt +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/SessionReplayConfigurationBuilderTest.kt @@ -7,8 +7,9 @@ package com.datadog.android.sessionreplay import android.view.View +import com.datadog.android.sessionreplay.SessionReplayConfiguration.Builder.Companion.SAMPLE_IN_ALL_SESSIONS import com.datadog.android.sessionreplay.forge.ForgeConfigurator -import com.datadog.android.sessionreplay.recorder.mapper.WireframeMapper +import fr.xgouchet.elmyr.annotation.BoolForgery import fr.xgouchet.elmyr.annotation.FloatForgery import fr.xgouchet.elmyr.annotation.Forgery import fr.xgouchet.elmyr.annotation.StringForgery @@ -34,12 +35,11 @@ import org.mockito.quality.Strictness @ForgeConfiguration(value = ForgeConfigurator::class) internal class SessionReplayConfigurationBuilderTest { - lateinit var testedBuilder: SessionReplayConfiguration.Builder + private lateinit var testedBuilder: SessionReplayConfiguration.Builder @Mock lateinit var mockExtensionSupport: ExtensionSupport - lateinit var fakeCustomViewMappers: Map, WireframeMapper<*>> - lateinit var fakeExpectedCustomMappers: List> + private lateinit var fakeExpectedCustomMappers: List> @FloatForgery var fakeSampleRate: Float = 0f @@ -48,7 +48,7 @@ internal class SessionReplayConfigurationBuilderTest { fun `set up`() { fakeExpectedCustomMappers = listOf(MapperTypeWrapper(View::class.java, mock())) whenever(mockExtensionSupport.getCustomViewMappers()).thenReturn(fakeExpectedCustomMappers) - testedBuilder = SessionReplayConfiguration.Builder(fakeSampleRate) + testedBuilder = SessionReplayConfiguration.Builder() } @Test @@ -59,10 +59,12 @@ internal class SessionReplayConfigurationBuilderTest { // Then assertThat(sessionReplayConfiguration.customEndpointUrl).isNull() assertThat(sessionReplayConfiguration.privacy).isEqualTo(SessionReplayPrivacy.MASK) - assertThat(sessionReplayConfiguration.imagePrivacy).isEqualTo(ImagePrivacy.MASK_LARGE_ONLY) + assertThat(sessionReplayConfiguration.imagePrivacy).isEqualTo(ImagePrivacy.MASK_ALL) + assertThat(sessionReplayConfiguration.touchPrivacy).isEqualTo(TouchPrivacy.HIDE) + assertThat(sessionReplayConfiguration.sampleRate).isEqualTo(SAMPLE_IN_ALL_SESSIONS) assertThat(sessionReplayConfiguration.customMappers).isEmpty() assertThat(sessionReplayConfiguration.customOptionSelectorDetectors).isEmpty() - assertThat(sessionReplayConfiguration.sampleRate).isEqualTo(fakeSampleRate) + assertThat(sessionReplayConfiguration.dynamicOptimizationEnabled).isEqualTo(true) } @Test @@ -70,6 +72,7 @@ internal class SessionReplayConfigurationBuilderTest { @StringForgery(regex = "https://[a-z]+\\.com") sessionReplayUrl: String ) { // When + testedBuilder = SessionReplayConfiguration.Builder(fakeSampleRate) val sessionReplayConfiguration = testedBuilder .useCustomEndpoint(sessionReplayUrl) .build() @@ -78,46 +81,61 @@ internal class SessionReplayConfigurationBuilderTest { assertThat(sessionReplayConfiguration.customEndpointUrl) .isEqualTo(sessionReplayUrl) assertThat(sessionReplayConfiguration.privacy).isEqualTo(SessionReplayPrivacy.MASK) - assertThat(sessionReplayConfiguration.imagePrivacy).isEqualTo(ImagePrivacy.MASK_LARGE_ONLY) + assertThat(sessionReplayConfiguration.imagePrivacy).isEqualTo(ImagePrivacy.MASK_ALL) + assertThat(sessionReplayConfiguration.touchPrivacy).isEqualTo(TouchPrivacy.HIDE) assertThat(sessionReplayConfiguration.customMappers).isEmpty() assertThat(sessionReplayConfiguration.customOptionSelectorDetectors).isEmpty() assertThat(sessionReplayConfiguration.sampleRate).isEqualTo(fakeSampleRate) } @Test - fun `M use the given privacy rule W setSessionReplayPrivacy`( - @Forgery fakePrivacy: SessionReplayPrivacy + fun `M use the given image privacy rule W setImagePrivacy`( + @Forgery fakeImagePrivacy: ImagePrivacy ) { // When val sessionReplayConfiguration = testedBuilder - .setPrivacy(fakePrivacy) + .setImagePrivacy(fakeImagePrivacy) .build() // Then - assertThat(sessionReplayConfiguration.customEndpointUrl).isNull() - assertThat(sessionReplayConfiguration.privacy).isEqualTo(fakePrivacy) - assertThat(sessionReplayConfiguration.imagePrivacy).isEqualTo(ImagePrivacy.MASK_LARGE_ONLY) - assertThat(sessionReplayConfiguration.customMappers).isEmpty() - assertThat(sessionReplayConfiguration.customOptionSelectorDetectors).isEmpty() - assertThat(sessionReplayConfiguration.sampleRate).isEqualTo(fakeSampleRate) + assertThat(sessionReplayConfiguration.imagePrivacy).isEqualTo(fakeImagePrivacy) } @Test - fun `M use the given image privacy rule W setImagePrivacy`( - @Forgery fakeImagePrivacy: ImagePrivacy + fun `M use the given touch privacy rule W setTouchPrivacy`( + @Forgery fakeTouchPrivacy: TouchPrivacy ) { // When val sessionReplayConfiguration = testedBuilder - .setImagePrivacy(fakeImagePrivacy) + .setTouchPrivacy(fakeTouchPrivacy) .build() // Then - assertThat(sessionReplayConfiguration.customEndpointUrl).isNull() - assertThat(sessionReplayConfiguration.privacy).isEqualTo(SessionReplayPrivacy.MASK) - assertThat(sessionReplayConfiguration.imagePrivacy).isEqualTo(fakeImagePrivacy) - assertThat(sessionReplayConfiguration.customMappers).isEmpty() - assertThat(sessionReplayConfiguration.customOptionSelectorDetectors).isEmpty() - assertThat(sessionReplayConfiguration.sampleRate).isEqualTo(fakeSampleRate) + assertThat(sessionReplayConfiguration.touchPrivacy).isEqualTo(fakeTouchPrivacy) + } + + @Test + fun `M use the given text and input privacy rule W setTextAndInputPrivacy`( + @Forgery fakeTextAndInputPrivacy: TextAndInputPrivacy + ) { + // When + val sessionReplayConfiguration = testedBuilder + .setTextAndInputPrivacy(fakeTextAndInputPrivacy) + .build() + + // Then + assertThat(sessionReplayConfiguration.textAndInputPrivacy).isEqualTo(fakeTextAndInputPrivacy) + } + + @Test + fun `M pass startRecordingImmediately W startRecordingImmediately`() { + // When + val sessionReplayConfiguration = testedBuilder + .startRecordingImmediately(true) + .build() + + // Then + assertThat(sessionReplayConfiguration.startRecordingImmediately).isTrue() } @Test @@ -132,6 +150,20 @@ internal class SessionReplayConfigurationBuilderTest { .isEqualTo(fakeExpectedCustomMappers) } + @Test + fun `M use the given dynamic optimization W setDynamicOptimization()`( + @BoolForgery fakeDynamicOptimizationEnabled: Boolean + ) { + // Given + val sessionReplayConfiguration = testedBuilder + .setDynamicOptimizationEnabled(fakeDynamicOptimizationEnabled) + .build() + + // Then + assertThat(sessionReplayConfiguration.dynamicOptimizationEnabled) + .isEqualTo(fakeDynamicOptimizationEnabled) + } + @Test fun `M return empty map W addExtensionSupport { no mappers provided }`() { // Given @@ -143,4 +175,59 @@ internal class SessionReplayConfigurationBuilderTest { // Then assertThat(sessionReplayConfiguration.customMappers).isEmpty() } + + @Suppress("DEPRECATION") + @Test + fun `M not overwrite fgm W setPrivacy { fgm already set }`() { + // When + val sessionReplayConfiguration = testedBuilder + .setImagePrivacy(ImagePrivacy.MASK_ALL) + .setPrivacy(SessionReplayPrivacy.ALLOW) + .build() + + // Then + assertThat(sessionReplayConfiguration.imagePrivacy).isEqualTo(ImagePrivacy.MASK_ALL) + } + + @Suppress("DEPRECATION") + @Test + fun `M set appropriate fgm privacy W setPrivacy { allow }`() { + // When + val sessionReplayConfiguration = testedBuilder + .setPrivacy(SessionReplayPrivacy.ALLOW) + .build() + + // Then + assertThat(sessionReplayConfiguration.imagePrivacy).isEqualTo(ImagePrivacy.MASK_NONE) + assertThat(sessionReplayConfiguration.touchPrivacy).isEqualTo(TouchPrivacy.SHOW) + assertThat(sessionReplayConfiguration.textAndInputPrivacy).isEqualTo(TextAndInputPrivacy.MASK_SENSITIVE_INPUTS) + } + + @Suppress("DEPRECATION") + @Test + fun `M set appropriate fgm privacy W setPrivacy { mask_user_input }`() { + // When + val sessionReplayConfiguration = testedBuilder + .setPrivacy(SessionReplayPrivacy.MASK_USER_INPUT) + .build() + + // Then + assertThat(sessionReplayConfiguration.imagePrivacy).isEqualTo(ImagePrivacy.MASK_LARGE_ONLY) + assertThat(sessionReplayConfiguration.touchPrivacy).isEqualTo(TouchPrivacy.HIDE) + assertThat(sessionReplayConfiguration.textAndInputPrivacy).isEqualTo(TextAndInputPrivacy.MASK_ALL_INPUTS) + } + + @Suppress("DEPRECATION") + @Test + fun `M set appropriate fgm privacy W setPrivacy { mask }`() { + // When + val sessionReplayConfiguration = testedBuilder + .setPrivacy(SessionReplayPrivacy.MASK) + .build() + + // Then + assertThat(sessionReplayConfiguration.imagePrivacy).isEqualTo(ImagePrivacy.MASK_ALL) + assertThat(sessionReplayConfiguration.touchPrivacy).isEqualTo(TouchPrivacy.HIDE) + assertThat(sessionReplayConfiguration.textAndInputPrivacy).isEqualTo(TextAndInputPrivacy.MASK_ALL) + } } diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/SessionReplayRecorderTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/SessionReplayRecorderTest.kt index c730b4bb22..54963f1a11 100644 --- a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/SessionReplayRecorderTest.kt +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/SessionReplayRecorderTest.kt @@ -12,6 +12,7 @@ import android.view.View import android.view.Window import com.datadog.android.api.InternalLogger import com.datadog.android.sessionreplay.internal.LifecycleCallback +import com.datadog.android.sessionreplay.internal.TouchPrivacyManager import com.datadog.android.sessionreplay.internal.async.RecordedDataQueueHandler import com.datadog.android.sessionreplay.internal.recorder.SessionReplayRecorder import com.datadog.android.sessionreplay.internal.recorder.ViewOnDrawInterceptor @@ -60,7 +61,7 @@ internal class SessionReplayRecorderTest { private lateinit var mockRecordWriter: RecordWriter @Forgery - private lateinit var fakePrivacy: SessionReplayPrivacy + private lateinit var fakeTextAndInputPrivacy: TextAndInputPrivacy @Forgery private lateinit var fakeImagePrivacy: ImagePrivacy @@ -83,6 +84,9 @@ internal class SessionReplayRecorderTest { @Mock lateinit var mockInternalLogger: InternalLogger + @Mock + lateinit var mockTouchPrivacyManager: TouchPrivacyManager + @Mock lateinit var mockDataStoreManager: ResourceDataStoreManager @@ -107,8 +111,9 @@ internal class SessionReplayRecorderTest { testedSessionReplayRecorder = SessionReplayRecorder( appContext = appContext.mockInstance, rumContextProvider = mockRumContextProvider, - privacy = fakePrivacy, + textAndInputPrivacy = fakeTextAndInputPrivacy, imagePrivacy = fakeImagePrivacy, + touchPrivacyManager = mockTouchPrivacyManager, recordWriter = mockRecordWriter, timeProvider = mockTimeProvider, mappers = mock(), @@ -151,7 +156,7 @@ internal class SessionReplayRecorderTest { verify(mockWindowCallbackInterceptor).intercept(fakeActiveWindows, appContext.mockInstance) verify(mockViewOnDrawInterceptor).intercept( decorViews = fakeActiveWindowsDecorViews, - sessionReplayPrivacy = fakePrivacy, + textAndInputPrivacy = fakeTextAndInputPrivacy, imagePrivacy = fakeImagePrivacy ) } @@ -182,7 +187,7 @@ internal class SessionReplayRecorderTest { // Then verify(mockWindowCallbackInterceptor).intercept(fakeAddedWindows, appContext.mockInstance) - verify(mockViewOnDrawInterceptor).intercept(fakeNewDecorViews, fakePrivacy, fakeImagePrivacy) + verify(mockViewOnDrawInterceptor).intercept(fakeNewDecorViews, fakeTextAndInputPrivacy, fakeImagePrivacy) } @Test @@ -204,7 +209,7 @@ internal class SessionReplayRecorderTest { verify(mockWindowCallbackInterceptor, never()) .intercept(fakeAddedWindows, appContext.mockInstance) verify(mockViewOnDrawInterceptor, never()) - .intercept(fakeNewDecorViews, fakePrivacy, fakeImagePrivacy) + .intercept(fakeNewDecorViews, fakeTextAndInputPrivacy, fakeImagePrivacy) } @Test @@ -226,7 +231,7 @@ internal class SessionReplayRecorderTest { verify(mockWindowCallbackInterceptor, never()) .intercept(fakeAddedWindows, appContext.mockInstance) verify(mockViewOnDrawInterceptor, never()) - .intercept(fakeNewDecorViews, fakePrivacy, fakeImagePrivacy) + .intercept(fakeNewDecorViews, fakeTextAndInputPrivacy, fakeImagePrivacy) } @Test @@ -245,7 +250,7 @@ internal class SessionReplayRecorderTest { // Then verify(mockWindowCallbackInterceptor).stopIntercepting(fakeAddedWindows) - verify(mockViewOnDrawInterceptor).intercept(fakeNewDecorViews, fakePrivacy, fakeImagePrivacy) + verify(mockViewOnDrawInterceptor).intercept(fakeNewDecorViews, fakeTextAndInputPrivacy, fakeImagePrivacy) } @Test @@ -266,7 +271,7 @@ internal class SessionReplayRecorderTest { // Then verify(mockWindowCallbackInterceptor, never()).stopIntercepting(fakeAddedWindows) verify(mockViewOnDrawInterceptor, never()) - .intercept(fakeNewDecorViews, fakePrivacy, fakeImagePrivacy) + .intercept(fakeNewDecorViews, fakeTextAndInputPrivacy, fakeImagePrivacy) } @Test @@ -285,7 +290,7 @@ internal class SessionReplayRecorderTest { // Then verify(mockWindowCallbackInterceptor, never()).stopIntercepting(fakeAddedWindows) verify(mockViewOnDrawInterceptor, never()) - .intercept(fakeNewDecorViews, fakePrivacy, fakeImagePrivacy) + .intercept(fakeNewDecorViews, fakeTextAndInputPrivacy, fakeImagePrivacy) } @Test diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/SessionReplayTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/SessionReplayTest.kt index 3e9f333415..9425609026 100644 --- a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/SessionReplayTest.kt +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/SessionReplayTest.kt @@ -6,10 +6,15 @@ package com.datadog.android.sessionreplay +import com.datadog.android.api.InternalLogger +import com.datadog.android.api.feature.Feature.Companion.SESSION_REPLAY_FEATURE_NAME +import com.datadog.android.api.feature.FeatureScope import com.datadog.android.api.feature.FeatureSdkCore +import com.datadog.android.sessionreplay.SessionReplay.IS_ALREADY_REGISTERED_WARNING import com.datadog.android.sessionreplay.forge.ForgeConfigurator import com.datadog.android.sessionreplay.internal.SessionReplayFeature import com.datadog.android.sessionreplay.internal.net.SegmentRequestFactory +import com.datadog.android.sessionreplay.utils.verifyLog import fr.xgouchet.elmyr.annotation.Forgery import fr.xgouchet.elmyr.annotation.StringForgery import fr.xgouchet.elmyr.junit5.ForgeConfiguration @@ -22,7 +27,9 @@ import org.junit.jupiter.api.extension.Extensions import org.mockito.Mock import org.mockito.junit.jupiter.MockitoExtension import org.mockito.junit.jupiter.MockitoSettings +import org.mockito.kotlin.any import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.doAnswer import org.mockito.kotlin.doReturn import org.mockito.kotlin.mock import org.mockito.kotlin.verify @@ -40,9 +47,13 @@ internal class SessionReplayTest { @Mock lateinit var mockSdkCore: FeatureSdkCore + @Mock + lateinit var mockSystemRequirementsConfiguration: SystemRequirementsConfiguration + @BeforeEach fun `set up`() { whenever(mockSdkCore.internalLogger) doReturn mock() + SessionReplay.currentRegisteredCore = null } @Test @@ -51,7 +62,18 @@ internal class SessionReplayTest { @Forgery fakeSessionReplayConfiguration: SessionReplayConfiguration ) { // When - SessionReplay.enable(fakeSessionReplayConfiguration, mockSdkCore) + val fakeSessionReplayConfigurationWithMockRequirement = fakeSessionReplayConfiguration.copy( + systemRequirementsConfiguration = mockSystemRequirementsConfiguration + ) + whenever( + mockSystemRequirementsConfiguration.runIfRequirementsMet(any(), any()) + ) doAnswer { + it.getArgument<() -> Unit>(1).invoke() + } + SessionReplay.enable( + fakeSessionReplayConfigurationWithMockRequirement, + mockSdkCore + ) // Then argumentCaptor { @@ -65,4 +87,115 @@ internal class SessionReplayTest { .isEqualTo(fakeSessionReplayConfiguration.customEndpointUrl) } } + + @Test + fun `M call manuallyStartRecording on feature W startRecording`( + @Mock mockFeatureScope: FeatureScope, + @Mock mockSessionReplayFeature: SessionReplayFeature + ) { + // Given + whenever(mockSdkCore.getFeature(SESSION_REPLAY_FEATURE_NAME)) + .thenReturn(mockFeatureScope) + + whenever(mockFeatureScope.unwrap()) doReturn mockSessionReplayFeature + + // When + SessionReplay.startRecording(mockSdkCore) + + // Then + verify(mockSessionReplayFeature).manuallyStartRecording() + } + + @Test + fun `M call manuallyStopRecording on feature W stopRecording`( + @Mock mockFeatureScope: FeatureScope, + @Mock mockSessionReplayFeature: SessionReplayFeature + ) { + // Given + whenever(mockSdkCore.getFeature(SESSION_REPLAY_FEATURE_NAME)) + .thenReturn(mockFeatureScope) + + whenever(mockFeatureScope.unwrap()) doReturn mockSessionReplayFeature + + // When + SessionReplay.stopRecording(mockSdkCore) + + // Then + verify(mockSessionReplayFeature).manuallyStopRecording() + } + + @Test + fun `M warn and send telemetry W enable { session replay feature already registered with another core }`( + @Forgery fakeSessionReplayConfiguration: SessionReplayConfiguration, + @Mock mockCore1: FeatureSdkCore, + @Mock mockCore2: FeatureSdkCore, + @Mock mockInternalLogger: InternalLogger + ) { + // Given + whenever(mockCore1.isCoreActive()).thenReturn(true) + whenever(mockCore1.internalLogger).thenReturn(mockInternalLogger) + whenever(mockCore2.internalLogger).thenReturn(mockInternalLogger) + val fakeSessionReplayConfigurationWithMockRequirement = fakeSessionReplayConfiguration.copy( + systemRequirementsConfiguration = mockSystemRequirementsConfiguration + ) + whenever( + mockSystemRequirementsConfiguration.runIfRequirementsMet(any(), any()) + ) doAnswer { + it.getArgument<() -> Unit>(1).invoke() + } + SessionReplay.enable( + sessionReplayConfiguration = fakeSessionReplayConfigurationWithMockRequirement, + sdkCore = mockCore1 + ) + + // When + SessionReplay.enable( + sessionReplayConfiguration = fakeSessionReplayConfigurationWithMockRequirement, + sdkCore = mockCore2 + ) + + // Then + mockInternalLogger.verifyLog( + level = InternalLogger.Level.WARN, + targets = listOf(InternalLogger.Target.MAINTAINER, InternalLogger.Target.TELEMETRY), + message = IS_ALREADY_REGISTERED_WARNING + ) + assertThat(SessionReplay.currentRegisteredCore?.get()).isEqualTo(mockCore1) + } + + @Test + fun `M allow changing cores W enable { Session Replay already enabled but old core inactive }`( + @Forgery fakeSessionReplayConfiguration: SessionReplayConfiguration, + @Mock mockCore1: FeatureSdkCore, + @Mock mockCore2: FeatureSdkCore, + @Mock mockInternalLogger: InternalLogger + ) { + // Given + whenever(mockCore1.internalLogger).thenReturn(mockInternalLogger) + whenever(mockCore2.internalLogger).thenReturn(mockInternalLogger) + val fakeSessionReplayConfigurationWithMockRequirement = fakeSessionReplayConfiguration.copy( + systemRequirementsConfiguration = mockSystemRequirementsConfiguration + ) + whenever( + mockSystemRequirementsConfiguration.runIfRequirementsMet(any(), any()) + ) doAnswer { + it.getArgument<() -> Unit>(1).invoke() + } + whenever(mockCore1.isCoreActive()).thenReturn(true) + SessionReplay.enable( + sessionReplayConfiguration = fakeSessionReplayConfigurationWithMockRequirement, + sdkCore = mockCore1 + ) + assertThat(SessionReplay.currentRegisteredCore?.get()).isEqualTo(mockCore1) + + // When + whenever(mockCore1.isCoreActive()).thenReturn(false) + SessionReplay.enable( + sessionReplayConfiguration = fakeSessionReplayConfigurationWithMockRequirement, + sdkCore = mockCore2 + ) + + // Then + assertThat(SessionReplay.currentRegisteredCore?.get()).isEqualTo(mockCore2) + } } diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/forge/ForgeConfigurator.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/forge/ForgeConfigurator.kt index 2641cb3e3d..996433a772 100644 --- a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/forge/ForgeConfigurator.kt +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/forge/ForgeConfigurator.kt @@ -15,6 +15,7 @@ internal class ForgeConfigurator : BaseConfigurator() { override fun configure(forge: Forge) { super.configure(forge) + forge.useJvmFactories() // Core forge.useCoreFactories() diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/forge/MappingContextForgeryFactory.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/forge/MappingContextForgeryFactory.kt index 50954b7e4b..77c940fdc3 100644 --- a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/forge/MappingContextForgeryFactory.kt +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/forge/MappingContextForgeryFactory.kt @@ -17,7 +17,7 @@ internal class MappingContextForgeryFactory : ForgeryFactory { systemInformation = forge.getForgery(), imageWireframeHelper = mock(), hasOptionSelectorParent = forge.aBool(), - privacy = forge.getForgery(), + textAndInputPrivacy = forge.getForgery(), imagePrivacy = forge.getForgery() ) } diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/forge/SessionReplayConfigurationForgeryFactory.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/forge/SessionReplayConfigurationForgeryFactory.kt index 434398c648..9018adf7d3 100644 --- a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/forge/SessionReplayConfigurationForgeryFactory.kt +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/forge/SessionReplayConfigurationForgeryFactory.kt @@ -9,6 +9,9 @@ package com.datadog.android.sessionreplay.forge import com.datadog.android.sessionreplay.ImagePrivacy import com.datadog.android.sessionreplay.SessionReplayConfiguration import com.datadog.android.sessionreplay.SessionReplayPrivacy +import com.datadog.android.sessionreplay.SystemRequirementsConfiguration +import com.datadog.android.sessionreplay.TextAndInputPrivacy +import com.datadog.android.sessionreplay.TouchPrivacy import fr.xgouchet.elmyr.Forge import fr.xgouchet.elmyr.ForgeryFactory import org.mockito.kotlin.mock @@ -18,10 +21,19 @@ class SessionReplayConfigurationForgeryFactory : ForgeryFactory mockRecorder } } @@ -121,10 +133,16 @@ internal class SessionReplayFeatureTest { sdkCore = mockSdkCore, customEndpointUrl = fakeConfiguration.customEndpointUrl, privacy = fakeConfiguration.privacy, + textAndInputPrivacy = fakeConfiguration.textAndInputPrivacy, imagePrivacy = fakeConfiguration.imagePrivacy, + touchPrivacy = fakeConfiguration.touchPrivacy, + touchPrivacyManager = mockTouchPrivacyManager, customMappers = emptyList(), customOptionSelectorDetectors = emptyList(), - sampleRate = fakeConfiguration.sampleRate + customDrawableMappers = emptyList(), + startRecordingImmediately = true, + sampleRate = fakeConfiguration.sampleRate, + dynamicOptimizationEnabled = fakeConfiguration.dynamicOptimizationEnabled ) // When @@ -142,10 +160,16 @@ internal class SessionReplayFeatureTest { sdkCore = mockSdkCore, customEndpointUrl = fakeConfiguration.customEndpointUrl, privacy = fakeConfiguration.privacy, + textAndInputPrivacy = fakeConfiguration.textAndInputPrivacy, imagePrivacy = fakeConfiguration.imagePrivacy, + touchPrivacy = fakeConfiguration.touchPrivacy, + touchPrivacyManager = mockTouchPrivacyManager, customMappers = emptyList(), customOptionSelectorDetectors = emptyList(), - sampleRate = fakeConfiguration.sampleRate + customDrawableMappers = emptyList(), + sampleRate = fakeConfiguration.sampleRate, + startRecordingImmediately = true, + dynamicOptimizationEnabled = fakeConfiguration.dynamicOptimizationEnabled ) // When @@ -161,10 +185,14 @@ internal class SessionReplayFeatureTest { firstValue.invoke(updatedContext) assertThat(updatedContext[SessionReplayFeature.SESSION_REPLAY_SAMPLE_RATE_KEY]) .isEqualTo(fakeConfiguration.sampleRate.toLong()) - assertThat(updatedContext[SessionReplayFeature.SESSION_REPLAY_PRIVACY_KEY]) - .isEqualTo(fakeConfiguration.privacy.toString().lowercase(Locale.US)) - assertThat(updatedContext[SessionReplayFeature.SESSION_REPLAY_MANUAL_RECORDING_KEY]) - .isEqualTo(false) + assertThat(updatedContext[SessionReplayFeature.SESSION_REPLAY_START_IMMEDIATE_RECORDING_KEY]) + .isEqualTo(true) + assertThat(updatedContext[SessionReplayFeature.SESSION_REPLAY_IMAGE_PRIVACY_KEY]) + .isEqualTo(fakeConfiguration.imagePrivacy.toString().lowercase(Locale.US)) + assertThat(updatedContext[SessionReplayFeature.SESSION_REPLAY_TOUCH_PRIVACY_KEY]) + .isEqualTo(fakeConfiguration.touchPrivacy.toString().lowercase(Locale.US)) + assertThat(updatedContext[SessionReplayFeature.SESSION_REPLAY_TEXT_AND_INPUT_PRIVACY_KEY]) + .isEqualTo(fakeConfiguration.textAndInputPrivacy.toString().lowercase(Locale.US)) } } @@ -175,7 +203,10 @@ internal class SessionReplayFeatureTest { sdkCore = mockSdkCore, customEndpointUrl = fakeConfiguration.customEndpointUrl, privacy = fakeConfiguration.privacy, + textAndInputPrivacy = fakeConfiguration.textAndInputPrivacy, imagePrivacy = fakeConfiguration.imagePrivacy, + startRecordingImmediately = true, + touchPrivacy = fakeConfiguration.touchPrivacy, rateBasedSampler = mockSampler ) { _, _, _, _ -> mockRecorder } @@ -819,7 +850,8 @@ internal class SessionReplayFeatureTest { @Test fun `M log warning and do nothing W onReceive() { unknown type property value }`( - forge: Forge + forge: Forge, + @Mock fakeContext: Application ) { // Given val event = mapOf( @@ -828,6 +860,7 @@ internal class SessionReplayFeatureTest { ) // When + testedFeature.onInitialize(fakeContext) testedFeature.onReceive(event) // Then @@ -839,11 +872,13 @@ internal class SessionReplayFeatureTest { expectedMessage ) - verifyNoInteractions(mockRecorder) + verify(mockRecorder, never()).resumeRecorders() } @Test - fun `M log warning and do nothing W onReceive() { missing mandatory fields }`() { + fun `M log warning and do nothing W onReceive() { missing mandatory fields }`( + @Mock fakeContext: Application + ) { // Given val event = mapOf( SessionReplayFeature.SESSION_REPLAY_BUS_MESSAGE_TYPE_KEY to @@ -851,6 +886,7 @@ internal class SessionReplayFeatureTest { ) // When + testedFeature.onInitialize(fakeContext) testedFeature.onReceive(event) // Then @@ -860,11 +896,13 @@ internal class SessionReplayFeatureTest { SessionReplayFeature.EVENT_MISSING_MANDATORY_FIELDS ) - verifyNoInteractions(mockRecorder) + verify(mockRecorder, never()).resumeRecorders() } @Test - fun `M log warning and do nothing W onReceive() { missing keep state field }`() { + fun `M log warning and do nothing W onReceive() { missing keep state field }`( + @Mock fakeContext: Application + ) { // Given val event = mapOf( SessionReplayFeature.SESSION_REPLAY_BUS_MESSAGE_TYPE_KEY to @@ -873,6 +911,7 @@ internal class SessionReplayFeatureTest { ) // When + testedFeature.onInitialize(fakeContext) testedFeature.onReceive(event) // Then @@ -882,12 +921,13 @@ internal class SessionReplayFeatureTest { SessionReplayFeature.EVENT_MISSING_MANDATORY_FIELDS ) - verifyNoInteractions(mockRecorder) + verify(mockRecorder, never()).resumeRecorders() } @Test fun `M log warning and do nothing W onReceive() { missing session id field }`( - @BoolForgery fakeKeep: Boolean + @BoolForgery fakeKeep: Boolean, + @Mock fakeContext: Application ) { // Given val event = mapOf( @@ -897,6 +937,7 @@ internal class SessionReplayFeatureTest { ) // When + testedFeature.onInitialize(fakeContext) testedFeature.onReceive(event) // Then @@ -906,12 +947,13 @@ internal class SessionReplayFeatureTest { SessionReplayFeature.EVENT_MISSING_MANDATORY_FIELDS ) - verifyNoInteractions(mockRecorder) + verify(mockRecorder, never()).resumeRecorders() } @Test fun `M log warning and do nothing W onReceive() { mandatory fields have wrong format }`( - forge: Forge + forge: Forge, + @Mock fakeContext: Application ) { // Given val event = mapOf( @@ -924,6 +966,7 @@ internal class SessionReplayFeatureTest { ) // When + testedFeature.onInitialize(fakeContext) testedFeature.onReceive(event) // Then @@ -933,7 +976,7 @@ internal class SessionReplayFeatureTest { SessionReplayFeature.EVENT_MISSING_MANDATORY_FIELDS ) - verifyNoInteractions(mockRecorder) + verify(mockRecorder, never()).resumeRecorders() } @Test @@ -957,6 +1000,158 @@ internal class SessionReplayFeatureTest { .isEqualTo(SessionReplayFeature.STORAGE_CONFIGURATION) } + // region startRecordingImmediately + + @ParameterizedTest + @MethodSource("recordingScenarioProvider") + fun `M start recording W startRecordingImmediately`( + scenario: SessionRecordingScenario + ) { + // Given + val fakeContext = mock() + val event = mapOf( + SessionReplayFeature.SESSION_REPLAY_BUS_MESSAGE_TYPE_KEY to + SessionReplayFeature.RUM_SESSION_RENEWED_BUS_MESSAGE, + SessionReplayFeature.RUM_KEEP_SESSION_BUS_MESSAGE_KEY to scenario.keepSession, + SessionReplayFeature.RUM_SESSION_ID_BUS_MESSAGE_KEY to fakeSessionId + ) + + whenever(mockSampler.sample()).thenReturn(scenario.sampleInSession) + + // When + testedFeature = SessionReplayFeature( + sdkCore = mockSdkCore, + customEndpointUrl = fakeConfiguration.customEndpointUrl, + privacy = fakeConfiguration.privacy, + textAndInputPrivacy = fakeConfiguration.textAndInputPrivacy, + imagePrivacy = fakeConfiguration.imagePrivacy, + touchPrivacy = fakeConfiguration.touchPrivacy, + startRecordingImmediately = scenario.startRecordingImmediately, + rateBasedSampler = mockSampler + ) { _, _, _, _ -> mockRecorder } + testedFeature.onInitialize(fakeContext) + testedFeature.onReceive(event) + + // Then + if (scenario.expectedResult) { + verify(mockRecorder).resumeRecorders() + } else { + verify(mockRecorder, never()).resumeRecorders() + } + } + + // endregion + + // region manual stop/start + + @Test + fun `M start recorders only once W onReceive { sessionId is the same and recordingState did not change }`( + @Mock fakeContext: Application + ) { + // Given + val event = mapOf( + SessionReplayFeature.SESSION_REPLAY_BUS_MESSAGE_TYPE_KEY to + SessionReplayFeature.RUM_SESSION_RENEWED_BUS_MESSAGE, + SessionReplayFeature.RUM_KEEP_SESSION_BUS_MESSAGE_KEY to true, + SessionReplayFeature.RUM_SESSION_ID_BUS_MESSAGE_KEY to fakeSessionId + ) + whenever(mockSampler.sample()).thenReturn(true) + + // When + testedFeature = SessionReplayFeature( + sdkCore = mockSdkCore, + customEndpointUrl = fakeConfiguration.customEndpointUrl, + privacy = fakeConfiguration.privacy, + textAndInputPrivacy = fakeConfiguration.textAndInputPrivacy, + imagePrivacy = fakeConfiguration.imagePrivacy, + touchPrivacy = fakeConfiguration.touchPrivacy, + startRecordingImmediately = true, + rateBasedSampler = mockSampler + ) { _, _, _, _ -> mockRecorder } + testedFeature.onInitialize(fakeContext) + testedFeature.onReceive(event) + testedFeature.onReceive(event) + + // Then + verify(mockRecorder, times(1)).resumeRecorders() + } + + @Test + fun `M call resumeRecorders W manuallyStartRecording`( + @Mock fakeContext: Application + ) { + // Given + val event = mapOf( + SessionReplayFeature.SESSION_REPLAY_BUS_MESSAGE_TYPE_KEY to + SessionReplayFeature.RUM_SESSION_RENEWED_BUS_MESSAGE, + SessionReplayFeature.RUM_KEEP_SESSION_BUS_MESSAGE_KEY to true, + SessionReplayFeature.RUM_SESSION_ID_BUS_MESSAGE_KEY to fakeSessionId + ) + whenever(mockSampler.sample()).thenReturn(true) + + // When + testedFeature = SessionReplayFeature( + sdkCore = mockSdkCore, + customEndpointUrl = fakeConfiguration.customEndpointUrl, + privacy = fakeConfiguration.privacy, + textAndInputPrivacy = fakeConfiguration.textAndInputPrivacy, + imagePrivacy = fakeConfiguration.imagePrivacy, + touchPrivacy = fakeConfiguration.touchPrivacy, + startRecordingImmediately = false, + rateBasedSampler = mockSampler + ) { _, _, _, _ -> mockRecorder } + testedFeature.onInitialize(fakeContext) + testedFeature.manuallyStartRecording() + testedFeature.onReceive(event) + + // Then + verify(mockRecorder).resumeRecorders() + } + + @Test + fun `M call stopRecorders W manuallyStopRecording { if already recording }`( + @Mock fakeContext: Application, + @StringForgery fakeSessionId: String + ) { + // Given + val event = mapOf( + SessionReplayFeature.SESSION_REPLAY_BUS_MESSAGE_TYPE_KEY to + SessionReplayFeature.RUM_SESSION_RENEWED_BUS_MESSAGE, + SessionReplayFeature.RUM_KEEP_SESSION_BUS_MESSAGE_KEY to true, + SessionReplayFeature.RUM_SESSION_ID_BUS_MESSAGE_KEY to fakeSessionId + ) + + whenever(mockSampler.sample()).thenReturn(true) + + // When + testedFeature = SessionReplayFeature( + sdkCore = mockSdkCore, + customEndpointUrl = fakeConfiguration.customEndpointUrl, + privacy = fakeConfiguration.privacy, + textAndInputPrivacy = fakeConfiguration.textAndInputPrivacy, + imagePrivacy = fakeConfiguration.imagePrivacy, + touchPrivacy = fakeConfiguration.touchPrivacy, + startRecordingImmediately = true, + rateBasedSampler = mockSampler + ) { _, _, _, _ -> mockRecorder } + testedFeature.onInitialize(fakeContext) + testedFeature.onReceive(event) + testedFeature.manuallyStopRecording() + testedFeature.onReceive(event) + + // Then + verify(mockRecorder).stopRecorders() + } + + // endregion + + internal data class SessionRecordingScenario( + val startRecordingImmediately: Boolean, + val keepSession: Boolean, + val sampleInSession: Boolean, + val expectedResult: Boolean + ) + companion object { val appContext = ApplicationContextTestConfiguration(Application::class.java) @@ -966,5 +1161,75 @@ internal class SessionReplayFeatureTest { fun getTestConfigurations(): List { return listOf(appContext) } + + @JvmStatic + fun recordingScenarioProvider(): Stream { + return Stream.of( + Arguments.of( + SessionRecordingScenario( + startRecordingImmediately = true, + keepSession = true, + sampleInSession = true, + expectedResult = true + ) + ), + Arguments.of( + SessionRecordingScenario( + startRecordingImmediately = true, + keepSession = false, + sampleInSession = true, + expectedResult = false + ) + ), + Arguments.of( + SessionRecordingScenario( + startRecordingImmediately = true, + keepSession = true, + sampleInSession = false, + expectedResult = false + ) + ), + Arguments.of( + SessionRecordingScenario( + startRecordingImmediately = true, + keepSession = false, + sampleInSession = false, + expectedResult = false + ) + ), + Arguments.of( + SessionRecordingScenario( + startRecordingImmediately = false, + keepSession = true, + sampleInSession = true, + expectedResult = false + ) + ), + Arguments.of( + SessionRecordingScenario( + startRecordingImmediately = false, + keepSession = false, + sampleInSession = true, + expectedResult = false + ) + ), + Arguments.of( + SessionRecordingScenario( + startRecordingImmediately = false, + keepSession = true, + sampleInSession = false, + expectedResult = false + ) + ), + Arguments.of( + SessionRecordingScenario( + startRecordingImmediately = false, + keepSession = false, + sampleInSession = false, + expectedResult = false + ) + ) + ) + } } } diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/TouchPrivacyManagerTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/TouchPrivacyManagerTest.kt new file mode 100644 index 0000000000..98f2f3a614 --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/TouchPrivacyManagerTest.kt @@ -0,0 +1,157 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.datadog.android.sessionreplay.internal + +import android.graphics.Point +import android.graphics.Rect +import com.datadog.android.sessionreplay.TouchPrivacy +import com.datadog.android.sessionreplay.forge.ForgeConfigurator +import fr.xgouchet.elmyr.Forge +import fr.xgouchet.elmyr.junit5.ForgeConfiguration +import fr.xgouchet.elmyr.junit5.ForgeExtension +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.junit.jupiter.api.extension.Extensions +import org.mockito.Mockito.mock +import org.mockito.junit.jupiter.MockitoExtension +import org.mockito.junit.jupiter.MockitoSettings +import org.mockito.quality.Strictness + +@Extensions( + ExtendWith(MockitoExtension::class), + ExtendWith(ForgeExtension::class) +) +@MockitoSettings(strictness = Strictness.LENIENT) +@ForgeConfiguration(ForgeConfigurator::class) +internal class TouchPrivacyManagerTest { + private lateinit var testedManager: TouchPrivacyManager + + @BeforeEach + fun `set up`(forge: Forge) { + val fakeGlobalPrivacy = forge.aValueFrom(TouchPrivacy::class.java) + testedManager = TouchPrivacyManager(fakeGlobalPrivacy) + } + + @Test + fun `M add to nextOverrideAreas W addTouchOverrideArea()`( + forge: Forge + ) { + // Given + val fakePrivacyOverride = forge.aValueFrom(TouchPrivacy::class.java) + val mockOverrideArea = mock() + + // When + testedManager.addTouchOverrideArea(mockOverrideArea, fakePrivacyOverride) + + // Then + assertThat(testedManager.getNextOverrideAreas()[mockOverrideArea]).isEqualTo(fakePrivacyOverride) + } + + @Test + fun `M replace currentAreas W updateCurrentTouchOverrideAreas()`( + forge: Forge + ) { + // Given + val fakePrivacyOverride = forge.aValueFrom(TouchPrivacy::class.java) + val mockOverrideArea = mock() + testedManager.addTouchOverrideArea(mockOverrideArea, fakePrivacyOverride) + assertThat(testedManager.getNextOverrideAreas()[mockOverrideArea]).isEqualTo(fakePrivacyOverride) + + // When + testedManager.updateCurrentTouchOverrideAreas() + + // Then + assertThat(testedManager.getCurrentOverrideAreas()[mockOverrideArea]).isEqualTo(fakePrivacyOverride) + assertThat(testedManager.getNextOverrideAreas()).isEmpty() + } + + @Test + fun `M return override W shouldRecordTouch() { within override area }`( + forge: Forge + ) { + // Given + testedManager = TouchPrivacyManager(TouchPrivacy.HIDE) + val fakePrivacyOverride = TouchPrivacy.SHOW + val touchLocation = Point( + forge.aPositiveInt(), + forge.aPositiveInt() + ) + + val overrideArea = Rect( + touchLocation.x - forge.aPositiveInt(), + touchLocation.y - forge.aPositiveInt(), + touchLocation.x + forge.aPositiveInt(), + touchLocation.y + forge.aPositiveInt() + ) + + testedManager.addTouchOverrideArea(overrideArea, fakePrivacyOverride) + testedManager.updateCurrentTouchOverrideAreas() + + // Then + assertThat(testedManager.shouldRecordTouch(touchLocation)).isTrue() + } + + @Test + fun `M use global privacy W shouldRecordTouch() { outside override area }`( + forge: Forge + ) { + // Given + testedManager = TouchPrivacyManager(TouchPrivacy.SHOW) + val fakeTouchX = forge.aPositiveInt() + val fakeTouchY = forge.aPositiveInt() + val fakePoint = mock() + fakePoint.x = fakeTouchX + fakePoint.y = fakeTouchY + + val fakeOverrideArea = Rect( + fakeTouchX + 1, + fakeTouchY + 1, + fakeTouchX + 100, + fakeTouchY + 100 + ) + + testedManager.addTouchOverrideArea(fakeOverrideArea, TouchPrivacy.HIDE) + testedManager.updateCurrentTouchOverrideAreas() + + // Then + assertThat(testedManager.shouldRecordTouch(fakePoint)).isTrue() + } + + @Test + fun `M return false W shouldRecordTouch { matches both HIDE and SHOW }`( + forge: Forge + ) { + // Given + val touchLocation = Point( + forge.aPositiveInt(), + forge.aPositiveInt() + ) + + val hiddenTouchArea = Rect( + touchLocation.x - forge.aPositiveInt(), + touchLocation.y - forge.aPositiveInt(), + touchLocation.x + forge.aPositiveInt(), + touchLocation.y + forge.aPositiveInt() + ) + + val shownTouchArea = Rect( + touchLocation.x - forge.aPositiveInt(), + touchLocation.y - forge.aPositiveInt(), + touchLocation.x + forge.aPositiveInt(), + touchLocation.y + forge.aPositiveInt() + ) + + testedManager.addTouchOverrideArea(hiddenTouchArea, TouchPrivacy.HIDE) + testedManager.addTouchOverrideArea(shownTouchArea, TouchPrivacy.SHOW) + testedManager.updateCurrentTouchOverrideAreas() + + // Then + assertThat(testedManager.shouldRecordTouch(touchLocation)).isFalse() + } +} diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/net/BatchesToSegmentsMapperTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/net/BatchesToSegmentsMapperTest.kt index 61337ce621..01c1dc6f15 100644 --- a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/net/BatchesToSegmentsMapperTest.kt +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/net/BatchesToSegmentsMapperTest.kt @@ -7,6 +7,7 @@ package com.datadog.android.sessionreplay.internal.net import com.datadog.android.api.InternalLogger +import com.datadog.android.api.context.DatadogContext import com.datadog.android.sessionreplay.forge.ForgeConfigurator import com.datadog.android.sessionreplay.internal.processor.EnrichedRecord import com.datadog.android.sessionreplay.internal.utils.SessionReplayRumContext @@ -15,6 +16,7 @@ import com.datadog.android.utils.verifyLog import com.google.gson.JsonParser import com.google.gson.JsonPrimitive import fr.xgouchet.elmyr.Forge +import fr.xgouchet.elmyr.annotation.Forgery import fr.xgouchet.elmyr.junit5.ForgeConfiguration import fr.xgouchet.elmyr.junit5.ForgeExtension import fr.xgouchet.elmyr.jvm.ext.aTimestamp @@ -40,11 +42,22 @@ internal class BatchesToSegmentsMapperTest { @Mock lateinit var mockInternalLogger: InternalLogger + @Forgery + lateinit var datadogContext: DatadogContext + private lateinit var testedMapper: BatchesToSegmentsMapper + var fakeSegmentSource: MobileSegment.Source? = null + @BeforeEach - fun `set up`() { + fun `set up`(forge: Forge) { testedMapper = BatchesToSegmentsMapper(mockInternalLogger) + + fakeSegmentSource = forge.aNullable { aValueFrom(MobileSegment.Source::class.java) } + + datadogContext = datadogContext.copy( + source = fakeSegmentSource?.toJson()?.asString ?: forge.aString() + ) } @Test @@ -71,7 +84,7 @@ internal class BatchesToSegmentsMapperTest { val fakeBatchData = fakeEnrichedRecords.map { it.toJson().toByteArray() } // When - val mappedSegments = testedMapper.map(fakeBatchData) + val mappedSegments = testedMapper.map(datadogContext, fakeBatchData) // Then assertThat(mappedSegments.size).isEqualTo(fakeEnrichedRecords.size) @@ -106,7 +119,7 @@ internal class BatchesToSegmentsMapperTest { val fakeBatchData = fakeEnrichedRecords.map { it.toJson().toByteArray() } // When - val mappedSegments = testedMapper.map(fakeBatchData) + val mappedSegments = testedMapper.map(datadogContext, fakeBatchData) // Then mappedSegments.forEachIndexed { index, pair -> @@ -140,7 +153,7 @@ internal class BatchesToSegmentsMapperTest { val fakeBatchData = fakeEnrichedRecords.map { it.toJson().toByteArray() } // When - val mappedSegments = testedMapper.map(fakeBatchData) + val mappedSegments = testedMapper.map(datadogContext, fakeBatchData) // Then mappedSegments.forEachIndexed { index, pair -> @@ -177,7 +190,7 @@ internal class BatchesToSegmentsMapperTest { val fakeBatchData = fakeEnrichedRecords.map { it.toJson().toByteArray() } // When - val mappedSegments = testedMapper.map(fakeBatchData) + val mappedSegments = testedMapper.map(datadogContext, fakeBatchData) // Then mappedSegments.forEachIndexed { index, pair -> @@ -201,7 +214,7 @@ internal class BatchesToSegmentsMapperTest { val fakeBatchData = fakeEnrichedRecords.map { it.toJson().toByteArray() } // When - assertThat(testedMapper.map(fakeBatchData)).isEmpty() + assertThat(testedMapper.map(datadogContext, fakeBatchData)).isEmpty() } @Test @@ -212,7 +225,7 @@ internal class BatchesToSegmentsMapperTest { val fakeBatchData = forge.aList { forge.anAlphabeticalString().toByteArray() } // When - assertThat(testedMapper.map(fakeBatchData)).isEmpty() + assertThat(testedMapper.map(datadogContext, fakeBatchData)).isEmpty() } @Test @@ -249,7 +262,7 @@ internal class BatchesToSegmentsMapperTest { .map { it.toString().toByteArray() } // When - assertThat(testedMapper.map(fakeBatchData)).isEmpty() + assertThat(testedMapper.map(datadogContext, fakeBatchData)).isEmpty() } @Test @@ -292,7 +305,7 @@ internal class BatchesToSegmentsMapperTest { .map { it.toString().toByteArray() } // When - val mappedSegments = testedMapper.map(fakeBatchData) + val mappedSegments = testedMapper.map(datadogContext, fakeBatchData) val expectedRecordsSize = fakeRecords.size - removedRecords assertThat(mappedSegments.size).isEqualTo(1) assertThat(mappedSegments[0].first.recordsCount.toInt()).isEqualTo(expectedRecordsSize) @@ -329,7 +342,7 @@ internal class BatchesToSegmentsMapperTest { } .map { it.toString().toByteArray() } // When - assertThat(testedMapper.map(fakeBatchData)).isEmpty() + assertThat(testedMapper.map(datadogContext, fakeBatchData)).isEmpty() mockInternalLogger.verifyLog( InternalLogger.Level.ERROR, InternalLogger.Target.TELEMETRY, @@ -376,7 +389,7 @@ internal class BatchesToSegmentsMapperTest { .map { it.toString().toByteArray() } // When - val mappedSegments = testedMapper.map(fakeBatchData) + val mappedSegments = testedMapper.map(datadogContext, fakeBatchData) assertThat(mappedSegments.size).isEqualTo(1) val expectedRecordsSize = fakeRecords.size - removedRecords assertThat(mappedSegments[0].first.recordsCount.toInt()).isEqualTo(expectedRecordsSize) @@ -415,7 +428,7 @@ internal class BatchesToSegmentsMapperTest { .map { it.toString().toByteArray() } // When - assertThat(testedMapper.map(fakeBatchData)).isEmpty() + assertThat(testedMapper.map(datadogContext, fakeBatchData)).isEmpty() mockInternalLogger.verifyLog( InternalLogger.Level.ERROR, InternalLogger.Target.TELEMETRY, @@ -462,7 +475,7 @@ internal class BatchesToSegmentsMapperTest { .map { it.toString().toByteArray() } // When - val mappedSegments = testedMapper.map(fakeBatchData) + val mappedSegments = testedMapper.map(datadogContext, fakeBatchData) // Then val expectedRecordsSize = fakeRecords.size - removedRecords @@ -502,7 +515,7 @@ internal class BatchesToSegmentsMapperTest { .map { it.toString().toByteArray() } // Then - assertThat(testedMapper.map(fakeBatchData)).isEmpty() + assertThat(testedMapper.map(datadogContext, fakeBatchData)).isEmpty() mockInternalLogger.verifyLog( InternalLogger.Level.ERROR, InternalLogger.Target.TELEMETRY, @@ -549,7 +562,7 @@ internal class BatchesToSegmentsMapperTest { .map { it.toString().toByteArray() } // When - val mappedSegments = testedMapper.map(fakeBatchData) + val mappedSegments = testedMapper.map(datadogContext, fakeBatchData) // Then val expectedRecordsSize = fakeRecords.size - removedRecords @@ -603,7 +616,7 @@ internal class BatchesToSegmentsMapperTest { .map { it.toString().toByteArray() } // When - val mappedSegments = testedMapper.map(fakeBatchData) + val mappedSegments = testedMapper.map(datadogContext, fakeBatchData) // Then val expectedRecordsSize = fakeRecords.size - removedRecords @@ -651,7 +664,7 @@ internal class BatchesToSegmentsMapperTest { .map { it.toString().toByteArray() } // When - val mappedSegments = testedMapper.map(fakeBatchData) + val mappedSegments = testedMapper.map(datadogContext, fakeBatchData) // Then val expectedRecordsSize = fakeRecords.size - removedRecords @@ -673,7 +686,7 @@ internal class BatchesToSegmentsMapperTest { recordsCount = records.size.toLong(), indexInView = null, hasFullSnapshot = hasFullSnapshot(), - source = MobileSegment.Source.ANDROID, + source = fakeSegmentSource ?: MobileSegment.Source.ANDROID, records = records ) } diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/net/ResourcesRequestFactoryTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/net/ResourcesRequestFactoryTest.kt index abc0602302..bee65c2f2d 100644 --- a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/net/ResourcesRequestFactoryTest.kt +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/net/ResourcesRequestFactoryTest.kt @@ -11,6 +11,7 @@ import com.datadog.android.api.InternalLogger import com.datadog.android.api.context.DatadogContext import com.datadog.android.api.feature.Feature import com.datadog.android.api.net.Request +import com.datadog.android.api.net.RequestExecutionContext import com.datadog.android.api.net.RequestFactory import com.datadog.android.api.net.RequestFactory.Companion.HEADER_API_KEY import com.datadog.android.api.net.RequestFactory.Companion.HEADER_EVP_ORIGIN @@ -20,6 +21,7 @@ import com.datadog.android.sessionreplay.forge.ForgeConfigurator import com.datadog.android.sessionreplay.internal.net.ResourcesRequestFactory.Companion.APPLICATION_ID import com.datadog.android.sessionreplay.internal.net.ResourcesRequestFactory.Companion.UPLOAD_DESCRIPTION import fr.xgouchet.elmyr.Forge +import fr.xgouchet.elmyr.annotation.Forgery import fr.xgouchet.elmyr.annotation.StringForgery import fr.xgouchet.elmyr.junit5.ForgeConfiguration import fr.xgouchet.elmyr.junit5.ForgeExtension @@ -69,6 +71,9 @@ internal class ResourcesRequestFactoryTest { @Mock lateinit var fakeDatadogContext: DatadogContext + @Forgery + lateinit var fakeExecutionContext: RequestExecutionContext + @BeforeEach fun `set up`(forge: Forge) { val fakeRumFeature = mapOf(APPLICATION_ID to fakeApplicationId) @@ -103,6 +108,7 @@ internal class ResourcesRequestFactoryTest { // When val request = testedRequestFactory.create( fakeDatadogContext, + fakeExecutionContext, fakeRawBatchEvents, null ) @@ -138,6 +144,7 @@ internal class ResourcesRequestFactoryTest { // When val request = testedRequestFactory.create( fakeDatadogContext, + fakeExecutionContext, fakeRawBatchEvents, null ) diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/net/SegmentRequestFactoryTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/net/SegmentRequestFactoryTest.kt index 283957695e..4cb7f9651e 100644 --- a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/net/SegmentRequestFactoryTest.kt +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/net/SegmentRequestFactoryTest.kt @@ -7,6 +7,7 @@ package com.datadog.android.sessionreplay.internal.net import com.datadog.android.api.context.DatadogContext +import com.datadog.android.api.net.RequestExecutionContext import com.datadog.android.api.net.RequestFactory import com.datadog.android.api.storage.RawBatchEvent import com.datadog.android.sessionreplay.forge.ForgeConfigurator @@ -58,6 +59,9 @@ internal class SegmentRequestFactoryTest { @Forgery lateinit var fakeDatadogContext: DatadogContext + @Forgery + lateinit var fakeExecutionContext: RequestExecutionContext + @Mock lateinit var mockRequestBody: RequestBody @@ -87,7 +91,7 @@ internal class SegmentRequestFactoryTest { fakeBatchMetadata = forge.aNullable { forge.aString().toByteArray() } whenever(mockSegmentRequestBodyFactory.create(fakeDataGroup)) .thenReturn(mockRequestBody) - whenever(mockBatchesToSegmentsMapper.map(fakeBatchData.map { it.data })) + whenever(mockBatchesToSegmentsMapper.map(fakeDatadogContext, fakeBatchData.map { it.data })) .thenReturn(fakeDataGroup) testedRequestFactory = SegmentRequestFactory( customEndpointUrl = null, @@ -103,6 +107,7 @@ internal class SegmentRequestFactoryTest { // When val request = testedRequestFactory.create( fakeDatadogContext, + fakeExecutionContext, fakeBatchData, fakeBatchMetadata ) @@ -136,6 +141,7 @@ internal class SegmentRequestFactoryTest { ) val request = testedRequestFactory.create( fakeDatadogContext, + fakeExecutionContext, fakeBatchData, fakeBatchMetadata ) @@ -160,12 +166,17 @@ internal class SegmentRequestFactoryTest { @Test fun `M throw exception W create(){ payload is broken }`() { // Given - whenever(mockBatchesToSegmentsMapper.map(fakeBatchData.map { it.data })) + whenever(mockBatchesToSegmentsMapper.map(fakeDatadogContext, fakeBatchData.map { it.data })) .thenReturn(emptyList()) // When assertThatThrownBy { - testedRequestFactory.create(fakeDatadogContext, fakeBatchData, fakeBatchMetadata) + testedRequestFactory.create( + fakeDatadogContext, + fakeExecutionContext, + fakeBatchData, + fakeBatchMetadata + ) } .isInstanceOf(InvalidPayloadFormatException::class.java) .hasMessage( diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/prerequisite/CPURequirementCheckerTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/prerequisite/CPURequirementCheckerTest.kt new file mode 100644 index 0000000000..92a6e4b741 --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/prerequisite/CPURequirementCheckerTest.kt @@ -0,0 +1,114 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.datadog.android.sessionreplay.internal.prerequisite + +import com.datadog.android.api.InternalLogger +import com.datadog.android.sessionreplay.forge.ForgeConfigurator +import fr.xgouchet.elmyr.annotation.IntForgery +import fr.xgouchet.elmyr.junit5.ForgeConfiguration +import fr.xgouchet.elmyr.junit5.ForgeExtension +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.junit.jupiter.api.extension.Extensions +import org.mockito.Mock +import org.mockito.junit.jupiter.MockitoExtension +import org.mockito.junit.jupiter.MockitoSettings +import org.mockito.kotlin.any +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.mockito.quality.Strictness +import java.io.File +import java.io.FilenameFilter + +@Extensions( + ExtendWith(MockitoExtension::class), + ExtendWith(ForgeExtension::class) +) +@MockitoSettings(strictness = Strictness.LENIENT) +@ForgeConfiguration(ForgeConfigurator::class) +class CPURequirementCheckerTest { + + private lateinit var testedCPURequirementChecker: CPURequirementChecker + + @Mock + private lateinit var mockCpuDir: File + + @Mock + private lateinit var mockInternalLogger: InternalLogger + + @IntForgery(min = 1, max = 10) + private var fakeCpuCores: Int = 0 + + private lateinit var fakeCpuFileList: List + + @BeforeEach + fun `set up`() { + fakeCpuFileList = mockCpuFileList(fakeCpuCores) + testedCPURequirementChecker = CPURequirementChecker( + minCPUCores = fakeCpuCores, + cpuDirFile = mockCpuDir, + internalLogger = mockInternalLogger + ) + } + + @Test + fun `M have correct name W name()`() { + assertThat(testedCPURequirementChecker.name()).isEqualTo("cpu") + } + + @Test + fun `M return true W checkMinimumRequirement { cpu core number meets configuration }`() { + // Given + val fakeCpuFileList = mockCpuFileList(fakeCpuCores + 1) + + // When + whenever(mockCpuDir.listFiles(any())).doReturn(fakeCpuFileList.toTypedArray()) + val result = testedCPURequirementChecker.checkMinimumRequirement() + + // Then + assertThat(result).isTrue() + } + + @Test + fun `M return false W checkMinimumRequirement { cpu core number does not meet configuration }`() { + // Given + val fakeCpuFileList = mockCpuFileList(fakeCpuCores - 1) + + // When + whenever(mockCpuDir.listFiles(any())).doReturn(fakeCpuFileList.toTypedArray()) + val result = testedCPURequirementChecker.checkMinimumRequirement() + + // Then + assertThat(result).isFalse() + } + + @Test + fun `M return correct checked value W checkMinimumRequirement`() { + // Given + val fakeCpuFileList = mockCpuFileList(fakeCpuCores) + + // When + whenever(mockCpuDir.listFiles(any())).doReturn(fakeCpuFileList.toTypedArray()) + testedCPURequirementChecker.checkMinimumRequirement() + + // Then + assertThat(testedCPURequirementChecker.checkedValue()).isEqualTo(fakeCpuCores) + } + + private fun mockCpuFileList(number: Int): List { + val fakeCpuFileList = mutableListOf() + repeat(number) { index -> + fakeCpuFileList += mock { + whenever(it.name).doReturn("cpu$index") + } + } + return fakeCpuFileList + } +} diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/prerequisite/MemoryRequirementCheckerTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/prerequisite/MemoryRequirementCheckerTest.kt new file mode 100644 index 0000000000..02e63aac80 --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/prerequisite/MemoryRequirementCheckerTest.kt @@ -0,0 +1,130 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.datadog.android.sessionreplay.internal.prerequisite + +import com.datadog.android.api.InternalLogger +import com.datadog.android.sessionreplay.forge.ForgeConfigurator +import fr.xgouchet.elmyr.annotation.IntForgery +import fr.xgouchet.elmyr.annotation.LongForgery +import fr.xgouchet.elmyr.junit5.ForgeConfiguration +import fr.xgouchet.elmyr.junit5.ForgeExtension +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.junit.jupiter.api.extension.Extensions +import org.junit.jupiter.api.io.TempDir +import org.mockito.Mock +import org.mockito.junit.jupiter.MockitoExtension +import org.mockito.junit.jupiter.MockitoSettings +import org.mockito.quality.Strictness +import java.io.File + +@Extensions( + ExtendWith(MockitoExtension::class), + ExtendWith(ForgeExtension::class) +) +@MockitoSettings(strictness = Strictness.LENIENT) +@ForgeConfiguration(ForgeConfigurator::class) +class MemoryRequirementCheckerTest { + + private lateinit var testedMemoryRequirementChecker: MemoryRequirementChecker + + @TempDir + lateinit var tempDir: File + + private lateinit var fakeMemoryFile: File + + @Mock + private lateinit var mockInternalLogger: InternalLogger + + @IntForgery(min = 0) + private var fakeMemorySizeInMb: Int = 0 + + @LongForgery(min = 0) + private var fakeMemoryFree: Long = 0L + + @LongForgery(min = 0) + private var fakeMemoryAvailable: Long = 0L + + @BeforeEach + fun `set up`() { + fakeMemoryFile = File(tempDir, "meminfo") + testedMemoryRequirementChecker = MemoryRequirementChecker( + minRamSizeMb = fakeMemorySizeInMb, + memInfoFile = fakeMemoryFile, + internalLogger = mockInternalLogger + ) + } + + @Test + fun `M have correct name W call name()`() { + assertThat(testedMemoryRequirementChecker.name()).isEqualTo("ram") + } + + @Test + fun `M return true W check memory size meet configuration`() { + // Given + val fakeMemoryFileContent = generateFakeMemoryFileContent(fakeMemorySizeInMb + 1) + testedMemoryRequirementChecker = MemoryRequirementChecker( + minRamSizeMb = fakeMemorySizeInMb, + memInfoFile = fakeMemoryFile, + internalLogger = mockInternalLogger + ) + // When + fakeMemoryFile.writeText(fakeMemoryFileContent) + val result = testedMemoryRequirementChecker.checkMinimumRequirement() + + // Then + assertThat(result).isTrue() + } + + @Test + fun `M return false W check cpu core number not meet configuration`() { + // Given + val fakeMemoryFileContent = generateFakeMemoryFileContent(fakeMemorySizeInMb - 1) + testedMemoryRequirementChecker = MemoryRequirementChecker( + minRamSizeMb = fakeMemorySizeInMb, + memInfoFile = fakeMemoryFile, + internalLogger = mockInternalLogger + ) + + // When + fakeMemoryFile.writeText(fakeMemoryFileContent) + val result = testedMemoryRequirementChecker.checkMinimumRequirement() + + // Then + assertThat(result).isFalse() + } + + @Test + fun `M return correct checked value W check check`() { + // Given + val fakeMemoryFileContent = generateFakeMemoryFileContent(fakeMemorySizeInMb) + testedMemoryRequirementChecker = MemoryRequirementChecker( + minRamSizeMb = fakeMemorySizeInMb, + memInfoFile = fakeMemoryFile, + internalLogger = mockInternalLogger + ) + + // When + fakeMemoryFile.writeText(fakeMemoryFileContent) + testedMemoryRequirementChecker.checkMinimumRequirement() + + // Then + assertThat(testedMemoryRequirementChecker.checkedValue()).isEqualTo((fakeMemorySizeInMb).toLong()) + } + + private fun generateFakeMemoryFileContent(memorySizeInMb: Int): String { + return mapOf( + "MemTotal" to (memorySizeInMb.toLong() * 1000), + "MemFree" to fakeMemoryFree, + "MemAvailable" to fakeMemoryAvailable + ).map { (key, value) -> "$key:\t$value" } + .joinToString("\n") + } +} diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/prerequisite/SystemRequirementsConfigurationTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/prerequisite/SystemRequirementsConfigurationTest.kt new file mode 100644 index 0000000000..165356203b --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/prerequisite/SystemRequirementsConfigurationTest.kt @@ -0,0 +1,74 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.datadog.android.sessionreplay.internal.prerequisite + +import com.datadog.android.sessionreplay.SystemRequirementsConfiguration +import com.datadog.android.sessionreplay.forge.ForgeConfigurator +import fr.xgouchet.elmyr.annotation.IntForgery +import fr.xgouchet.elmyr.junit5.ForgeConfiguration +import fr.xgouchet.elmyr.junit5.ForgeExtension +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.junit.jupiter.api.extension.Extensions +import org.mockito.junit.jupiter.MockitoExtension +import org.mockito.junit.jupiter.MockitoSettings +import org.mockito.quality.Strictness + +@Extensions( + ExtendWith(MockitoExtension::class), + ExtendWith(ForgeExtension::class) +) +@MockitoSettings(strictness = Strictness.LENIENT) +@ForgeConfiguration(ForgeConfigurator::class) +class SystemRequirementsConfigurationTest { + + @IntForgery + private var fakeMemorySize: Int = 0 + + @IntForgery + private var fakeCpuCores: Int = 0 + + @Test + fun `M have correct default value W build()`() { + // Given + val builder = SystemRequirementsConfiguration.Builder() + + // When + val result = builder.build() + + // Then + assertThat(result.minCPUCores).isZero() + assertThat(result.minRAMSizeMb).isZero() + } + + @Test + fun `M have 0 value W use NONE`() { + // Given + val result = SystemRequirementsConfiguration.NONE + + // Then + assertThat(result.minCPUCores).isZero() + assertThat(result.minRAMSizeMb).isZero() + } + + @Test + fun `M build correct instance W build()`() { + // Given + val builder = SystemRequirementsConfiguration.Builder() + + // When + val result = builder + .setMinRAMSizeMb(fakeMemorySize) + .setMinCPUCoreNumber(fakeCpuCores) + .build() + + // Then + assertThat(result.minCPUCores).isEqualTo(fakeCpuCores) + assertThat(result.minRAMSizeMb).isEqualTo(fakeMemorySize) + } +} diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/processor/MobileSegmentExtTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/processor/MobileSegmentExtTest.kt new file mode 100644 index 0000000000..91e0b57c1f --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/processor/MobileSegmentExtTest.kt @@ -0,0 +1,90 @@ +package com.datadog.android.sessionreplay.internal.processor + +import com.datadog.android.api.InternalLogger +import com.datadog.android.sessionreplay.model.MobileSegment +import fr.xgouchet.elmyr.Forge +import fr.xgouchet.elmyr.junit5.ForgeExtension +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.junit.jupiter.api.extension.Extensions +import org.mockito.Mock +import org.mockito.junit.jupiter.MockitoExtension +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.eq +import org.mockito.kotlin.isA +import org.mockito.kotlin.isNull +import org.mockito.kotlin.verify +import java.util.Locale + +@Extensions( + ExtendWith(ForgeExtension::class), + ExtendWith(MockitoExtension::class) +) +internal class MobileSegmentExtTest { + + @Mock + lateinit var mockInternalLogger: InternalLogger + + // region MobileSegment.Source + + @Test + fun `M resolve the MobileSegment source W tryFromSource`( + forge: Forge + ) { + // Given + val fakeValidSource = forge.aValueFrom(MobileSegment.Source::class.java) + + // When + val source = MobileSegment.Source.tryFromSource(fakeValidSource.toJson().asString, mockInternalLogger) + + // Then + assertThat(source).isEqualTo(fakeValidSource) + } + + @Test + fun `M return default value W tryFromSource { unknown source }`( + forge: Forge + ) { + // Given + val fakeInvalidSource = forge.aString() + + // When + val source = MobileSegment.Source.tryFromSource(fakeInvalidSource, mockInternalLogger) + + // Then + assertThat(source).isEqualTo(MobileSegment.Source.ANDROID) + } + + @Test + fun `M send an error maintainer log W tryFromSource { unknown source }`( + forge: Forge + ) { + // Given + val fakeInvalidSource = forge.aString() + + // When + MobileSegment.Source.tryFromSource(fakeInvalidSource, mockInternalLogger) + + // Then + argumentCaptor<() -> String>() { + verify(mockInternalLogger).log( + level = eq(InternalLogger.Level.ERROR), + target = eq(InternalLogger.Target.MAINTAINER), + messageBuilder = capture(), + throwable = isA(), + onlyOnce = eq(false), + additionalProperties = isNull() + ) + + assertThat(firstValue()).isEqualTo( + UNKNOWN_MOBILE_SEGMENT_SOURCE_WARNING_MESSAGE_FORMAT.format( + Locale.US, + fakeInvalidSource + ) + ) + } + } + + // endregion +} diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/processor/NodeFlattenerTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/processor/NodeFlattenerTest.kt index e786c2ef1a..9c311cc15e 100644 --- a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/processor/NodeFlattenerTest.kt +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/processor/NodeFlattenerTest.kt @@ -141,7 +141,7 @@ internal class NodeFlattenerTest { private fun generateTreeFromList(list: List): Node { val mutableList = list.toMutableList() - val root = mutableList.removeFirst().toNode() + val root = mutableList.removeAt(0).toNode() val middle = mutableList.size / 2 // add left // we need to create a new list as Kotlin .subList re - uses the old list @@ -155,7 +155,7 @@ internal class NodeFlattenerTest { if (leafs.isEmpty()) { return } - val leafToAdd = leafs.removeFirst().toNode() + val leafToAdd = leafs.removeAt(0).toNode() parent.addChild(leafToAdd) val middle = leafs.size / 2 // add left diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/DebouncerTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/DebouncerTest.kt index 2db8ddbbc9..a78f612670 100644 --- a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/DebouncerTest.kt +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/DebouncerTest.kt @@ -7,8 +7,12 @@ package com.datadog.android.sessionreplay.internal.recorder import android.os.Handler +import com.datadog.android.api.feature.Feature.Companion.RUM_FEATURE_NAME +import com.datadog.android.api.feature.FeatureScope +import com.datadog.android.api.feature.FeatureSdkCore import com.datadog.android.sessionreplay.forge.ForgeConfigurator import fr.xgouchet.elmyr.Forge +import fr.xgouchet.elmyr.annotation.BoolForgery import fr.xgouchet.elmyr.junit5.ForgeConfiguration import fr.xgouchet.elmyr.junit5.ForgeExtension import org.assertj.core.api.Assertions.assertThat @@ -38,11 +42,76 @@ internal class DebouncerTest { @Mock lateinit var mockHandler: Handler + @Mock + lateinit var mockSdkCore: FeatureSdkCore + + @Mock + lateinit var mockTimeBank: TimeBank + + @Mock + lateinit var mockRumFeature: FeatureScope + + @BoolForgery + var fakeDynamicOptimizationEnabled: Boolean = false + lateinit var testedDebouncer: Debouncer @BeforeEach fun `set up`() { - testedDebouncer = Debouncer(mockHandler, TEST_MAX_DELAY_THRESHOLD_IN_NS) + whenever(mockTimeBank.updateAndCheck(any())).thenReturn(true) + whenever(mockSdkCore.getFeature(RUM_FEATURE_NAME)).thenReturn(mockRumFeature) + testedDebouncer = Debouncer( + mockHandler, + TEST_MAX_DELAY_THRESHOLD_IN_NS, + sdkCore = mockSdkCore, + timeBank = mockTimeBank, + dynamicOptimizationEnabled = fakeDynamicOptimizationEnabled + ) + } + + @Test + fun `M not optimize W dynamicOptimizationEnabled is false`() { + // Given + whenever(mockTimeBank.updateAndCheck(any())).thenReturn(false) + testedDebouncer = Debouncer( + mockHandler, + TEST_MAX_DELAY_THRESHOLD_IN_NS, + sdkCore = mockSdkCore, + dynamicOptimizationEnabled = false, + timeBank = mockTimeBank + ) + val fakeRunnable = TestRunnable() + val fakeSecondRunnable = TestRunnable() + + // When + testedDebouncer.debounce(fakeRunnable) + Thread.sleep(TimeUnit.NANOSECONDS.toMillis(TEST_MAX_DELAY_THRESHOLD_IN_NS)) + testedDebouncer.debounce(fakeSecondRunnable) + + assertThat(fakeSecondRunnable.wasExecuted).isTrue() + } + + @Test + fun `M send telemetry W frame is skipped by time bank`() { + // Given + testedDebouncer = Debouncer( + mockHandler, + TEST_MAX_DELAY_THRESHOLD_IN_NS, + sdkCore = mockSdkCore, + timeBank = mockTimeBank, + dynamicOptimizationEnabled = true + ) + whenever(mockTimeBank.updateAndCheck(any())).thenReturn(false) + val fakeRunnable = TestRunnable() + val fakeSecondRunnable = TestRunnable() + + // When + testedDebouncer.debounce(fakeRunnable) + Thread.sleep(TimeUnit.NANOSECONDS.toMillis(TEST_MAX_DELAY_THRESHOLD_IN_NS)) + testedDebouncer.debounce(fakeSecondRunnable) + + // Then + verify(mockRumFeature, times(1)).sendEvent(any()) } @Test diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/SnapshotProducerTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/SnapshotProducerTest.kt index d73a75af4b..47427de3e4 100644 --- a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/SnapshotProducerTest.kt +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/SnapshotProducerTest.kt @@ -8,13 +8,18 @@ package com.datadog.android.sessionreplay.internal.recorder import android.view.View import android.view.ViewGroup +import com.datadog.android.api.InternalLogger import com.datadog.android.sessionreplay.ImagePrivacy -import com.datadog.android.sessionreplay.SessionReplayPrivacy +import com.datadog.android.sessionreplay.R +import com.datadog.android.sessionreplay.TextAndInputPrivacy import com.datadog.android.sessionreplay.forge.ForgeConfigurator import com.datadog.android.sessionreplay.internal.async.RecordedDataQueueRefs +import com.datadog.android.sessionreplay.internal.recorder.SnapshotProducer.Companion.INVALID_PRIVACY_LEVEL_ERROR import com.datadog.android.sessionreplay.model.MobileSegment import com.datadog.android.sessionreplay.recorder.MappingContext import com.datadog.android.sessionreplay.recorder.SystemInformation +import com.datadog.android.sessionreplay.setSessionReplayImagePrivacy +import com.datadog.android.sessionreplay.setSessionReplayTextAndInputPrivacy import com.datadog.android.sessionreplay.utils.ImageWireframeHelper import fr.xgouchet.elmyr.Forge import fr.xgouchet.elmyr.annotation.Forgery @@ -60,6 +65,9 @@ internal class SnapshotProducerTest { @Mock lateinit var mockImageWireframeHelper: ImageWireframeHelper + @Mock + lateinit var mockInternalLogger: InternalLogger + @Forgery lateinit var fakeSystemInformation: SystemInformation @@ -67,7 +75,7 @@ internal class SnapshotProducerTest { lateinit var fakeViewWireframes: List @Forgery - lateinit var fakePrivacy: SessionReplayPrivacy + lateinit var fakeTextAndInputPrivacy: TextAndInputPrivacy @Forgery lateinit var fakeImagePrivacy: ImagePrivacy @@ -77,7 +85,8 @@ internal class SnapshotProducerTest { testedSnapshotProducer = SnapshotProducer( mockImageWireframeHelper, mockTreeViewTraversal, - mockOptionSelectorDetector + mockOptionSelectorDetector, + mockInternalLogger ) } @@ -96,7 +105,7 @@ internal class SnapshotProducerTest { val snapshot = testedSnapshotProducer.produce( mockRoot, fakeSystemInformation, - fakePrivacy, + fakeTextAndInputPrivacy, fakeImagePrivacy, mockRecordedDataQueueRefs ) @@ -123,7 +132,7 @@ internal class SnapshotProducerTest { val snapshot = testedSnapshotProducer.produce( fakeRoot, fakeSystemInformation, - fakePrivacy, + fakeTextAndInputPrivacy, fakeImagePrivacy, mockRecordedDataQueueRefs ) @@ -150,7 +159,7 @@ internal class SnapshotProducerTest { val snapshot = testedSnapshotProducer.produce( fakeRoot, fakeSystemInformation, - fakePrivacy, + fakeTextAndInputPrivacy, fakeImagePrivacy, mockRecordedDataQueueRefs ) @@ -177,7 +186,7 @@ internal class SnapshotProducerTest { val snapshot = testedSnapshotProducer.produce( fakeRoot, fakeSystemInformation, - fakePrivacy, + fakeTextAndInputPrivacy, fakeImagePrivacy, mockRecordedDataQueueRefs ) @@ -208,7 +217,7 @@ internal class SnapshotProducerTest { testedSnapshotProducer.produce( mockRoot, fakeSystemInformation, - fakePrivacy, + fakeTextAndInputPrivacy, fakeImagePrivacy, mockRecordedDataQueueRefs ) @@ -220,7 +229,7 @@ internal class SnapshotProducerTest { argumentCaptor.allValues.forEach { assertThat(it.systemInformation).isEqualTo(fakeSystemInformation) assertThat(it.imageWireframeHelper).isEqualTo(mockImageWireframeHelper) - assertThat(it.privacy).isEqualTo(fakePrivacy) + assertThat(it.textAndInputPrivacy).isEqualTo(fakeTextAndInputPrivacy) } } @@ -245,7 +254,7 @@ internal class SnapshotProducerTest { testedSnapshotProducer.produce( mockRoot, fakeSystemInformation, - fakePrivacy, + fakeTextAndInputPrivacy, fakeImagePrivacy, mockRecordedDataQueueRefs ) @@ -280,7 +289,7 @@ internal class SnapshotProducerTest { testedSnapshotProducer.produce( mockRoot, fakeSystemInformation, - fakePrivacy, + fakeTextAndInputPrivacy, fakeImagePrivacy, mockRecordedDataQueueRefs ) @@ -324,7 +333,7 @@ internal class SnapshotProducerTest { val snapshot = testedSnapshotProducer.produce( fakeRoot, fakeSystemInformation, - fakePrivacy, + fakeTextAndInputPrivacy, fakeImagePrivacy, mockRecordedDataQueueRefs ) @@ -357,7 +366,7 @@ internal class SnapshotProducerTest { val snapshot = testedSnapshotProducer.produce( fakeRoot, fakeSystemInformation, - fakePrivacy, + fakeTextAndInputPrivacy, fakeImagePrivacy, mockRecordedDataQueueRefs ) @@ -366,6 +375,103 @@ internal class SnapshotProducerTest { assertThat(snapshot).isEqualTo(expectedSnapshot) } + @Test + fun `M apply override privacy to parent and children W produce()`( + forge: Forge + ) { + // Given + val mockChildren: List = forge.aList { mock() } + val mockRoot: ViewGroup = mock { root -> + whenever(root.childCount).thenReturn(mockChildren.size) + whenever(root.getChildAt(any())).thenAnswer { mockChildren[it.getArgument(0)] } + } + val fakeImagePrivacy = forge.aValueFrom(ImagePrivacy::class.java) + val fakeTextAndInputPrivacy = forge.aValueFrom(TextAndInputPrivacy::class.java) + mockRoot.setSessionReplayImagePrivacy(fakeImagePrivacy) + mockRoot.setSessionReplayTextAndInputPrivacy(fakeTextAndInputPrivacy) + val fakeTraversedTreeView = TreeViewTraversal.TraversedTreeView( + fakeViewWireframes, + TraversalStrategy.TRAVERSE_ALL_CHILDREN + ) + whenever(mockTreeViewTraversal.traverse(any(), any(), any())) + .thenReturn(fakeTraversedTreeView) + .thenReturn( + fakeTraversedTreeView.copy( + nextActionStrategy = + TraversalStrategy.STOP_AND_DROP_NODE + ) + ) + + // When + testedSnapshotProducer.produce( + mockRoot, + fakeSystemInformation, + fakeTextAndInputPrivacy, + fakeImagePrivacy, + mockRecordedDataQueueRefs + ) + + // Then + val argumentCaptor = argumentCaptor() + verify(mockTreeViewTraversal, times(1 + mockChildren.size)) + .traverse(any(), argumentCaptor.capture(), any()) + argumentCaptor.allValues.forEach { + assertThat(it.imagePrivacy).isEqualTo(fakeImagePrivacy) + assertThat(it.textAndInputPrivacy).isEqualTo(fakeTextAndInputPrivacy) + } + } + + @Test + fun `M log invalid privacy level W produce() { invalid override tag value }`( + forge: Forge + ) { + // Given + val mockChildren: List = forge.aList { mock() } + val mockRoot: ViewGroup = mock { root -> + whenever(root.childCount).thenReturn(mockChildren.size) + whenever(root.getChildAt(any())).thenAnswer { mockChildren[it.getArgument(0)] } + whenever(root.getTag(R.id.datadog_image_privacy)).thenReturn("arglblargl") + whenever(root.getTag(R.id.datadog_text_and_input_privacy)).thenReturn("arglblargl") + } + val fakeImagePrivacy = forge.aValueFrom(ImagePrivacy::class.java) + val fakeTextAndInputPrivacy = forge.aValueFrom(TextAndInputPrivacy::class.java) + + val fakeTraversedTreeView = TreeViewTraversal.TraversedTreeView( + fakeViewWireframes, + TraversalStrategy.TRAVERSE_ALL_CHILDREN + ) + whenever(mockTreeViewTraversal.traverse(any(), any(), any())) + .thenReturn(fakeTraversedTreeView) + .thenReturn( + fakeTraversedTreeView.copy( + nextActionStrategy = + TraversalStrategy.STOP_AND_DROP_NODE + ) + ) + + // When + testedSnapshotProducer.produce( + mockRoot, + fakeSystemInformation, + fakeTextAndInputPrivacy, + fakeImagePrivacy, + mockRecordedDataQueueRefs + ) + + // Then + argumentCaptor<() -> String> { + verify(mockInternalLogger, times(2)).log( + eq(InternalLogger.Level.ERROR), + eq(listOf(InternalLogger.Target.USER, InternalLogger.Target.TELEMETRY)), + capture(), + any(), + eq(false), + eq(null) + ) + assertThat(lastValue.invoke()).isEqualTo(INVALID_PRIVACY_LEVEL_ERROR) + } + } + // region Internals private fun View.toNode( diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/TreeViewTraversalTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/TreeViewTraversalTest.kt index 0c8a14fcc0..a58a283d91 100644 --- a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/TreeViewTraversalTest.kt +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/TreeViewTraversalTest.kt @@ -21,9 +21,11 @@ import com.datadog.android.api.feature.measureMethodCallPerf import com.datadog.android.core.metrics.MethodCallSamplingRate import com.datadog.android.sessionreplay.MapperTypeWrapper import com.datadog.android.sessionreplay.forge.ForgeConfigurator +import com.datadog.android.sessionreplay.internal.TouchPrivacyManager import com.datadog.android.sessionreplay.internal.async.RecordedDataQueueRefs import com.datadog.android.sessionreplay.internal.recorder.TreeViewTraversal.Companion.METHOD_CALL_MAP_PREFIX import com.datadog.android.sessionreplay.internal.recorder.mapper.DecorViewMapper +import com.datadog.android.sessionreplay.internal.recorder.mapper.HiddenViewMapper import com.datadog.android.sessionreplay.internal.recorder.mapper.ViewWireframeMapper import com.datadog.android.sessionreplay.model.MobileSegment import com.datadog.android.sessionreplay.recorder.MappingContext @@ -68,6 +70,12 @@ internal class TreeViewTraversalTest { @Mock lateinit var mockDecorViewMapper: DecorViewMapper + @Mock + lateinit var mockHiddenViewMapper: HiddenViewMapper + + @Mock + lateinit var mockTouchPrivacyManager: TouchPrivacyManager + @Mock lateinit var mockViewUtilsInternal: ViewUtilsInternal @@ -82,11 +90,13 @@ internal class TreeViewTraversalTest { whenever(mockViewUtilsInternal.isNotVisible(any())).thenReturn(false) whenever(mockViewUtilsInternal.isSystemNoise(any())).thenReturn(false) testedTreeViewTraversal = TreeViewTraversal( - emptyList(), - mockDefaultViewMapper, - mockDecorViewMapper, - mockViewUtilsInternal, - mockInternalLogger + mappers = emptyList(), + defaultViewMapper = mockDefaultViewMapper, + hiddenViewMapper = mockHiddenViewMapper, + decorViewMapper = mockDecorViewMapper, + viewUtilsInternal = mockViewUtilsInternal, + internalLogger = mockInternalLogger, + touchPrivacyManager = mockTouchPrivacyManager ) } @@ -121,11 +131,13 @@ internal class TreeViewTraversalTest { ) .thenReturn(fakeViewMappedWireframes) testedTreeViewTraversal = TreeViewTraversal( - fakeTypeMapperWrappers, - mockDefaultViewMapper, - mockDecorViewMapper, - mockViewUtilsInternal, - mockInternalLogger + mappers = fakeTypeMapperWrappers, + defaultViewMapper = mockDefaultViewMapper, + hiddenViewMapper = mockHiddenViewMapper, + decorViewMapper = mockDecorViewMapper, + viewUtilsInternal = mockViewUtilsInternal, + internalLogger = mockInternalLogger, + touchPrivacyManager = mockTouchPrivacyManager ) // When @@ -154,11 +166,13 @@ internal class TreeViewTraversalTest { whenever(mockDefaultViewMapper.map(eq(mockView), eq(fakeMappingContext), any(), eq(mockInternalLogger))) .thenReturn(fakeViewMappedWireframes) testedTreeViewTraversal = TreeViewTraversal( - emptyList(), - mockDefaultViewMapper, - mockDecorViewMapper, - mockViewUtilsInternal, - mockInternalLogger + mappers = emptyList(), + defaultViewMapper = mockDefaultViewMapper, + hiddenViewMapper = mockHiddenViewMapper, + decorViewMapper = mockDecorViewMapper, + viewUtilsInternal = mockViewUtilsInternal, + internalLogger = mockInternalLogger, + touchPrivacyManager = mockTouchPrivacyManager ) // When @@ -188,11 +202,13 @@ internal class TreeViewTraversalTest { whenever(mockDefaultViewMapper.map(eq(mockView), eq(fakeMappingContext), any(), eq(mockInternalLogger))) .thenReturn(fakeViewMappedWireframes) testedTreeViewTraversal = TreeViewTraversal( - emptyList(), - mockDefaultViewMapper, - mockDecorViewMapper, - mockViewUtilsInternal, - mockInternalLogger + mappers = emptyList(), + defaultViewMapper = mockDefaultViewMapper, + hiddenViewMapper = mockHiddenViewMapper, + decorViewMapper = mockDecorViewMapper, + viewUtilsInternal = mockViewUtilsInternal, + internalLogger = mockInternalLogger, + touchPrivacyManager = mockTouchPrivacyManager ) // When @@ -237,11 +253,13 @@ internal class TreeViewTraversalTest { ) .thenReturn(fakeViewMappedWireframes) testedTreeViewTraversal = TreeViewTraversal( - fakeTypeMapperWrappers, - mockDefaultViewMapper, - mockDecorViewMapper, - mockViewUtilsInternal, - mockInternalLogger + mappers = fakeTypeMapperWrappers, + defaultViewMapper = mockDefaultViewMapper, + hiddenViewMapper = mockHiddenViewMapper, + decorViewMapper = mockDecorViewMapper, + viewUtilsInternal = mockViewUtilsInternal, + internalLogger = mockInternalLogger, + touchPrivacyManager = mockTouchPrivacyManager ) // When @@ -270,11 +288,13 @@ internal class TreeViewTraversalTest { whenever(mockDecorViewMapper.map(eq(mockView), eq(fakeMappingContext), any(), eq(mockInternalLogger))) .thenReturn(fakeViewMappedWireframes) testedTreeViewTraversal = TreeViewTraversal( - emptyList(), - mockDefaultViewMapper, - mockDecorViewMapper, - mockViewUtilsInternal, - mockInternalLogger + mappers = emptyList(), + defaultViewMapper = mockDefaultViewMapper, + hiddenViewMapper = mockHiddenViewMapper, + decorViewMapper = mockDecorViewMapper, + viewUtilsInternal = mockViewUtilsInternal, + internalLogger = mockInternalLogger, + touchPrivacyManager = mockTouchPrivacyManager ) // When @@ -304,11 +324,13 @@ internal class TreeViewTraversalTest { whenever(mockDecorViewMapper.map(eq(mockView), eq(fakeMappingContext), any(), eq(mockInternalLogger))) .thenReturn(fakeViewMappedWireframes) testedTreeViewTraversal = TreeViewTraversal( - emptyList(), - mockDefaultViewMapper, - mockDecorViewMapper, - mockViewUtilsInternal, - mockInternalLogger + mappers = emptyList(), + defaultViewMapper = mockDefaultViewMapper, + hiddenViewMapper = mockHiddenViewMapper, + decorViewMapper = mockDecorViewMapper, + viewUtilsInternal = mockViewUtilsInternal, + internalLogger = mockInternalLogger, + touchPrivacyManager = mockTouchPrivacyManager ) // When @@ -440,11 +462,13 @@ internal class TreeViewTraversalTest { whenever(mockMapper.getUnsafeMapper()).thenReturn(mockWireFrameMapper) testedTreeViewTraversal = TreeViewTraversal( - listOf(mockMapper), - mockDefaultViewMapper, - mockDecorViewMapper, - mockViewUtilsInternal, - mockInternalLogger + mappers = listOf(mockMapper), + defaultViewMapper = mockDefaultViewMapper, + hiddenViewMapper = mockHiddenViewMapper, + decorViewMapper = mockDecorViewMapper, + viewUtilsInternal = mockViewUtilsInternal, + internalLogger = mockInternalLogger, + touchPrivacyManager = mockTouchPrivacyManager ) // When diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/ViewOnDrawInterceptorTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/ViewOnDrawInterceptorTest.kt index 863a2ee994..22f0857bfd 100644 --- a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/ViewOnDrawInterceptorTest.kt +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/ViewOnDrawInterceptorTest.kt @@ -10,8 +10,9 @@ import android.view.View import android.view.ViewTreeObserver import com.datadog.android.api.InternalLogger import com.datadog.android.sessionreplay.ImagePrivacy -import com.datadog.android.sessionreplay.SessionReplayPrivacy +import com.datadog.android.sessionreplay.TextAndInputPrivacy import com.datadog.android.sessionreplay.forge.ForgeConfigurator +import com.datadog.android.sessionreplay.internal.TouchPrivacyManager import fr.xgouchet.elmyr.Forge import fr.xgouchet.elmyr.annotation.Forgery import fr.xgouchet.elmyr.junit5.ForgeConfiguration @@ -53,8 +54,11 @@ internal class ViewOnDrawInterceptorTest { @Mock lateinit var mockOnDrawListener: ViewTreeObserver.OnDrawListener + @Mock + lateinit var mockTouchPrivacyManager: TouchPrivacyManager + @Forgery - lateinit var fakePrivacy: SessionReplayPrivacy + lateinit var fakeTextAndInputPrivacy: TextAndInputPrivacy @Forgery lateinit var fakeImagePrivacy: ImagePrivacy @@ -67,22 +71,24 @@ internal class ViewOnDrawInterceptorTest { whenever( mockOnDrawListenerProducer.create( - fakeDecorViews, - fakePrivacy, - fakeImagePrivacy + decorViews = fakeDecorViews, + textAndInputPrivacy = fakeTextAndInputPrivacy, + imagePrivacy = fakeImagePrivacy, + touchPrivacyManager = mockTouchPrivacyManager ) ) doReturn mockOnDrawListener testedInterceptor = ViewOnDrawInterceptor( internalLogger = mockInternalLogger, - onDrawListenerProducer = mockOnDrawListenerProducer + onDrawListenerProducer = mockOnDrawListenerProducer, + touchPrivacyManager = mockTouchPrivacyManager ) } @Test fun `M register the OnDrawListener W intercept()`() { // When - testedInterceptor.intercept(fakeDecorViews, fakePrivacy, fakeImagePrivacy) + testedInterceptor.intercept(fakeDecorViews, fakeTextAndInputPrivacy, fakeImagePrivacy) // Then fakeDecorViews.forEach { @@ -98,16 +104,17 @@ internal class ViewOnDrawInterceptorTest { // Given testedInterceptor = ViewOnDrawInterceptor( internalLogger = mockInternalLogger, - onDrawListenerProducer = { _, privacy, _ -> - check(privacy == fakePrivacy) { - "Expected to create an OnDrawListener with privacy $fakePrivacy but was $privacy" + touchPrivacyManager = mockTouchPrivacyManager, + onDrawListenerProducer = { _, privacy, _, _ -> + check(privacy == fakeTextAndInputPrivacy) { + "Expected to create an OnDrawListener with privacy $fakeTextAndInputPrivacy but was $privacy" } mock() } ) // When - testedInterceptor.intercept(fakeDecorViews, fakePrivacy, fakeImagePrivacy) + testedInterceptor.intercept(fakeDecorViews, fakeTextAndInputPrivacy, fakeImagePrivacy) // Then fakeDecorViews.forEach { @@ -120,11 +127,12 @@ internal class ViewOnDrawInterceptorTest { // Given val mockOnDrawListener = mock() testedInterceptor = ViewOnDrawInterceptor( - internalLogger = mockInternalLogger - ) { _, _, _ -> mockOnDrawListener } + internalLogger = mockInternalLogger, + touchPrivacyManager = mockTouchPrivacyManager + ) { _, _, _, _ -> mockOnDrawListener } // When - testedInterceptor.intercept(fakeDecorViews, fakePrivacy, fakeImagePrivacy) + testedInterceptor.intercept(fakeDecorViews, fakeTextAndInputPrivacy, fakeImagePrivacy) // Then fakeDecorViews.forEach { @@ -136,7 +144,7 @@ internal class ViewOnDrawInterceptorTest { @Test fun `M register one single listener instance W intercept()`() { // When - testedInterceptor.intercept(fakeDecorViews, fakePrivacy, fakeImagePrivacy) + testedInterceptor.intercept(fakeDecorViews, fakeTextAndInputPrivacy, fakeImagePrivacy) // Then val captor = argumentCaptor() @@ -158,7 +166,7 @@ internal class ViewOnDrawInterceptorTest { } // When - testedInterceptor.intercept(fakeDecorViews, fakePrivacy, fakeImagePrivacy) + testedInterceptor.intercept(fakeDecorViews, fakeTextAndInputPrivacy, fakeImagePrivacy) // Then assertThat(testedInterceptor.decorOnDrawListeners).isEmpty() @@ -172,7 +180,7 @@ internal class ViewOnDrawInterceptorTest { } // When - testedInterceptor.intercept(fakeDecorViews, fakePrivacy, fakeImagePrivacy) + testedInterceptor.intercept(fakeDecorViews, fakeTextAndInputPrivacy, fakeImagePrivacy) // Then assertThat(testedInterceptor.decorOnDrawListeners).isEmpty() @@ -181,7 +189,7 @@ internal class ViewOnDrawInterceptorTest { @Test fun `M unregister and clean the listeners W stopIntercepting(decorViews)`() { // Given - testedInterceptor.intercept(fakeDecorViews, fakePrivacy, fakeImagePrivacy) + testedInterceptor.intercept(fakeDecorViews, fakeTextAndInputPrivacy, fakeImagePrivacy) // When testedInterceptor.stopIntercepting(fakeDecorViews) @@ -198,7 +206,7 @@ internal class ViewOnDrawInterceptorTest { @Test fun `M unregister the listeners safely W stopIntercepting(decorViews)`() { // Given - testedInterceptor.intercept(fakeDecorViews, fakePrivacy, fakeImagePrivacy) + testedInterceptor.intercept(fakeDecorViews, fakeTextAndInputPrivacy, fakeImagePrivacy) fakeDecorViews.forEach { whenever(it.viewTreeObserver.removeOnDrawListener(any())) doThrow IllegalStateException() } @@ -218,7 +226,7 @@ internal class ViewOnDrawInterceptorTest { @Test fun `M unregister and clean the listeners W stopIntercepting()`() { // Given - testedInterceptor.intercept(fakeDecorViews, fakePrivacy, fakeImagePrivacy) + testedInterceptor.intercept(fakeDecorViews, fakeTextAndInputPrivacy, fakeImagePrivacy) // When testedInterceptor.stopIntercepting() @@ -236,10 +244,10 @@ internal class ViewOnDrawInterceptorTest { @Test fun `M unregister first and clean the listeners W intercepting()`() { // Given - testedInterceptor.intercept(fakeDecorViews, fakePrivacy, fakeImagePrivacy) + testedInterceptor.intercept(fakeDecorViews, fakeTextAndInputPrivacy, fakeImagePrivacy) // When - testedInterceptor.intercept(fakeDecorViews, fakePrivacy, fakeImagePrivacy) + testedInterceptor.intercept(fakeDecorViews, fakeTextAndInputPrivacy, fakeImagePrivacy) // Then fakeDecorViews.forEach { @@ -255,7 +263,7 @@ internal class ViewOnDrawInterceptorTest { @Test fun `M unregister the listeners safely W stopIntercepting()`() { // Given - testedInterceptor.intercept(fakeDecorViews, fakePrivacy, fakeImagePrivacy) + testedInterceptor.intercept(fakeDecorViews, fakeTextAndInputPrivacy, fakeImagePrivacy) fakeDecorViews.forEach { whenever(it.viewTreeObserver.removeOnDrawListener(any())) doThrow IllegalStateException() } diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/WindowCallbackInterceptorTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/WindowCallbackInterceptorTest.kt index a859b67591..07f758e020 100644 --- a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/WindowCallbackInterceptorTest.kt +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/WindowCallbackInterceptorTest.kt @@ -13,8 +13,9 @@ import android.view.View import android.view.Window import com.datadog.android.api.InternalLogger import com.datadog.android.sessionreplay.ImagePrivacy -import com.datadog.android.sessionreplay.SessionReplayPrivacy +import com.datadog.android.sessionreplay.TextAndInputPrivacy import com.datadog.android.sessionreplay.forge.ForgeConfigurator +import com.datadog.android.sessionreplay.internal.TouchPrivacyManager import com.datadog.android.sessionreplay.internal.async.RecordedDataQueueHandler import com.datadog.android.sessionreplay.internal.recorder.callback.NoOpWindowCallback import com.datadog.android.sessionreplay.internal.recorder.callback.RecorderWindowCallback @@ -64,11 +65,14 @@ internal class WindowCallbackInterceptorTest { lateinit var mockInternalLogger: InternalLogger @Forgery - lateinit var fakePrivacy: SessionReplayPrivacy + lateinit var fakeTextAndInputPrivacy: TextAndInputPrivacy @Forgery lateinit var fakeImagePrivacy: ImagePrivacy + @Mock + lateinit var mockTouchPrivacyManager: TouchPrivacyManager + private lateinit var fakeWindowsList: List private lateinit var mockActivity: Activity @@ -78,12 +82,13 @@ internal class WindowCallbackInterceptorTest { mockActivity = forge.aMockedActivity() fakeWindowsList = forge.aMockedWindowsList() testedInterceptor = WindowCallbackInterceptor( - mockRecordedDataQueueHandler, - mockViewOnDrawInterceptor, - mockTimeProvider, - mockInternalLogger, - fakePrivacy, - fakeImagePrivacy + recordedDataQueueHandler = mockRecordedDataQueueHandler, + viewOnDrawInterceptor = mockViewOnDrawInterceptor, + timeProvider = mockTimeProvider, + internalLogger = mockInternalLogger, + imagePrivacy = fakeImagePrivacy, + textAndInputPrivacy = fakeTextAndInputPrivacy, + touchPrivacyManager = mockTouchPrivacyManager ) } diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/callback/RecorderWindowCallbackTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/callback/RecorderWindowCallbackTest.kt index 90c781d08d..b08e24870d 100644 --- a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/callback/RecorderWindowCallbackTest.kt +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/callback/RecorderWindowCallbackTest.kt @@ -14,8 +14,9 @@ import android.view.View import android.view.Window import com.datadog.android.api.InternalLogger import com.datadog.android.sessionreplay.ImagePrivacy -import com.datadog.android.sessionreplay.SessionReplayPrivacy +import com.datadog.android.sessionreplay.TextAndInputPrivacy import com.datadog.android.sessionreplay.forge.ForgeConfigurator +import com.datadog.android.sessionreplay.internal.TouchPrivacyManager import com.datadog.android.sessionreplay.internal.async.RecordedDataQueueHandler import com.datadog.android.sessionreplay.internal.async.TouchEventRecordedDataQueueItem import com.datadog.android.sessionreplay.internal.recorder.ViewOnDrawInterceptor @@ -72,6 +73,9 @@ internal class RecorderWindowCallbackTest { @Mock lateinit var mockViewOnDrawInterceptor: ViewOnDrawInterceptor + @Mock + lateinit var mockTouchPrivacyManager: TouchPrivacyManager + @Mock lateinit var mockTimeProvider: TimeProvider @@ -101,10 +105,7 @@ internal class RecorderWindowCallbackTest { lateinit var mockEventUtils: MotionEventUtils @Forgery - lateinit var fakePrivacy: SessionReplayPrivacy - - @Forgery - lateinit var fakeImagePrivacy: ImagePrivacy + lateinit var fakeTextAndInputPrivacy: TextAndInputPrivacy @BeforeEach fun `set up`() { @@ -116,6 +117,8 @@ internal class RecorderWindowCallbackTest { .thenReturn(fakeTouchEventRecordedDataQueueItem) whenever(mockContext.resources).thenReturn(mockResources) whenever(mockTimeProvider.getDeviceTimestamp()).thenReturn(fakeTimestamp) + whenever(mockTouchPrivacyManager.shouldRecordTouch(any())) + .thenReturn(true) testedWindowCallback = RecorderWindowCallback( appContext = mockContext, recordedDataQueueHandler = mockRecordedDataQueueHandler, @@ -123,8 +126,9 @@ internal class RecorderWindowCallbackTest { timeProvider = mockTimeProvider, viewOnDrawInterceptor = mockViewOnDrawInterceptor, internalLogger = mockInternalLogger, - privacy = fakePrivacy, - imagePrivacy = fakeImagePrivacy, + imagePrivacy = ImagePrivacy.MASK_NONE, + touchPrivacyManager = mockTouchPrivacyManager, + privacy = fakeTextAndInputPrivacy, copyEvent = { it }, motionEventUtils = mockEventUtils, motionUpdateThresholdInNs = TEST_MOTION_UPDATE_DELAY_THRESHOLD_NS, @@ -231,6 +235,10 @@ internal class RecorderWindowCallbackTest { @Test fun `M update the positions and flush them W onTouchEvent() { ActionUp }`(forge: Forge) { // Given + val fakeDownEvent = forge.touchRecords(MobileSegment.PointerEventType.DOWN) + val downMotionEvent = fakeDownEvent.asMotionEvent() + testedWindowCallback.dispatchTouchEvent(downMotionEvent) + val fakeRecords = forge.touchRecords(MobileSegment.PointerEventType.UP) val relatedMotionEvent = fakeRecords.asMotionEvent() @@ -239,7 +247,7 @@ internal class RecorderWindowCallbackTest { // Then assertThat(testedWindowCallback.pointerInteractions).isEmpty() - verify(mockRecordedDataQueueHandler).addTouchEventItem(fakeRecords) + verify(mockRecordedDataQueueHandler).addTouchEventItem(fakeDownEvent + fakeRecords) verify(mockRecordedDataQueueHandler).tryToConsumeItems() } @@ -427,7 +435,7 @@ internal class RecorderWindowCallbackTest { // Then inOrder(mockViewOnDrawInterceptor) { verify(mockViewOnDrawInterceptor).stopIntercepting() - verify(mockViewOnDrawInterceptor).intercept(fakeDecorViews, fakePrivacy, fakeImagePrivacy) + verify(mockViewOnDrawInterceptor).intercept(fakeDecorViews, fakeTextAndInputPrivacy, ImagePrivacy.MASK_NONE) } } @@ -446,6 +454,92 @@ internal class RecorderWindowCallbackTest { // endregion + // region touchPrivacy + + @Test + fun `M capture touch events W onTouchEvent { TouchPrivacy SHOW }`( + forge: Forge + ) { + // Given + val fakeEvent1Records = forge.touchRecords(MobileSegment.PointerEventType.DOWN) + val relatedMotionEvent1 = fakeEvent1Records.asMotionEvent() + val fakeEvent2Records = forge.touchRecords(MobileSegment.PointerEventType.MOVE) + val relatedMotionEvent2 = fakeEvent2Records.asMotionEvent() + val fakeEvent3Records = forge.touchRecords(MobileSegment.PointerEventType.UP) + val relatedMotionEvent3 = fakeEvent3Records.asMotionEvent() + + testedWindowCallback = RecorderWindowCallback( + appContext = mockContext, + recordedDataQueueHandler = mockRecordedDataQueueHandler, + wrappedCallback = mockWrappedCallback, + timeProvider = mockTimeProvider, + viewOnDrawInterceptor = mockViewOnDrawInterceptor, + internalLogger = mockInternalLogger, + privacy = fakeTextAndInputPrivacy, + imagePrivacy = ImagePrivacy.MASK_NONE, + touchPrivacyManager = mockTouchPrivacyManager, + copyEvent = { it }, + motionEventUtils = mockEventUtils, + motionUpdateThresholdInNs = TEST_MOTION_UPDATE_DELAY_THRESHOLD_NS, + flushPositionBufferThresholdInNs = TEST_FLUSH_BUFFER_THRESHOLD_NS, + windowInspector = mockWindowInspector + ) + + // When + testedWindowCallback.dispatchTouchEvent(relatedMotionEvent1) + testedWindowCallback.dispatchTouchEvent(relatedMotionEvent2) + testedWindowCallback.dispatchTouchEvent(relatedMotionEvent3) + + // Then + val expectedRecords = fakeEvent1Records + fakeEvent2Records + fakeEvent3Records + verify(mockRecordedDataQueueHandler).addTouchEventItem(expectedRecords) + verify(mockRecordedDataQueueHandler).tryToConsumeItems() + assertThat(testedWindowCallback.pointerInteractions).isEmpty() + } + + @Test + fun `M not capture touch events W onTouchEvent { TouchPrivacy HIDE }`( + forge: Forge + ) { + // Given + val fakeEvent1Records = forge.touchRecords(MobileSegment.PointerEventType.DOWN) + val relatedMotionEvent1 = fakeEvent1Records.asMotionEvent() + val fakeEvent2Records = forge.touchRecords(MobileSegment.PointerEventType.MOVE) + val relatedMotionEvent2 = fakeEvent2Records.asMotionEvent() + val fakeEvent3Records = forge.touchRecords(MobileSegment.PointerEventType.UP) + val relatedMotionEvent3 = fakeEvent3Records.asMotionEvent() + whenever(mockTouchPrivacyManager.shouldRecordTouch(any())) + .thenReturn(false) + + testedWindowCallback = RecorderWindowCallback( + appContext = mockContext, + recordedDataQueueHandler = mockRecordedDataQueueHandler, + wrappedCallback = mockWrappedCallback, + timeProvider = mockTimeProvider, + viewOnDrawInterceptor = mockViewOnDrawInterceptor, + internalLogger = mockInternalLogger, + privacy = fakeTextAndInputPrivacy, + imagePrivacy = ImagePrivacy.MASK_NONE, + touchPrivacyManager = mockTouchPrivacyManager, + copyEvent = { it }, + motionEventUtils = mockEventUtils, + motionUpdateThresholdInNs = TEST_MOTION_UPDATE_DELAY_THRESHOLD_NS, + flushPositionBufferThresholdInNs = TEST_FLUSH_BUFFER_THRESHOLD_NS, + windowInspector = mockWindowInspector + ) + + // When + testedWindowCallback.dispatchTouchEvent(relatedMotionEvent1) + testedWindowCallback.dispatchTouchEvent(relatedMotionEvent2) + testedWindowCallback.dispatchTouchEvent(relatedMotionEvent3) + + // Then + verifyNoInteractions(mockRecordedDataQueueHandler) + assertThat(testedWindowCallback.pointerInteractions).isEmpty() + } + + // endregion + // region Internal private fun Forge.touchRecords( diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/listener/WindowsOnDrawListenerTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/listener/WindowsOnDrawListenerTest.kt index f26d01242a..cb9b30eefe 100644 --- a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/listener/WindowsOnDrawListenerTest.kt +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/listener/WindowsOnDrawListenerTest.kt @@ -12,11 +12,13 @@ import android.content.res.Resources import android.content.res.Resources.Theme import android.view.View import com.datadog.android.api.InternalLogger +import com.datadog.android.api.feature.FeatureSdkCore import com.datadog.android.core.metrics.PerformanceMetric import com.datadog.android.core.metrics.TelemetryMetricType import com.datadog.android.sessionreplay.ImagePrivacy -import com.datadog.android.sessionreplay.SessionReplayPrivacy +import com.datadog.android.sessionreplay.TextAndInputPrivacy import com.datadog.android.sessionreplay.forge.ForgeConfigurator +import com.datadog.android.sessionreplay.internal.TouchPrivacyManager import com.datadog.android.sessionreplay.internal.async.RecordedDataQueueHandler import com.datadog.android.sessionreplay.internal.async.RecordedDataQueueRefs import com.datadog.android.sessionreplay.internal.async.SnapshotRecordedDataQueueItem @@ -26,6 +28,7 @@ import com.datadog.android.sessionreplay.internal.recorder.SnapshotProducer import com.datadog.android.sessionreplay.internal.utils.MiscUtils import com.datadog.android.sessionreplay.recorder.SystemInformation import fr.xgouchet.elmyr.Forge +import fr.xgouchet.elmyr.annotation.BoolForgery import fr.xgouchet.elmyr.annotation.FloatForgery import fr.xgouchet.elmyr.annotation.Forgery import fr.xgouchet.elmyr.annotation.IntForgery @@ -76,9 +79,15 @@ internal class WindowsOnDrawListenerTest { @Mock lateinit var mockDebouncer: Debouncer + @Mock + lateinit var mockTouchPrivacyManager: TouchPrivacyManager + @Mock lateinit var mockInternalLogger: InternalLogger + @Mock + lateinit var mockSdkCore: FeatureSdkCore + @Mock lateinit var mockPerformanceMetric: PerformanceMetric @@ -108,16 +117,20 @@ internal class WindowsOnDrawListenerTest { lateinit var mockContext: Context @Forgery - lateinit var fakePrivacy: SessionReplayPrivacy + lateinit var fakeTextAndInputPrivacy: TextAndInputPrivacy @Forgery lateinit var fakeImagePrivacy: ImagePrivacy + @BoolForgery + var fakeDynamicOptimizationEnabled: Boolean = false + @FloatForgery var fakeMethodCallSamplingRate: Float = 0f @BeforeEach fun `set up`(forge: Forge) { + whenever(mockSdkCore.internalLogger).thenReturn(mockInternalLogger) whenever(mockMiscUtils.resolveSystemInformation(mockContext)) .thenReturn(fakeSystemInformation) fakeMockedDecorViews = forge.aMockedDecorViewList().onEach { @@ -130,7 +143,7 @@ internal class WindowsOnDrawListenerTest { mockSnapshotProducer.produce( eq(decorView), eq(fakeSystemInformation), - eq(fakePrivacy), + eq(fakeTextAndInputPrivacy), eq(fakeImagePrivacy), any() ) @@ -145,6 +158,7 @@ internal class WindowsOnDrawListenerTest { .ORIENTATION_LANDSCAPE, Configuration.ORIENTATION_PORTRAIT ) + fakeDynamicOptimizationEnabled = forge.aBool() configuration.orientation = fakeOrientation mockResources = mock { whenever(it.configuration).thenReturn(configuration) @@ -157,12 +171,14 @@ internal class WindowsOnDrawListenerTest { zOrderedDecorViews = fakeMockedDecorViews, recordedDataQueueHandler = mockRecordedDataQueueHandler, snapshotProducer = mockSnapshotProducer, - privacy = fakePrivacy, + textAndInputPrivacy = fakeTextAndInputPrivacy, imagePrivacy = fakeImagePrivacy, debouncer = mockDebouncer, miscUtils = mockMiscUtils, - internalLogger = mockInternalLogger, - methodCallSamplingRate = fakeMethodCallSamplingRate + sdkCore = mockSdkCore, + methodCallSamplingRate = fakeMethodCallSamplingRate, + dynamicOptimizationEnabled = fakeDynamicOptimizationEnabled, + touchPrivacyManager = mockTouchPrivacyManager ) } @@ -194,7 +210,7 @@ internal class WindowsOnDrawListenerTest { verify(mockSnapshotProducer, times(fakeWindowsSnapshots.size)).produce( rootView = any(), systemInformation = any(), - privacy = eq(fakePrivacy), + textAndInputPrivacy = eq(fakeTextAndInputPrivacy), imagePrivacy = eq(fakeImagePrivacy), recordedDataQueueRefs = argCaptor.capture() ) @@ -209,12 +225,14 @@ internal class WindowsOnDrawListenerTest { zOrderedDecorViews = emptyList(), recordedDataQueueHandler = mockRecordedDataQueueHandler, snapshotProducer = mockSnapshotProducer, - privacy = fakePrivacy, + textAndInputPrivacy = fakeTextAndInputPrivacy, imagePrivacy = fakeImagePrivacy, debouncer = mockDebouncer, miscUtils = mockMiscUtils, - internalLogger = mockInternalLogger, - methodCallSamplingRate = fakeMethodCallSamplingRate + sdkCore = mockSdkCore, + methodCallSamplingRate = fakeMethodCallSamplingRate, + dynamicOptimizationEnabled = fakeDynamicOptimizationEnabled, + touchPrivacyManager = mockTouchPrivacyManager ) testedListener.onDraw() diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/BaseCheckableTextViewMapperTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/BaseCheckableTextViewMapperTest.kt index b9967fe236..9212ee52da 100644 --- a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/BaseCheckableTextViewMapperTest.kt +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/BaseCheckableTextViewMapperTest.kt @@ -13,10 +13,11 @@ import android.os.Build import android.widget.Checkable import android.widget.TextView import com.datadog.android.sessionreplay.ImagePrivacy -import com.datadog.android.sessionreplay.SessionReplayPrivacy +import com.datadog.android.sessionreplay.TextAndInputPrivacy import com.datadog.android.sessionreplay.forge.ForgeConfigurator import com.datadog.android.sessionreplay.internal.recorder.mapper.CheckableTextViewMapper.Companion.CHECK_BOX_CHECKED_DRAWABLE_INDEX import com.datadog.android.sessionreplay.internal.recorder.mapper.CheckableTextViewMapper.Companion.CHECK_BOX_NOT_CHECKED_DRAWABLE_INDEX +import com.datadog.android.sessionreplay.internal.recorder.resources.DrawableCopier import com.datadog.android.sessionreplay.model.MobileSegment import com.datadog.android.sessionreplay.recorder.mapper.TextViewMapper import com.datadog.android.sessionreplay.utils.GlobalBounds @@ -110,6 +111,9 @@ internal abstract class BaseCheckableTextViewMapperTest : @Mock lateinit var mockClonedDrawable: Drawable + @Mock + lateinit var mockDrawableCopier: DrawableCopier + @IntForgery var mockCloneDrawableIntrinsicHeight: Int = 0 @@ -179,7 +183,7 @@ internal abstract class BaseCheckableTextViewMapperTest : internal abstract fun mockCheckableTextView(): T internal open fun expectedCheckedShapeStyle(checkBoxColor: String): MobileSegment.ShapeStyle? { - return if (fakeMappingContext.privacy == SessionReplayPrivacy.ALLOW) { + return if (fakeMappingContext.textAndInputPrivacy == TextAndInputPrivacy.MASK_SENSITIVE_INPUTS) { MobileSegment.ShapeStyle( backgroundColor = checkBoxColor, opacity = mockCheckableTextView.alpha @@ -196,7 +200,10 @@ internal abstract class BaseCheckableTextViewMapperTest : fun `M create ImageWireFrame W map() { checked, above M }`() { // Given val allowedMappingContext = - fakeMappingContext.copy(privacy = SessionReplayPrivacy.ALLOW, imagePrivacy = ImagePrivacy.MASK_LARGE_ONLY) + fakeMappingContext.copy( + textAndInputPrivacy = TextAndInputPrivacy.MASK_SENSITIVE_INPUTS, + imagePrivacy = ImagePrivacy.MASK_LARGE_ONLY + ) whenever(mockButtonDrawable.intrinsicHeight).thenReturn(fakeIntrinsicDrawableHeight) whenever(mockCheckableTextView.isChecked).thenReturn(true) @@ -215,14 +222,15 @@ internal abstract class BaseCheckableTextViewMapperTest : // Then verify(fakeMappingContext.imageWireframeHelper).createImageWireframeByDrawable( view = eq(mockCheckableTextView), - imagePrivacy = eq(ImagePrivacy.MASK_LARGE_ONLY), + imagePrivacy = eq(ImagePrivacy.MASK_NONE), currentWireframeIndex = anyInt(), x = eq(expectedX), y = eq(expectedY), width = eq(mockCloneDrawableIntrinsicWidth), height = eq(mockCloneDrawableIntrinsicHeight), usePIIPlaceholder = anyBoolean(), - drawable = eq(mockClonedDrawable), + drawable = any(), + drawableCopier = any(), asyncJobStatusCallback = eq(mockAsyncJobStatusCallback), clipping = eq(MobileSegment.WireframeClip()), shapeStyle = isNull(), @@ -236,7 +244,7 @@ internal abstract class BaseCheckableTextViewMapperTest : fun `M create ImageWireFrame W map() { not checked, above M }`() { // Given val allowedMappingContext = fakeMappingContext.copy( - privacy = SessionReplayPrivacy.ALLOW, + textAndInputPrivacy = TextAndInputPrivacy.MASK_SENSITIVE_INPUTS, imagePrivacy = ImagePrivacy.MASK_LARGE_ONLY ) whenever(mockButtonDrawable.intrinsicHeight).thenReturn(fakeIntrinsicDrawableHeight) @@ -258,7 +266,7 @@ internal abstract class BaseCheckableTextViewMapperTest : // Then verify(fakeMappingContext.imageWireframeHelper).createImageWireframeByDrawable( view = eq(mockCheckableTextView), - imagePrivacy = eq(ImagePrivacy.MASK_LARGE_ONLY), + imagePrivacy = eq(ImagePrivacy.MASK_NONE), currentWireframeIndex = anyInt(), x = eq(expectedX), y = eq(expectedY), @@ -266,6 +274,7 @@ internal abstract class BaseCheckableTextViewMapperTest : height = eq(mockCloneDrawableIntrinsicHeight), usePIIPlaceholder = anyBoolean(), drawable = eq(mockClonedDrawable), + drawableCopier = any(), asyncJobStatusCallback = eq(mockAsyncJobStatusCallback), clipping = eq(MobileSegment.WireframeClip()), shapeStyle = isNull(), diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/BaseSwitchCompatMapperTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/BaseSwitchCompatMapperTest.kt index d1ceca3e1f..c48948df15 100644 --- a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/BaseSwitchCompatMapperTest.kt +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/BaseSwitchCompatMapperTest.kt @@ -11,7 +11,7 @@ import android.graphics.Rect import android.graphics.drawable.Drawable import android.graphics.drawable.Drawable.ConstantState import androidx.appcompat.widget.SwitchCompat -import com.datadog.android.sessionreplay.SessionReplayPrivacy +import com.datadog.android.sessionreplay.TextAndInputPrivacy import com.datadog.android.sessionreplay.forge.ForgeConfigurator import com.datadog.android.sessionreplay.internal.recorder.densityNormalized import com.datadog.android.sessionreplay.model.MobileSegment @@ -197,7 +197,8 @@ internal abstract class BaseSwitchCompatMapperTest : LegacyBaseWireframeMapperTe // Given whenever(mockSwitch.thumbDrawable).thenReturn(null) whenever(mockSwitch.isChecked).thenReturn(forge.aBool()) - val allowMappingContext = fakeMappingContext.copy(privacy = SessionReplayPrivacy.ALLOW) + val allowMappingContext = + fakeMappingContext.copy(textAndInputPrivacy = TextAndInputPrivacy.MASK_SENSITIVE_INPUTS) // When val resolvedWireframes = testedSwitchCompatMapper.map( @@ -216,7 +217,8 @@ internal abstract class BaseSwitchCompatMapperTest : LegacyBaseWireframeMapperTe // Given whenever(mockSwitch.trackDrawable).thenReturn(null) whenever(mockSwitch.isChecked).thenReturn(forge.aBool()) - val allowMappingContext = fakeMappingContext.copy(privacy = SessionReplayPrivacy.ALLOW) + val allowMappingContext = + fakeMappingContext.copy(textAndInputPrivacy = TextAndInputPrivacy.MASK_SENSITIVE_INPUTS) // When val resolvedWireframes = testedSwitchCompatMapper.map( diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/ButtonMapperTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/ButtonMapperTest.kt index 31c7e0c8be..d5cb5717ed 100644 --- a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/ButtonMapperTest.kt +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/ButtonMapperTest.kt @@ -8,10 +8,11 @@ package com.datadog.android.sessionreplay.internal.recorder.mapper import android.graphics.Typeface import android.text.InputType +import android.text.Layout import android.view.Gravity import android.widget.Button import android.widget.TextView -import com.datadog.android.sessionreplay.SessionReplayPrivacy +import com.datadog.android.sessionreplay.TextAndInputPrivacy import com.datadog.android.sessionreplay.forge.ForgeConfigurator import com.datadog.android.sessionreplay.model.MobileSegment import com.datadog.android.sessionreplay.recorder.mapper.BaseAsyncBackgroundWireframeMapperTest @@ -32,6 +33,7 @@ import org.junit.jupiter.api.extension.Extensions import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.Arguments import org.junit.jupiter.params.provider.MethodSource +import org.mockito.Mock import org.mockito.junit.jupiter.MockitoExtension import org.mockito.junit.jupiter.MockitoSettings import org.mockito.kotlin.doReturn @@ -47,6 +49,9 @@ import java.util.stream.Stream @ForgeConfiguration(ForgeConfigurator::class) internal abstract class ButtonMapperTest : BaseAsyncBackgroundWireframeMapperTest() { + @Mock + lateinit var mockLayout: Layout + @StringForgery lateinit var fakeText: String @@ -68,7 +73,9 @@ internal abstract class ButtonMapperTest : BaseAsyncBackgroundWireframeMapperTes ) ) doReturn fakeTextColorHexString - withPrivacy(privacyOption()) + whenever(mockLayout.text) doReturn fakeText + + withTextAndInputPrivacy(privacyOption()) testedWireframeMapper = ButtonMapper( mockViewIdentifierResolver, @@ -80,7 +87,7 @@ internal abstract class ButtonMapperTest : BaseAsyncBackgroundWireframeMapperTes abstract fun expectedPrivacyCompliantText(text: String): String - abstract fun privacyOption(): SessionReplayPrivacy + abstract fun privacyOption(): TextAndInputPrivacy @ParameterizedTest(name = "{index} (typeface: {0}, align:{2}, gravity:{3})") @MethodSource("basicParametersMatrix") @@ -94,6 +101,7 @@ internal abstract class ButtonMapperTest : BaseAsyncBackgroundWireframeMapperTes ) { // Given prepareMockView