diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..9b54f9122 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,10 @@ +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "daily" + - package-ecosystem: "gradle" + directory: "/" + schedule: + interval: "daily" diff --git a/.github/workflows/gradle-wrapper-validation.yml b/.github/workflows/gradle-wrapper-validation.yml new file mode 100644 index 000000000..f7d62972d --- /dev/null +++ b/.github/workflows/gradle-wrapper-validation.yml @@ -0,0 +1,17 @@ +name: Gradle wrapper validation + +on: + pull_request: + paths: + - '**/gradle/wrapper/**' + push: + paths: + - '**/gradle/wrapper/**' + +jobs: + validation: + runs-on: ubuntu-20.04 + steps: + - uses: actions/checkout@v3.5.3 + + - uses: gradle/wrapper-validation-action@v1.1.0 \ No newline at end of file diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml new file mode 100644 index 000000000..69c9872e1 --- /dev/null +++ b/.github/workflows/main.yaml @@ -0,0 +1,26 @@ +name: "Continuous Build" + +on: + workflow_dispatch: + push: + branches: + - main + +concurrency: + group: ci + cancel-in-progress: true + +jobs: + build: + runs-on: ubuntu-20.04 + steps: + - uses: actions/checkout@v3.5.3 + + - name: Set up JDK 11 for running Gradle + uses: actions/setup-java@v3.12.0 + with: + distribution: temurin + java-version: 17 + + - name: Build and test + run: touch ./local.properties; ./gradlew build javadoc --no-daemon diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml new file mode 100644 index 000000000..65a130fc0 --- /dev/null +++ b/.github/workflows/pr.yaml @@ -0,0 +1,31 @@ +name: PR build + +on: + pull_request: + +concurrency: + group: pr-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + build: + runs-on: ubuntu-20.04 + steps: + - uses: actions/checkout@v3.5.3 + - name: Set up JDK 11 for running Gradle + uses: actions/setup-java@v3.12.0 + with: + distribution: temurin + java-version: 17 + + - name: Build and test + run: touch ./local.properties; ./gradlew build javadoc --no-daemon + check_links: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3.5.3 + - name: Link Checker + uses: lycheeverse/lychee-action@v1.8.0 + with: + fail: true + lycheeVersion: 0.10.3 diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 000000000..51bbfaef3 --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,72 @@ +include: + - project: 'prodsec/scp-scanning/gitlab-checkmarx' + ref: latest + file: '/templates/.sast_scan.yml' + - project: 'ci-cd/templates' + ref: master + file: '/prodsec/.oss-scan.yml' + +image: + name: "docker-hub.repo.splunkdev.net/eclipse-temurin:17.0.6_10-jdk" + +variables: + ANDROID_COMPILE_SDK: "30" + ANDROID_BUILD_TOOLS: "30.0.3" + ANDROID_COMMAND_LINE_TOOLS: "7302050" + +.prepare-android-environment: + before_script: + - apt-get --quiet update --yes + - apt-get --quiet install --yes wget tar unzip lib32stdc++6 lib32z1 + - wget --quiet --output-document=android-sdk.zip https://dl.google.com/android/repository/commandlinetools-linux-${ANDROID_COMMAND_LINE_TOOLS}_latest.zip + - unzip -d android-sdk-linux android-sdk.zip + - echo y | android-sdk-linux/cmdline-tools/bin/sdkmanager --sdk_root=. "platforms;android-${ANDROID_COMPILE_SDK}" >/dev/null + - echo y | android-sdk-linux/cmdline-tools/bin/sdkmanager --sdk_root=. "platform-tools" >/dev/null + - echo y | android-sdk-linux/cmdline-tools/bin/sdkmanager --sdk_root=. "build-tools;${ANDROID_BUILD_TOOLS}" >/dev/null + - export ANDROID_SDK_ROOT=$PWD + - export PATH=$PATH:$PWD/platform-tools/ + # temporarily disable checking for EPIPE error and use yes to accept all licenses + - set +o pipefail + - yes | android-sdk-linux/cmdline-tools/bin/sdkmanager --sdk_root=. --licenses + - set -o pipefail + +stages: + - build + - verify + - release + +build: + stage: build + rules: + - if: '$CI_COMMIT_REF_NAME == "main"' + extends: .prepare-android-environment + script: + - touch local.properties + - ./gradlew build publish -PmavenCentralUsername=$SONATYPE_USERNAME -PmavenCentralPassword=$SONATYPE_PASSWORD + +sast-scan: + stage: verify + rules: + - if: '$CI_COMMIT_REF_NAME == "main"' + extends: .sast_scan + variables: + SAST_SCANNER: "Semgrep" + # Fail build on high severity security vulnerabilities + alert_mode: "policy" + +oss-scan: + stage: verify + rules: + - if: '$CI_COMMIT_REF_NAME == "main"' + extends: .oss-scan + +release: + stage: release + rules: + - if: '$CI_COMMIT_TAG =~ /^v[0-9]+\.[0-9]+\.[0-9]+.*/' + extends: .prepare-android-environment + script: + - touch local.properties + - export ORG_GRADLE_PROJECT_signingKey=$GPG_SECRET_KEY + - export ORG_GRADLE_PROJECT_signingPassword=$GPG_PASSWORD + - ./gradlew -Prelease=true --no-build-cache -PmavenCentralUsername=$SONATYPE_USERNAME -PmavenCentralPassword=$SONATYPE_PASSWORD build signMavenPublication publish diff --git a/.lycheeignore b/.lycheeignore new file mode 100644 index 000000000..bb28bb18c --- /dev/null +++ b/.lycheeignore @@ -0,0 +1,4 @@ +https://rum-ingest/ +https://appassets.androidplatform.net/assets/index.html +https://appassets.androidplatform.net/assets/first.html +https://appassets.androidplatform.net/assets/second.html \ No newline at end of file diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 000000000..4ce7f1ea0 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,25 @@ +# Security + +## Reporting Security Issues + +Please *DO NOT* report security vulnerabilities with public GitHub issue +reports. Please [report security issues here]( +https://www.splunk.com/en_us/product-security/report.html). + +## Dependencies + +This project relies on a variety of external dependencies. +These dependencies are monitored by +[Dependabot](https://docs.github.com/en/code-security/supply-chain-security/configuring-dependabot-security-updates). +Dependencies are [checked +daily](https://github.com/signalfx/splunk-otel-java/blob/main/.github/dependabot.yml) +and associated pull requests are opened automatically. Upgrading to the [latest +release](https://github.com/signalfx/splunk-otel-android/releases) +is recommended to ensure you have the latest security updates. If a security +vulnerability is detected for a dependency of this project then either: + +- You are running an older release +- A new release with the updates has not been cut yet +- The updated dependency has not been merged likely due to some breaking change + (in this case, we will actively work to resolve the issue) +- The dependency has not released an updated version with the patch diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 000000000..cc43bc7b2 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,32 @@ +// Top-level build file where you can add configuration options common to all sub-projects/modules. +buildscript { + repositories { + google() + mavenCentral() + } + dependencies { + // keep this version in sync with /buildSrc/build.gradle.kts + classpath("com.android.tools.build:gradle:8.1.0") + } +} + +plugins { + id("splunk.spotless-conventions") +} + +allprojects { + repositories { + google() + mavenCentral() + maven { + url = uri("https://oss.sonatype.org/content/repositories/snapshots") + } + } + if (findProperty("release") != "true") { + version = "$version-SNAPSHOT" + } +} + +subprojects { + apply(plugin = "splunk.spotless-conventions") +} diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts new file mode 100644 index 000000000..e9d7284da --- /dev/null +++ b/buildSrc/build.gradle.kts @@ -0,0 +1,28 @@ +plugins { + `kotlin-dsl` + + // When updating, update below in dependencies too + id("com.diffplug.spotless") version "6.20.0" +} + +spotless { + kotlinGradle { + ktlint() + target("* * / *.gradle.kts") + } +} + +repositories { + mavenCentral() + google() + gradlePluginPortal() +} + +dependencies { + // keep this version in sync with /build.gradle.kts + implementation("com.android.tools.build:gradle:8.1.0") + + implementation("com.diffplug.spotless:spotless-plugin-gradle:6.20.0") + implementation("net.ltgt.gradle:gradle-errorprone-plugin:3.1.0") + implementation("net.ltgt.gradle:gradle-nullaway-plugin:1.6.0") +} diff --git a/buildSrc/src/main/kotlin/splunk.android-library-conventions.gradle.kts b/buildSrc/src/main/kotlin/splunk.android-library-conventions.gradle.kts new file mode 100644 index 000000000..036039732 --- /dev/null +++ b/buildSrc/src/main/kotlin/splunk.android-library-conventions.gradle.kts @@ -0,0 +1,116 @@ +import org.gradle.api.publish.maven.MavenPublication +import java.net.URI + +plugins { + id("com.android.library") + id("maven-publish") + id("signing") +} + +android { + lint { + warningsAsErrors = true + // A newer version of androidx.appcompat:appcompat than 1.3.1 is available: 1.4.1 [GradleDependency] + // we rely on dependabot for dependency updates + disable.add("GradleDependency") + } +} + +publishing { + repositories { + maven { + val releasesRepoUrl = URI("https://oss.sonatype.org/service/local/staging/deploy/maven2") + val snapshotsRepoUrl = URI("https://oss.sonatype.org/content/repositories/snapshots/") + url = if (project.findProperty("release") == "true") releasesRepoUrl else snapshotsRepoUrl + credentials { + username = findProperty("mavenCentralUsername") as String? + password = findProperty("mavenCentralPassword") as String? + } + } + } + publications { + register("maven") { + groupId = "com.splunk" + artifactId = base.archivesName.get() + + afterEvaluate { + pom.name.set("${project.extra["pomName"]}") + pom.description.set(project.description) + } + + pom { + url.set("https://github.com/signalfx/splunk-otel-android") + licenses { + license { + name.set("The Apache License, Version 2.0") + url.set("http://www.apache.org/licenses/LICENSE-2.0.txt") + } + } + developers { + developer { + id.set("splunk") + name.set("Splunk Instrumentation Authors") + email.set("support+java@signalfx.com") + organization.set("Splunk") + organizationUrl.set("https://www.splunk.com") + } + } + scm { + connection.set("https://github.com/signalfx/splunk-otel-android.git") + developerConnection.set("https://github.com/signalfx/splunk-otel-android.git") + url.set("https://github.com/signalfx/splunk-otel-android") + } + } + } + } +} + +if (project.findProperty("release") == "true") { + signing { + useGpgCmd() + val signingKey: String? by project + val signingPassword: String? by project + useInMemoryPgpKeys(signingKey, signingPassword) + sign(publishing.publications["maven"]) + } +} + +val sourcesJar by tasks.registering(Jar::class) { + + from(android.sourceSets.named("main").get().java.srcDirs) + archiveClassifier.set("sources") +} + +project.afterEvaluate { + + // note: we need to declare this here in afterEvaluate because the android plugin doesn't + // resolve dependencies early enough to make the libraryVariants hack work until here. + val javadoc by tasks.registering(Javadoc::class) { + source = android.sourceSets.named("main").get().java.getSourceFiles() + classpath += project.files(android.bootClasspath) + + // grab the library variants, because apparently this is where the real classpath lives that + // is needed for javadoc generation. + val firstVariant = project.android.libraryVariants.toList().first() + val javaCompile = firstVariant.javaCompileProvider.get() + classpath += javaCompile.classpath + classpath += javaCompile.outputs.files + } + + val javadocJar by tasks.registering(Jar::class) { + dependsOn(javadoc) + archiveClassifier.set("javadoc") + from(javadoc.get().destinationDir) + } + + val component = project.components.findByName("release") + publishing { + publications { + named("maven") { + from(component) + artifact(tasks.named("sourcesJar")) + artifact(javadocJar) + } + } + } +} diff --git a/buildSrc/src/main/kotlin/splunk.errorprone-conventions.gradle.kts b/buildSrc/src/main/kotlin/splunk.errorprone-conventions.gradle.kts new file mode 100644 index 000000000..a470dd5a1 --- /dev/null +++ b/buildSrc/src/main/kotlin/splunk.errorprone-conventions.gradle.kts @@ -0,0 +1,38 @@ +import net.ltgt.gradle.errorprone.CheckSeverity +import net.ltgt.gradle.errorprone.errorprone +import net.ltgt.gradle.nullaway.nullaway + +plugins { + id("net.ltgt.errorprone") + id("net.ltgt.nullaway") +} + +dependencies { + errorprone("com.uber.nullaway:nullaway:0.9.9") + errorprone("com.google.errorprone:error_prone_core:2.15.0") + errorproneJavac("com.google.errorprone:javac:9+181-r4173-1") +} + +nullaway { + annotatedPackages.add("com.splunk.rum") +} + +tasks { + withType().configureEach { + options.errorprone { + if (name.toLowerCase().contains("test")) { + // just disable all error prone checks for test + isEnabled.set(false); + } + + nullaway { + severity.set(CheckSeverity.ERROR) + } + + // Builder 'return this;' pattern + disable("CanIgnoreReturnValueSuggester") + // Common to avoid an allocation + disable("MixedMutabilityReturnType") + } + } +} diff --git a/buildSrc/src/main/kotlin/splunk.spotless-conventions.gradle.kts b/buildSrc/src/main/kotlin/splunk.spotless-conventions.gradle.kts new file mode 100644 index 000000000..582f6de54 --- /dev/null +++ b/buildSrc/src/main/kotlin/splunk.spotless-conventions.gradle.kts @@ -0,0 +1,39 @@ +import com.diffplug.gradle.spotless.SpotlessExtension + +plugins { + id("com.diffplug.spotless") +} + +extensions.configure("spotless") { + java { + googleJavaFormat().aosp() + licenseHeaderFile(rootProject.file("gradle/spotless.license.java"), "(package|import|public)") + target("src/**/*.java") + } + plugins.withId("org.jetbrains.kotlin.jvm") { + kotlin { + ktlint() + licenseHeaderFile(rootProject.file("gradle/spotless.license.java"), "(package|import|public)") + } + } + kotlinGradle { + ktlint() + } + format("misc") { + // not using "**/..." to help keep spotless fast + target( + ".gitignore", + ".gitattributes", + ".gitconfig", + ".editorconfig", + "*.md", + "src/**/*.md", + "docs/**/*.md", + "*.sh", + "src/**/*.properties" + ) + indentWithSpaces() + trimTrailingWhitespace() + endWithNewline() + } +} diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 000000000..4122e5bdd --- /dev/null +++ b/gradle.properties @@ -0,0 +1,23 @@ +# Project-wide Gradle settings. +# IDE (e.g. Android Studio) users: +# Gradle settings configured through the IDE *will override* +# any settings specified in this file. +# For more details on how to configure your build environment visit +# http://www.gradle.org/docs/current/userguide/build_environment.html +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +# When configured, Gradle will run in incubating parallel mode. +# This option should only be used with decoupled projects. More details, visit +# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects +# org.gradle.parallel=true +# AndroidX package structure to make it clearer which packages are bundled with the +# Android operating system, and which are packaged with your app"s APK +# https://developer.android.com/topic/libraries/support-library/androidx-rn +android.useAndroidX=true + +# generate the BuildConfig class that contains the app version +android.defaults.buildfeatures.buildconfig=true + +version=1.1.0 +group=com.splunk diff --git a/gradle/spotless.license.java b/gradle/spotless.license.java new file mode 100644 index 000000000..7529778b1 --- /dev/null +++ b/gradle/spotless.license.java @@ -0,0 +1,16 @@ +/* + * Copyright Splunk Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 000000000..c1962a79e Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 000000000..0c85a1f75 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.1-bin.zip +networkTimeout=10000 +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 000000000..aeb74cbb4 --- /dev/null +++ b/gradlew @@ -0,0 +1,245 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command; +# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of +# shell script including quotes and variable substitutions, so put them in +# double quotes to make sure that they get re-expanded; and +# * put everything else in single quotes, so that it's not re-expanded. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 000000000..6689b85be --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,92 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/opentelemetry-android-instrumentation/build.gradle.kts b/opentelemetry-android-instrumentation/build.gradle.kts new file mode 100644 index 000000000..52fae5ef0 --- /dev/null +++ b/opentelemetry-android-instrumentation/build.gradle.kts @@ -0,0 +1,86 @@ +plugins { + id("com.android.library") + id("splunk.android-library-conventions") + id("splunk.errorprone-conventions") +} + +// This submodule is alpha and is not yet intended to be used by itself +version = project.version.toString().replaceFirst("(-SNAPSHOT)?$".toRegex(), "-alpha$1") + +android { + namespace = "opentelemetry.rum.instrumentation" + + compileSdk = 33 + buildToolsVersion = "30.0.3" + + defaultConfig { + minSdk = 21 + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles("consumer-rules.pro") + } + + buildTypes { + all { + resValue("string", "rum.version", "${project.version}") + } + release { + isMinifyEnabled = false + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") + } + } + + compileOptions { + isCoreLibraryDesugaringEnabled = true + + sourceCompatibility(JavaVersion.VERSION_1_8) + targetCompatibility(JavaVersion.VERSION_1_8) + } + + testOptions { + unitTests.isReturnDefaultValues = true + unitTests.isIncludeAndroidResources = true + } +} + +val otelVersion = "1.28.0" +val otelAlphaVersion = "$otelVersion-alpha" +val otelInstrumentationVersion = "1.28.0" + +dependencies { + implementation("androidx.appcompat:appcompat:1.6.1") + implementation("androidx.core:core:1.10.1") + implementation("androidx.navigation:navigation-fragment:2.6.0") + + api(platform("io.opentelemetry:opentelemetry-bom:$otelVersion")) + implementation("io.opentelemetry:opentelemetry-sdk") + implementation("io.opentelemetry:opentelemetry-exporter-zipkin") + implementation("io.zipkin.reporter2:zipkin-sender-okhttp3") + implementation("io.opentelemetry:opentelemetry-exporter-logging") + implementation("io.opentelemetry.instrumentation:opentelemetry-instrumentation-api:$otelInstrumentationVersion") + + implementation(platform("io.opentelemetry:opentelemetry-bom-alpha:$otelAlphaVersion")) + implementation("io.opentelemetry:opentelemetry-semconv") + + api("io.opentelemetry:opentelemetry-api") + + testImplementation("org.mockito:mockito-core:5.4.0") + testImplementation("org.mockito:mockito-junit-jupiter:5.4.0") + testImplementation(platform("org.junit:junit-bom:5.10.0")) + testImplementation("org.junit.jupiter:junit-jupiter-api") + testImplementation("org.junit.jupiter:junit-jupiter-engine") + testImplementation("org.junit.vintage:junit-vintage-engine") + testImplementation("io.opentelemetry:opentelemetry-sdk-testing") + testImplementation("org.robolectric:robolectric:4.10.3") + testImplementation("androidx.test:core:1.5.0") + testImplementation("org.assertj:assertj-core:3.24.2") + testImplementation("org.awaitility:awaitility:4.2.0") + + coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.3") +} + +tasks.withType { + useJUnitPlatform() +} + +extra["pomName"] = "OpenTelemetry Android Instrumentation" +description = "A library for instrumenting Android applications with OpenTelemetry" diff --git a/opentelemetry-android-instrumentation/src/main/AndroidManifest.xml b/opentelemetry-android-instrumentation/src/main/AndroidManifest.xml new file mode 100644 index 000000000..298c8b6e8 --- /dev/null +++ b/opentelemetry-android-instrumentation/src/main/AndroidManifest.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/AndroidResource.java b/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/AndroidResource.java new file mode 100644 index 000000000..ababe4754 --- /dev/null +++ b/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/AndroidResource.java @@ -0,0 +1,81 @@ +/* + * Copyright Splunk Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.opentelemetry.rum.internal; + +import static io.opentelemetry.rum.internal.RumConstants.RUM_SDK_VERSION; +import static io.opentelemetry.semconv.resource.attributes.ResourceAttributes.DEVICE_MODEL_IDENTIFIER; +import static io.opentelemetry.semconv.resource.attributes.ResourceAttributes.DEVICE_MODEL_NAME; +import static io.opentelemetry.semconv.resource.attributes.ResourceAttributes.OS_NAME; +import static io.opentelemetry.semconv.resource.attributes.ResourceAttributes.OS_TYPE; +import static io.opentelemetry.semconv.resource.attributes.ResourceAttributes.OS_VERSION; +import static io.opentelemetry.semconv.resource.attributes.ResourceAttributes.SERVICE_NAME; + +import android.app.Application; +import android.os.Build; +import io.opentelemetry.sdk.resources.Resource; +import io.opentelemetry.sdk.resources.ResourceBuilder; +import java.util.function.Supplier; +import opentelemetry.rum.instrumentation.R; + +final class AndroidResource { + + static Resource createDefault(Application application) { + String appName = readAppName(application); + ResourceBuilder resourceBuilder = + Resource.getDefault().toBuilder().put(SERVICE_NAME, appName); + + return resourceBuilder + .put(RUM_SDK_VERSION, detectRumVersion(application)) + .put(DEVICE_MODEL_NAME, Build.MODEL) + .put(DEVICE_MODEL_IDENTIFIER, Build.MODEL) + .put(OS_NAME, "Android") + .put(OS_TYPE, "linux") + .put(OS_VERSION, Build.VERSION.RELEASE) + .build(); + } + + private static String readAppName(Application application) { + return trapTo( + () -> { + int stringId = + application.getApplicationContext().getApplicationInfo().labelRes; + return application.getApplicationContext().getString(stringId); + }, + "unknown_service:android"); + } + + private static String detectRumVersion(Application application) { + return trapTo( + () -> { + // TODO: Verify that this will be in the lib/jar at runtime. + // TODO: After donation, package of R file will change + return application + .getApplicationContext() + .getResources() + .getString(R.string.rum_version); + }, + "unknown"); + } + + private static String trapTo(Supplier fn, String defaultValue) { + try { + return fn.get(); + } catch (Exception e) { + return defaultValue; + } + } +} diff --git a/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/ApplicationStateWatcher.java b/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/ApplicationStateWatcher.java new file mode 100644 index 000000000..4df387b61 --- /dev/null +++ b/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/ApplicationStateWatcher.java @@ -0,0 +1,55 @@ +/* + * Copyright Splunk Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.opentelemetry.rum.internal; + +import android.app.Activity; +import androidx.annotation.NonNull; +import io.opentelemetry.rum.internal.instrumentation.ApplicationStateListener; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; + +final class ApplicationStateWatcher implements DefaultingActivityLifecycleCallbacks { + + private final List applicationStateListeners = + new CopyOnWriteArrayList<>(); + // we count the number of activities that have been "started" and not yet "stopped" here to + // figure out when the app goes into the background. + private int numberOfOpenActivities = 0; + + @Override + public void onActivityStarted(@NonNull Activity activity) { + if (numberOfOpenActivities == 0) { + for (ApplicationStateListener listener : applicationStateListeners) { + listener.onApplicationForegrounded(); + } + } + numberOfOpenActivities++; + } + + @Override + public void onActivityStopped(@NonNull Activity activity) { + if (--numberOfOpenActivities == 0) { + for (ApplicationStateListener listener : applicationStateListeners) { + listener.onApplicationBackgrounded(); + } + } + } + + void registerListener(ApplicationStateListener listener) { + applicationStateListeners.add(listener); + } +} diff --git a/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/DefaultingActivityLifecycleCallbacks.java b/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/DefaultingActivityLifecycleCallbacks.java new file mode 100644 index 000000000..1dad341dd --- /dev/null +++ b/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/DefaultingActivityLifecycleCallbacks.java @@ -0,0 +1,55 @@ +/* + * Copyright Splunk Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.opentelemetry.rum.internal; + +import android.app.Activity; +import android.app.Application; +import android.os.Bundle; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +/** + * Interface helper for implementations that don't need/want all the extra baggage of the full + * Application.ActivityLifecycleCallbacks interface. Implementations can choose which methods to + * implement. + */ +public interface DefaultingActivityLifecycleCallbacks + extends Application.ActivityLifecycleCallbacks { + + @Override + default void onActivityCreated( + @NonNull Activity activity, @Nullable Bundle savedInstanceState) {} + + @Override + default void onActivityStarted(@NonNull Activity activity) {} + + @Override + default void onActivityResumed(@NonNull Activity activity) {} + + @Override + default void onActivityPaused(@NonNull Activity activity) {} + + @Override + default void onActivityStopped(@NonNull Activity activity) {} + + @Override + default void onActivitySaveInstanceState( + @NonNull Activity activity, @NonNull Bundle outState) {} + + @Override + default void onActivityDestroyed(@NonNull Activity activity) {} +} diff --git a/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/GlobalAttributesSpanAppender.java b/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/GlobalAttributesSpanAppender.java new file mode 100644 index 000000000..75f8885e6 --- /dev/null +++ b/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/GlobalAttributesSpanAppender.java @@ -0,0 +1,96 @@ +/* + * Copyright Splunk Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.opentelemetry.rum.internal; + +import static java.util.Objects.requireNonNull; + +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.api.common.AttributesBuilder; +import io.opentelemetry.context.Context; +import io.opentelemetry.sdk.trace.ReadWriteSpan; +import io.opentelemetry.sdk.trace.ReadableSpan; +import io.opentelemetry.sdk.trace.SpanProcessor; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; + +/** + * A {@link SpanProcessor} implementation that appends a set of {@linkplain Attributes attributes} + * to every span that is exported. The attributes collection is mutable, and can be updated by + * calling {@link #update(Consumer)}. + * + *

This class is internal and is hence not for public use. Its APIs are unstable and can change + * at any time. + */ +public final class GlobalAttributesSpanAppender implements SpanProcessor { + + /** + * Returns a new {@link GlobalAttributesSpanAppender} with a given initial attributes. + * + * @param initialState The initial collection of attributes to append to every span. + */ + public static GlobalAttributesSpanAppender create(Attributes initialState) { + return new GlobalAttributesSpanAppender(initialState); + } + + private final AtomicReference attributes; + + private GlobalAttributesSpanAppender(Attributes initialState) { + this.attributes = new AtomicReference<>(initialState); + } + + @Override + public void onStart(Context parentContext, ReadWriteSpan span) { + span.setAllAttributes(attributes.get()); + } + + @Override + public boolean isStartRequired() { + return true; + } + + @Override + public void onEnd(ReadableSpan span) {} + + @Override + public boolean isEndRequired() { + return false; + } + + /** + * Update the global set of attributes that will be appended to every span. + * + *

Note: this operation performs an atomic update. The passed function should be free from + * side effects, since it may be called multiple times in case of thread contention. + * + * @param attributesUpdater A function which will update the current set of attributes, by + * operating on a {@link AttributesBuilder} from the current set. + */ + public void update(Consumer attributesUpdater) { + while (true) { + // we're absolutely certain this will never be null + Attributes oldAttributes = requireNonNull(attributes.get()); + + AttributesBuilder builder = oldAttributes.toBuilder(); + attributesUpdater.accept(builder); + Attributes newAttributes = builder.build(); + + if (attributes.compareAndSet(oldAttributes, newAttributes)) { + break; + } + } + } +} diff --git a/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/InstrumentedApplicationImpl.java b/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/InstrumentedApplicationImpl.java new file mode 100644 index 000000000..a8274d4e7 --- /dev/null +++ b/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/InstrumentedApplicationImpl.java @@ -0,0 +1,53 @@ +/* + * Copyright Splunk Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.opentelemetry.rum.internal; + +import android.app.Application; +import io.opentelemetry.rum.internal.instrumentation.ApplicationStateListener; +import io.opentelemetry.rum.internal.instrumentation.InstrumentedApplication; +import io.opentelemetry.sdk.OpenTelemetrySdk; + +final class InstrumentedApplicationImpl implements InstrumentedApplication { + + private final Application application; + private final OpenTelemetrySdk openTelemetrySdk; + private final ApplicationStateWatcher applicationStateWatcher; + + InstrumentedApplicationImpl( + Application application, + OpenTelemetrySdk openTelemetrySdk, + ApplicationStateWatcher applicationStateWatcher) { + this.application = application; + this.openTelemetrySdk = openTelemetrySdk; + this.applicationStateWatcher = applicationStateWatcher; + } + + @Override + public Application getApplication() { + return application; + } + + @Override + public OpenTelemetrySdk getOpenTelemetrySdk() { + return openTelemetrySdk; + } + + @Override + public void registerApplicationStateListener(ApplicationStateListener listener) { + applicationStateWatcher.registerListener(listener); + } +} diff --git a/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/NoopOpenTelemetryRum.java b/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/NoopOpenTelemetryRum.java new file mode 100644 index 000000000..8d2d50ed8 --- /dev/null +++ b/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/NoopOpenTelemetryRum.java @@ -0,0 +1,35 @@ +/* + * Copyright Splunk Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.opentelemetry.rum.internal; + +import io.opentelemetry.api.OpenTelemetry; +import io.opentelemetry.api.trace.TraceId; + +enum NoopOpenTelemetryRum implements OpenTelemetryRum { + INSTANCE; + + @Override + public OpenTelemetry getOpenTelemetry() { + return OpenTelemetry.noop(); + } + + @Override + public String getRumSessionId() { + // RUM sessionId has the same format as traceId + return TraceId.getInvalid(); + } +} diff --git a/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/OpenTelemetryRum.java b/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/OpenTelemetryRum.java new file mode 100644 index 000000000..0851e7cfa --- /dev/null +++ b/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/OpenTelemetryRum.java @@ -0,0 +1,81 @@ +/* + * Copyright Splunk Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.opentelemetry.rum.internal; + +import android.app.Application; +import io.opentelemetry.api.OpenTelemetry; +import io.opentelemetry.sdk.OpenTelemetrySdk; +import io.opentelemetry.sdk.logs.SdkLoggerProvider; +import io.opentelemetry.sdk.metrics.SdkMeterProvider; +import io.opentelemetry.sdk.trace.SdkTracerProvider; + +/** + * Entrypoint for the OpenTelemetry Real User Monitoring library for Android. + * + *

This class is internal and is hence not for public use. Its APIs are unstable and can change + * at any time. + */ +public interface OpenTelemetryRum { + + /** + * Returns a new {@link OpenTelemetryRumBuilder} for {@link OpenTelemetryRum}. Use this version + * if you would like to configure individual aspects of the OpenTelemetry SDK but would still + * prefer to allow OpenTelemetry RUM to create the SDK for you. If you would like to "bring your + * own" SDK, call the two-argument version. + * + * @param application The {@link Application} that is being instrumented. + */ + static OpenTelemetryRumBuilder builder(Application application) { + return new OpenTelemetryRumBuilder(application); + } + + /** + * Returns a new {@link SdkPreconfiguredRumBuilder} for {@link OpenTelemetryRum}. This version + * requires the user to preconfigure and create their own OpenTelemetrySdk instance. If you + * prefer to use the builder to configure individual aspects of the OpenTelemetry SDK and to + * create and manage it for you, call the one-argument version. + * + *

Specific consideration should be given to the creation of your provided SDK to ensure that + * the {@link SdkTracerProvider}, {@link SdkMeterProvider}, and {@link SdkLoggerProvider} are + * configured correctly for your target RUM provider. + * + * @param application The {@link Application} that is being instrumented. + * @param openTelemetrySdk The {@link OpenTelemetrySdk} that the user has already created. + */ + static SdkPreconfiguredRumBuilder builder( + Application application, OpenTelemetrySdk openTelemetrySdk) { + return new SdkPreconfiguredRumBuilder(application, openTelemetrySdk); + } + + /** Returns a no-op implementation of {@link OpenTelemetryRum}. */ + static OpenTelemetryRum noop() { + return NoopOpenTelemetryRum.INSTANCE; + } + + /** + * Get a handle to the instance of the {@linkplain OpenTelemetry OpenTelemetry API} that this + * instance is using for instrumentation. + */ + OpenTelemetry getOpenTelemetry(); + + /** + * Get the client session ID associated with this instance of the RUM instrumentation library. + * Note: this value will change throughout the lifetime of an application instance, so it is + * recommended that you do not cache this value, but always retrieve it from here when needed. + */ + String getRumSessionId(); +} diff --git a/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/OpenTelemetryRumBuilder.java b/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/OpenTelemetryRumBuilder.java new file mode 100644 index 000000000..12d8b7053 --- /dev/null +++ b/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/OpenTelemetryRumBuilder.java @@ -0,0 +1,214 @@ +/* + * Copyright Splunk Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.opentelemetry.rum.internal; + +import android.app.Application; +import io.opentelemetry.rum.internal.instrumentation.InstrumentedApplication; +import io.opentelemetry.sdk.OpenTelemetrySdk; +import io.opentelemetry.sdk.logs.SdkLoggerProvider; +import io.opentelemetry.sdk.logs.SdkLoggerProviderBuilder; +import io.opentelemetry.sdk.metrics.SdkMeterProvider; +import io.opentelemetry.sdk.metrics.SdkMeterProviderBuilder; +import io.opentelemetry.sdk.resources.Resource; +import io.opentelemetry.sdk.trace.SdkTracerProvider; +import io.opentelemetry.sdk.trace.SdkTracerProviderBuilder; +import java.util.ArrayList; +import java.util.List; +import java.util.function.BiFunction; +import java.util.function.Consumer; + +/** + * A builder of {@link OpenTelemetryRum}. It enabled configuring the OpenTelemetry SDK and disabling + * built-in Android instrumentations. + * + *

This class is internal and is hence not for public use. Its APIs are unstable and can change + * at any time. + */ +public final class OpenTelemetryRumBuilder { + + private final SessionId sessionId; + private final Application application; + private final List> + tracerProviderCustomizers = new ArrayList<>(); + private final List> + meterProviderCustomizers = new ArrayList<>(); + private final List> + loggerProviderCustomizers = new ArrayList<>(); + private final List> instrumentationInstallers = + new ArrayList<>(); + private Resource resource; + + OpenTelemetryRumBuilder(Application application) { + this.application = application; + SessionIdTimeoutHandler timeoutHandler = new SessionIdTimeoutHandler(); + this.sessionId = new SessionId(timeoutHandler); + this.resource = AndroidResource.createDefault(application); + } + + /** + * Assign a {@link Resource} to be attached to all telemetry emitted by the {@link + * OpenTelemetryRum} created by this builder. This replaces any existing resource. + * + * @return {@code this} + */ + public OpenTelemetryRumBuilder setResource(Resource resource) { + this.resource = resource; + return this; + } + + /** + * Merges a new {@link Resource} with any existing {@link Resource} in this builder. The + * resulting {@link Resource} will be attached to all telemetry emitted by the {@link + * OpenTelemetryRum} created by this builder. + * + * @return {@code this} + */ + public OpenTelemetryRumBuilder mergeResource(Resource resource) { + this.resource = this.resource.merge(resource); + return this; + } + + /** + * Adds a {@link BiFunction} to invoke the with the {@link SdkTracerProviderBuilder} to allow + * customization. The return value of the {@link BiFunction} will replace the passed-in + * argument. + * + *

Multiple calls will execute the customizers in order. + * + *

Note: calling {@link SdkTracerProviderBuilder#setResource(Resource)} inside of your + * configuration function will cause any resource customizers to be ignored that were configured + * via {@link #setResource(Resource)}. + * + * @return {@code this} + */ + public OpenTelemetryRumBuilder addTracerProviderCustomizer( + BiFunction + customizer) { + tracerProviderCustomizers.add(customizer); + return this; + } + + /** + * Adds a {@link BiFunction} to invoke the with the {@link SdkMeterProviderBuilder} to allow + * customization. The return value of the {@link BiFunction} will replace the passed-in + * argument. + * + *

Multiple calls will execute the customizers in order. + * + *

Note: calling {@link SdkMeterProviderBuilder#setResource(Resource)} inside of your + * configuration function will cause any resource customizers to be ignored that were configured + * via {@link #setResource(Resource)}. + * + * @return {@code this} + */ + public OpenTelemetryRumBuilder addMeterProviderCustomizer( + BiFunction customizer) { + meterProviderCustomizers.add(customizer); + return this; + } + + /** + * Adds a {@link BiFunction} to invoke the with the {@link SdkLoggerProviderBuilder} to allow + * customization. The return value of the {@link BiFunction} will replace the passed-in + * argument. + * + *

Multiple calls will execute the customizers in order. + * + *

Note: calling {@link SdkLoggerProviderBuilder#setResource(Resource)} inside of your + * configuration function will cause any resource customizers to be ignored that were configured + * via {@link #setResource(Resource)}. + * + * @return {@code this} + */ + public OpenTelemetryRumBuilder addLoggerProviderCustomizer( + BiFunction + customizer) { + loggerProviderCustomizers.add(customizer); + return this; + } + + /** + * Adds an instrumentation installer function that will be run on an {@link + * InstrumentedApplication} instance as a part of the {@link #build()} method call. + * + * @return {@code this} + */ + public OpenTelemetryRumBuilder addInstrumentation( + Consumer instrumentationInstaller) { + instrumentationInstallers.add(instrumentationInstaller); + return this; + } + + public SessionId getSessionId() { + return sessionId; + } + + /** + * Creates a new instance of {@link OpenTelemetryRum} with the settings of this {@link + * OpenTelemetryRumBuilder}. + * + *

This method will initialize the OpenTelemetry SDK and install built-in system + * instrumentations in the passed Android {@link Application}. + * + * @return A new {@link OpenTelemetryRum} instance. + */ + public OpenTelemetryRum build() { + OpenTelemetrySdk sdk = + OpenTelemetrySdk.builder() + .setTracerProvider(buildTracerProvider(sessionId, application)) + .setMeterProvider(buildMeterProvider(application)) + .setLoggerProvider(buildLoggerProvider(application)) + .build(); + + SdkPreconfiguredRumBuilder delegate = + new SdkPreconfiguredRumBuilder(application, sdk, sessionId); + instrumentationInstallers.forEach(delegate::addInstrumentation); + return delegate.build(); + } + + private SdkTracerProvider buildTracerProvider(SessionId sessionId, Application application) { + SdkTracerProviderBuilder tracerProviderBuilder = + SdkTracerProvider.builder() + .setResource(resource) + .addSpanProcessor(new SessionIdSpanAppender(sessionId)); + for (BiFunction + customizer : tracerProviderCustomizers) { + tracerProviderBuilder = customizer.apply(tracerProviderBuilder, application); + } + return tracerProviderBuilder.build(); + } + + private SdkMeterProvider buildMeterProvider(Application application) { + SdkMeterProviderBuilder meterProviderBuilder = + SdkMeterProvider.builder().setResource(resource); + for (BiFunction customizer : + meterProviderCustomizers) { + meterProviderBuilder = customizer.apply(meterProviderBuilder, application); + } + return meterProviderBuilder.build(); + } + + private SdkLoggerProvider buildLoggerProvider(Application application) { + SdkLoggerProviderBuilder loggerProviderBuilder = + SdkLoggerProvider.builder().setResource(resource); + for (BiFunction + customizer : loggerProviderCustomizers) { + loggerProviderBuilder = customizer.apply(loggerProviderBuilder, application); + } + return loggerProviderBuilder.build(); + } +} diff --git a/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/OpenTelemetryRumImpl.java b/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/OpenTelemetryRumImpl.java new file mode 100644 index 000000000..2b749fc4a --- /dev/null +++ b/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/OpenTelemetryRumImpl.java @@ -0,0 +1,41 @@ +/* + * Copyright Splunk Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.opentelemetry.rum.internal; + +import io.opentelemetry.api.OpenTelemetry; +import io.opentelemetry.sdk.OpenTelemetrySdk; + +final class OpenTelemetryRumImpl implements OpenTelemetryRum { + + private final OpenTelemetrySdk openTelemetrySdk; + private final SessionId sessionId; + + OpenTelemetryRumImpl(OpenTelemetrySdk openTelemetrySdk, SessionId sessionId) { + this.openTelemetrySdk = openTelemetrySdk; + this.sessionId = sessionId; + } + + @Override + public OpenTelemetry getOpenTelemetry() { + return openTelemetrySdk; + } + + @Override + public String getRumSessionId() { + return sessionId.getSessionId(); + } +} diff --git a/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/RumConstants.java b/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/RumConstants.java new file mode 100644 index 000000000..84ca83711 --- /dev/null +++ b/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/RumConstants.java @@ -0,0 +1,49 @@ +/* + * Copyright Splunk Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.opentelemetry.rum.internal; + +import static io.opentelemetry.api.common.AttributeKey.doubleKey; +import static io.opentelemetry.api.common.AttributeKey.longKey; +import static io.opentelemetry.api.common.AttributeKey.stringKey; + +import io.opentelemetry.api.common.AttributeKey; + +public class RumConstants { + + public static final String OTEL_RUM_LOG_TAG = "OpenTelemetryRum"; + + public static final AttributeKey SESSION_ID_KEY = stringKey("rum.session.id"); + + public static final AttributeKey LAST_SCREEN_NAME_KEY = + AttributeKey.stringKey("last.screen.name"); + public static final AttributeKey SCREEN_NAME_KEY = + AttributeKey.stringKey("screen.name"); + public static final AttributeKey START_TYPE_KEY = stringKey("start.type"); + + public static final AttributeKey RUM_SDK_VERSION = stringKey("rum.sdk.version"); + + public static final AttributeKey STORAGE_SPACE_FREE_KEY = longKey("storage.free"); + public static final AttributeKey HEAP_FREE_KEY = longKey("heap.free"); + public static final AttributeKey BATTERY_PERCENT_KEY = doubleKey("battery.percent"); + + public static final AttributeKey PREVIOUS_SESSION_ID_KEY = + stringKey("rum.session.previous_id"); + + public static final String APP_START_SPAN_NAME = "AppStart"; + + private RumConstants() {} +} diff --git a/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/RuntimeDetailsExtractor.java b/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/RuntimeDetailsExtractor.java new file mode 100644 index 000000000..36125d091 --- /dev/null +++ b/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/RuntimeDetailsExtractor.java @@ -0,0 +1,94 @@ +/* + * Copyright Splunk Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.opentelemetry.rum.internal; + +import static io.opentelemetry.rum.internal.RumConstants.BATTERY_PERCENT_KEY; +import static io.opentelemetry.rum.internal.RumConstants.HEAP_FREE_KEY; +import static io.opentelemetry.rum.internal.RumConstants.STORAGE_SPACE_FREE_KEY; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.os.BatteryManager; +import androidx.annotation.Nullable; +import io.opentelemetry.api.common.AttributesBuilder; +import io.opentelemetry.instrumentation.api.instrumenter.AttributesExtractor; +import java.io.File; + +/** Represents details about the runtime environment at a time */ +public final class RuntimeDetailsExtractor extends BroadcastReceiver + implements AttributesExtractor { + + private @Nullable volatile Double batteryPercent = null; + private final File filesDir; + + public static RuntimeDetailsExtractor create(Context context) { + IntentFilter batteryChangedFilter = new IntentFilter(Intent.ACTION_BATTERY_CHANGED); + File filesDir = context.getFilesDir(); + RuntimeDetailsExtractor runtimeDetails = new RuntimeDetailsExtractor<>(filesDir); + context.registerReceiver(runtimeDetails, batteryChangedFilter); + return runtimeDetails; + } + + private RuntimeDetailsExtractor(File filesDir) { + this.filesDir = filesDir; + } + + @Override + public void onReceive(Context context, Intent intent) { + int level = intent.getIntExtra(BatteryManager.EXTRA_LEVEL, -1); + int scale = intent.getIntExtra(BatteryManager.EXTRA_SCALE, -1); + batteryPercent = level * 100.0d / (float) scale; + } + + @Override + public void onStart( + AttributesBuilder attributes, + io.opentelemetry.context.Context parentContext, + RQ request) { + attributes.put(STORAGE_SPACE_FREE_KEY, getCurrentStorageFreeSpaceInBytes()); + attributes.put(HEAP_FREE_KEY, getCurrentFreeHeapInBytes()); + + Double currentBatteryPercent = getCurrentBatteryPercent(); + if (currentBatteryPercent != null) { + attributes.put(BATTERY_PERCENT_KEY, currentBatteryPercent); + } + } + + @Override + public void onEnd( + AttributesBuilder attributes, + io.opentelemetry.context.Context context, + RQ request, + RS response, + Throwable error) {} + + private long getCurrentStorageFreeSpaceInBytes() { + return filesDir.getFreeSpace(); + } + + private long getCurrentFreeHeapInBytes() { + Runtime runtime = Runtime.getRuntime(); + return runtime.freeMemory(); + } + + @Nullable + private Double getCurrentBatteryPercent() { + return batteryPercent; + } +} diff --git a/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/SdkPreconfiguredRumBuilder.java b/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/SdkPreconfiguredRumBuilder.java new file mode 100644 index 000000000..667bb1a56 --- /dev/null +++ b/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/SdkPreconfiguredRumBuilder.java @@ -0,0 +1,85 @@ +/* + * Copyright Splunk Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.opentelemetry.rum.internal; + +import android.app.Application; +import io.opentelemetry.api.trace.Tracer; +import io.opentelemetry.rum.internal.instrumentation.InstrumentedApplication; +import io.opentelemetry.sdk.OpenTelemetrySdk; +import java.util.ArrayList; +import java.util.List; +import java.util.function.Consumer; + +public final class SdkPreconfiguredRumBuilder { + private final Application application; + private final OpenTelemetrySdk sdk; + private final SessionId sessionId; + + private final List> instrumentationInstallers = + new ArrayList<>(); + + SdkPreconfiguredRumBuilder(Application application, OpenTelemetrySdk openTelemetrySdk) { + this(application, openTelemetrySdk, new SessionId(new SessionIdTimeoutHandler())); + } + + SdkPreconfiguredRumBuilder( + Application application, OpenTelemetrySdk openTelemetrySdk, SessionId sessionId) { + this.application = application; + this.sdk = openTelemetrySdk; + this.sessionId = sessionId; + } + + /** + * Adds an instrumentation installer function that will be run on an {@link + * InstrumentedApplication} instance as a part of the {@link #build()} method call. + * + * @return {@code this} + */ + public SdkPreconfiguredRumBuilder addInstrumentation( + Consumer instrumentationInstaller) { + instrumentationInstallers.add(instrumentationInstaller); + return this; + } + + /** + * Creates a new instance of {@link OpenTelemetryRum} with the settings of this {@link + * OpenTelemetryRumBuilder}. + * + *

This method uses a preconfigured OpenTelemetry SDK and install built-in system + * instrumentations in the passed Android {@link Application}. + * + * @return A new {@link OpenTelemetryRum} instance. + */ + public OpenTelemetryRum build() { + // the app state listeners need to be run in the first ActivityLifecycleCallbacks since they + // might turn off/on additional telemetry depending on whether the app is active or not + ApplicationStateWatcher applicationStateWatcher = new ApplicationStateWatcher(); + application.registerActivityLifecycleCallbacks(applicationStateWatcher); + applicationStateWatcher.registerListener(sessionId.getTimeoutHandler()); + + Tracer tracer = sdk.getTracer(OpenTelemetryRum.class.getSimpleName()); + sessionId.setSessionIdChangeListener(new SessionIdChangeTracer(tracer)); + + InstrumentedApplication instrumentedApplication = + new InstrumentedApplicationImpl(application, sdk, applicationStateWatcher); + for (Consumer installer : instrumentationInstallers) { + installer.accept(instrumentedApplication); + } + + return new OpenTelemetryRumImpl(sdk, sessionId); + } +} diff --git a/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/SessionId.java b/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/SessionId.java new file mode 100644 index 000000000..e61c15d4d --- /dev/null +++ b/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/SessionId.java @@ -0,0 +1,103 @@ +/* + * Copyright Splunk Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.opentelemetry.rum.internal; + +import static java.util.Objects.requireNonNull; + +import androidx.annotation.Nullable; +import io.opentelemetry.api.trace.TraceId; +import io.opentelemetry.sdk.common.Clock; +import java.util.Random; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; + +class SessionId { + + private static final long SESSION_LIFETIME_NANOS = TimeUnit.HOURS.toNanos(4); + + private final Clock clock; + private final AtomicReference value = new AtomicReference<>(); + private final SessionIdTimeoutHandler timeoutHandler; + private volatile long createTimeNanos; + @Nullable private volatile SessionIdChangeListener sessionIdChangeListener; + + SessionId(SessionIdTimeoutHandler timeoutHandler) { + this(Clock.getDefault(), timeoutHandler); + } + + // for testing + SessionId(Clock clock, SessionIdTimeoutHandler timeoutHandler) { + this.clock = clock; + this.timeoutHandler = timeoutHandler; + value.set(createNewId()); + createTimeNanos = clock.now(); + } + + private static String createNewId() { + Random random = new Random(); + // The OTel TraceId has exactly the same format as a RUM SessionId, so let's re-use it here, + // rather than re-inventing the wheel. + return TraceId.fromLongs(random.nextLong(), random.nextLong()); + } + + SessionIdTimeoutHandler getTimeoutHandler() { + return timeoutHandler; + } + + String getSessionId() { + // value will never be null + String oldValue = requireNonNull(value.get()); + String currentValue = oldValue; + boolean sessionIdChanged = false; + + if (sessionExpired() || timeoutHandler.hasTimedOut()) { + String newId = createNewId(); + // if this returns false, then another thread updated the value already. + sessionIdChanged = value.compareAndSet(oldValue, newId); + if (sessionIdChanged) { + createTimeNanos = clock.nanoTime(); + } + // value will never be null + currentValue = requireNonNull(value.get()); + } + + timeoutHandler.bump(); + // sessionId change listener needs to be called after bumping the timer because it may + // create a new span + SessionIdChangeListener sessionIdChangeListener = this.sessionIdChangeListener; + if (sessionIdChanged && sessionIdChangeListener != null) { + sessionIdChangeListener.onChange(oldValue, currentValue); + } + + return currentValue; + } + + private boolean sessionExpired() { + long elapsedTime = clock.nanoTime() - createTimeNanos; + return elapsedTime >= SESSION_LIFETIME_NANOS; + } + + void setSessionIdChangeListener(SessionIdChangeListener sessionIdChangeListener) { + this.sessionIdChangeListener = sessionIdChangeListener; + } + + @Override + public String toString() { + // value will never be null + return requireNonNull(value.get()); + } +} diff --git a/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/SessionIdChangeListener.java b/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/SessionIdChangeListener.java new file mode 100644 index 000000000..01966b3b7 --- /dev/null +++ b/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/SessionIdChangeListener.java @@ -0,0 +1,23 @@ +/* + * Copyright Splunk Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.opentelemetry.rum.internal; + +interface SessionIdChangeListener { + + /** Gets called every time a new sessionId is generated. */ + void onChange(String oldSessionId, String newSessionId); +} diff --git a/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/SessionIdChangeTracer.java b/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/SessionIdChangeTracer.java new file mode 100644 index 000000000..b7b3d0304 --- /dev/null +++ b/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/SessionIdChangeTracer.java @@ -0,0 +1,36 @@ +/* + * Copyright Splunk Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.opentelemetry.rum.internal; + +import io.opentelemetry.api.trace.Tracer; + +final class SessionIdChangeTracer implements SessionIdChangeListener { + + private final Tracer tracer; + + SessionIdChangeTracer(Tracer tracer) { + this.tracer = tracer; + } + + @Override + public void onChange(String oldSessionId, String newSessionId) { + tracer.spanBuilder("sessionId.change") + .setAttribute(RumConstants.PREVIOUS_SESSION_ID_KEY, oldSessionId) + .startSpan() + .end(); + } +} diff --git a/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/SessionIdRatioBasedSampler.java b/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/SessionIdRatioBasedSampler.java new file mode 100644 index 000000000..bba3b85b4 --- /dev/null +++ b/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/SessionIdRatioBasedSampler.java @@ -0,0 +1,63 @@ +/* + * Copyright Splunk Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.opentelemetry.rum.internal; + +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.api.trace.SpanKind; +import io.opentelemetry.context.Context; +import io.opentelemetry.sdk.trace.data.LinkData; +import io.opentelemetry.sdk.trace.samplers.Sampler; +import io.opentelemetry.sdk.trace.samplers.SamplingResult; +import java.util.List; + +/** + * Session ID ratio based sampler. Uses {@link Sampler#traceIdRatioBased(double)} sampler + * internally, but passes sessionId instead of traceId to the underlying sampler in order to use the + * same ratio logic but on sessionId instead. This is valid as sessionId uses {@link + * io.opentelemetry.api.trace.TraceId#fromLongs(long, long)} internally to generate random session + * IDs. + */ +public class SessionIdRatioBasedSampler implements Sampler { + private final Sampler ratioBasedSampler; + private final SessionId sessionid; + + public SessionIdRatioBasedSampler(double ratio, SessionId sessionId) { + this.sessionid = sessionId; + // SessionId uses the same format as TraceId, so we can reuse trace ID ratio sampler. + this.ratioBasedSampler = Sampler.traceIdRatioBased(ratio); + } + + @Override + public SamplingResult shouldSample( + Context parentContext, + String traceId, + String name, + SpanKind spanKind, + Attributes attributes, + List parentLinks) { + // Replace traceId with sessionId + return ratioBasedSampler.shouldSample( + parentContext, sessionid.getSessionId(), name, spanKind, attributes, parentLinks); + } + + @Override + public String getDescription() { + return String.format( + "SessionIdRatioBased{traceIdRatioBased:%s}", + this.ratioBasedSampler.getDescription()); + } +} diff --git a/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/SessionIdSpanAppender.java b/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/SessionIdSpanAppender.java new file mode 100644 index 000000000..13814bfaf --- /dev/null +++ b/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/SessionIdSpanAppender.java @@ -0,0 +1,49 @@ +/* + * Copyright Splunk Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.opentelemetry.rum.internal; + +import io.opentelemetry.context.Context; +import io.opentelemetry.sdk.trace.ReadWriteSpan; +import io.opentelemetry.sdk.trace.ReadableSpan; +import io.opentelemetry.sdk.trace.SpanProcessor; + +final class SessionIdSpanAppender implements SpanProcessor { + + private final SessionId sessionId; + + public SessionIdSpanAppender(SessionId sessionId) { + this.sessionId = sessionId; + } + + @Override + public void onStart(Context parentContext, ReadWriteSpan span) { + span.setAttribute(RumConstants.SESSION_ID_KEY, sessionId.getSessionId()); + } + + @Override + public boolean isStartRequired() { + return true; + } + + @Override + public void onEnd(ReadableSpan span) {} + + @Override + public boolean isEndRequired() { + return false; + } +} diff --git a/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/SessionIdTimeoutHandler.java b/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/SessionIdTimeoutHandler.java new file mode 100644 index 000000000..791aa43fc --- /dev/null +++ b/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/SessionIdTimeoutHandler.java @@ -0,0 +1,88 @@ +/* + * Copyright Splunk Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.opentelemetry.rum.internal; + +import io.opentelemetry.rum.internal.instrumentation.ApplicationStateListener; +import io.opentelemetry.sdk.common.Clock; +import java.util.concurrent.TimeUnit; + +/** + * This class encapsulates the following criteria about the sessionId timeout: + * + *

    + *
  • If the app is in the foreground sessionId should never time out. + *
  • If the app is in the background and no activity (spans) happens for >15 minutes, sessionId + * should time out. + *
  • If the app is in the background and some activity (spans) happens in <15 minute intervals, + * sessionId should not time out. + *
+ * + *

Consequently, when the app spent >15 minutes without any activity (spans) in the background, + * after moving to the foreground the first span should trigger the sessionId timeout. + */ +final class SessionIdTimeoutHandler implements ApplicationStateListener { + + private static final long SESSION_TIMEOUT_NANOS = TimeUnit.MINUTES.toNanos(15); + + private final Clock clock; + private volatile long timeoutStartNanos; + private volatile State state = State.FOREGROUND; + + SessionIdTimeoutHandler() { + this(Clock.getDefault()); + } + + // for testing + SessionIdTimeoutHandler(Clock clock) { + this.clock = clock; + } + + @Override + public void onApplicationForegrounded() { + state = State.TRANSITIONING_TO_FOREGROUND; + } + + @Override + public void onApplicationBackgrounded() { + state = State.BACKGROUND; + } + + boolean hasTimedOut() { + // don't apply sessionId timeout to apps in the foreground + if (state == State.FOREGROUND) { + return false; + } + long elapsedTime = clock.nanoTime() - timeoutStartNanos; + return elapsedTime >= SESSION_TIMEOUT_NANOS; + } + + void bump() { + timeoutStartNanos = clock.nanoTime(); + + // move from the temporary transition state to foreground after the first span + if (state == State.TRANSITIONING_TO_FOREGROUND) { + state = State.FOREGROUND; + } + } + + private enum State { + FOREGROUND, + BACKGROUND, + /** A temporary state representing the first event after the app has been brought back. */ + TRANSITIONING_TO_FOREGROUND + } +} diff --git a/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/export/AttributeModifyingSpanExporter.java b/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/export/AttributeModifyingSpanExporter.java new file mode 100644 index 000000000..51538e7b4 --- /dev/null +++ b/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/export/AttributeModifyingSpanExporter.java @@ -0,0 +1,92 @@ +/* + * Copyright Splunk Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.opentelemetry.rum.internal.export; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import io.opentelemetry.api.common.AttributeKey; +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.api.common.AttributesBuilder; +import io.opentelemetry.sdk.common.CompletableResultCode; +import io.opentelemetry.sdk.trace.data.SpanData; +import io.opentelemetry.sdk.trace.export.SpanExporter; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +/** A SpanExporter that is configured to modify some of its attributes at export time. */ +public class AttributeModifyingSpanExporter implements SpanExporter { + + private final SpanExporter delegate; + private final Map, Function> spanAttributeReplacements; + + public AttributeModifyingSpanExporter( + SpanExporter delegate, Map, Function> spanAttributeReplacements) { + this.delegate = delegate; + this.spanAttributeReplacements = spanAttributeReplacements; + } + + @Override + public CompletableResultCode export(Collection spans) { + if (spanAttributeReplacements.isEmpty()) { + return delegate.export(spans); + } + + List modifiedSpans = + spans.stream().map(this::doModify).collect(Collectors.toList()); + return delegate.export(modifiedSpans); + } + + private SpanData doModify(SpanData span) { + Attributes modifiedAttributes = buildModifiedAttributes(span); + return new ModifiedSpanData(span, modifiedAttributes); + } + + @NonNull + private Attributes buildModifiedAttributes(SpanData span) { + AttributesBuilder modifiedAttributes = Attributes.builder(); + span.getAttributes() + .forEach( + (key, attrValue) -> { + Function remapper = getRemapper(key); + if (remapper != null) { + attrValue = remapper.apply(attrValue); + } + if (attrValue != null) { + modifiedAttributes.put((AttributeKey) key, attrValue); + } + }); + return modifiedAttributes.build(); + } + + @Nullable + private Function getRemapper(AttributeKey key) { + return (Function) spanAttributeReplacements.get(key); + } + + @Override + public CompletableResultCode flush() { + return delegate.flush(); + } + + @Override + public CompletableResultCode shutdown() { + return delegate.shutdown(); + } +} diff --git a/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/export/FilteringSpanExporter.java b/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/export/FilteringSpanExporter.java new file mode 100644 index 000000000..c0744d653 --- /dev/null +++ b/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/export/FilteringSpanExporter.java @@ -0,0 +1,59 @@ +/* + * Copyright Splunk Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.opentelemetry.rum.internal.export; + +import io.opentelemetry.sdk.common.CompletableResultCode; +import io.opentelemetry.sdk.trace.data.SpanData; +import io.opentelemetry.sdk.trace.export.SpanExporter; +import java.util.Collection; +import java.util.List; +import java.util.function.Predicate; +import java.util.stream.Collectors; + +/** An exporter that will filter (not export) spans that fail a predicate. */ +public class FilteringSpanExporter implements SpanExporter { + + private final SpanExporter delegate; + + private final Predicate spanRejecter; + + public static FilteringSpanExporterBuilder builder(SpanExporter delegate) { + return new FilteringSpanExporterBuilder(delegate); + } + + FilteringSpanExporter(SpanExporter delegate, Predicate spanRejecter) { + this.delegate = delegate; + this.spanRejecter = spanRejecter; + } + + @Override + public CompletableResultCode export(Collection spans) { + List toExport = + spans.stream().filter(spanRejecter.negate()).collect(Collectors.toList()); + return delegate.export(toExport); + } + + @Override + public CompletableResultCode flush() { + return delegate.flush(); + } + + @Override + public CompletableResultCode shutdown() { + return delegate.shutdown(); + } +} diff --git a/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/export/FilteringSpanExporterBuilder.java b/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/export/FilteringSpanExporterBuilder.java new file mode 100644 index 000000000..f23a4154b --- /dev/null +++ b/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/export/FilteringSpanExporterBuilder.java @@ -0,0 +1,106 @@ +/* + * Copyright Splunk Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.opentelemetry.rum.internal.export; + +import io.opentelemetry.api.common.AttributeKey; +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.sdk.trace.data.SpanData; +import io.opentelemetry.sdk.trace.export.SpanExporter; +import java.util.Map; +import java.util.function.Predicate; + +public final class FilteringSpanExporterBuilder { + + private final SpanExporter delegate; + private Predicate predicate = x -> false; + + FilteringSpanExporterBuilder(SpanExporter spanExporter) { + this.delegate = spanExporter; + } + + /** + * Creates a SpanExporter that will not export any spans whose name matches the given name. All + * other spans will be exported by the delegate. + * + * @param name - Entire case sensitive span name to match for exclusion + * @return a SpanExporter + */ + public FilteringSpanExporterBuilder rejectSpansNamed(String name) { + return rejecting(span -> name.equals(span.getName())); + } + + /** + * Creates a SpanExporter that will not export any spans whose name matches the given predicate. + * All other spans will be exported by the delegate. + * + * @param spanNamePredicate - predicate to test the span name atainst + * @return a SpanExporter + */ + public FilteringSpanExporterBuilder rejectSpansNamed(Predicate spanNamePredicate) { + return rejecting(span -> spanNamePredicate.test(span.getName())); + } + + /** + * Creates a SpanExporter that will not export any spans whose name contains the given + * substring. All other spans will be exported by the delegate. + * + * @param substring - Substring go match within the span name + * @return a SpanExporter + */ + public FilteringSpanExporterBuilder rejectSpansWithNameContaining(String substring) { + return rejecting(span -> span.getName().contains(substring)); + } + + /** + * Creates a span exporter that will not export any spans whose SpanData matches the rejecting + * predicate. + * + * @param predicate A predicate that returns true when a span is to be rejected + * @return this + */ + public FilteringSpanExporterBuilder rejecting(Predicate predicate) { + this.predicate = this.predicate.or(predicate); + return this; + } + + public FilteringSpanExporterBuilder rejectSpansWithAttributesMatching( + Map, Predicate> attrRejection) { + if (attrRejection.isEmpty()) { + return this; + } + Predicate spanRejecter = + spanData -> { + Attributes attributes = spanData.getAttributes(); + return attrRejection.entrySet().stream() + .anyMatch( + e -> { + AttributeKey key = e.getKey(); + Predicate valuePredicate = + (Predicate) e.getValue(); + Object attributeValue = attributes.get(key); + return (attributeValue != null + && valuePredicate.test(attributeValue)); + }); + }; + this.predicate = this.predicate.or(spanRejecter); + return this; + } + + public SpanExporter build() { + return new FilteringSpanExporter(delegate, this.predicate); + } +} diff --git a/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/export/ModifiedSpanData.java b/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/export/ModifiedSpanData.java new file mode 100644 index 000000000..9340c2ea9 --- /dev/null +++ b/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/export/ModifiedSpanData.java @@ -0,0 +1,41 @@ +/* + * Copyright Splunk Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.opentelemetry.rum.internal.export; + +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.sdk.trace.data.DelegatingSpanData; +import io.opentelemetry.sdk.trace.data.SpanData; + +final class ModifiedSpanData extends DelegatingSpanData { + + private final Attributes modifiedAttributes; + + ModifiedSpanData(SpanData original, Attributes modifiedAttributes) { + super(original); + this.modifiedAttributes = modifiedAttributes; + } + + @Override + public Attributes getAttributes() { + return modifiedAttributes; + } + + @Override + public int getTotalAttributeCount() { + return modifiedAttributes.size(); + } +} diff --git a/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/export/SpanDataModifier.java b/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/export/SpanDataModifier.java new file mode 100644 index 000000000..fa714533d --- /dev/null +++ b/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/export/SpanDataModifier.java @@ -0,0 +1,153 @@ +/* + * Copyright Splunk Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.opentelemetry.rum.internal.export; + +import io.opentelemetry.api.common.AttributeKey; +import io.opentelemetry.sdk.trace.export.SpanExporter; +import java.util.HashMap; +import java.util.Map; +import java.util.function.Function; +import java.util.function.Predicate; + +/** + * A utility that can be used to create a SpanExporter that allows filtering and modification of + * span data before it is sent to Allows modification of span data before it is sent to a delegate + * exporter. Spans can be rejected entirely based on their name or attribute content, or their + * attributes may be modified. + */ +public final class SpanDataModifier { + + private final SpanExporter delegate; + private Predicate rejectSpanNamesPredicate = spanName -> false; + private final Map, Predicate> rejectSpanAttributesPredicates = + new HashMap<>(); + private final Map, Function> spanAttributeReplacements = new HashMap<>(); + + public static SpanDataModifier builder(SpanExporter delegate) { + return new SpanDataModifier(delegate); + } + + private SpanDataModifier(SpanExporter delegate) { + this.delegate = delegate; + } + + /** + * Remove matching spans from the exporter pipeline. + * + *

Spans with names that match the {@code spanNamePredicate} will not be exported. + * + * @param spanNamePredicate A function that returns true if a span with passed name should be + * rejected. + * @return {@code this}. + */ + public SpanDataModifier rejectSpansByName(Predicate spanNamePredicate) { + rejectSpanNamesPredicate = rejectSpanNamesPredicate.or(spanNamePredicate); + return this; + } + + /** + * Remove matching spans from the exporter pipeline. + * + *

Any span that contains an attribute with key {@code attributeKey} and value matching the + * {@code attributeValuePredicate} will not be exported. + * + * @param attributeKey An attribute key to match. + * @param attributeValuePredicate A function that returns true if a span containing an attribute + * with matching value should be rejected. + * @return {@code this}. + */ + public SpanDataModifier rejectSpansByAttributeValue( + AttributeKey attributeKey, Predicate attributeValuePredicate) { + + rejectSpanAttributesPredicates.compute( + attributeKey, + (k, oldValue) -> + oldValue == null + ? attributeValuePredicate + : ((Predicate) oldValue).or(attributeValuePredicate)); + return this; + } + + /** + * Modify span data before it enters the exporter pipeline. + * + *

Any attribute with key {@code attributeKey} and will be removed from the span before it is + * exported. + * + * @param attributeKey An attribute key to match. + * @return {@code this}. + */ + public SpanDataModifier removeSpanAttribute(AttributeKey attributeKey) { + return removeSpanAttribute(attributeKey, value -> true); + } + + /** + * Modify span data before it enters the exporter pipeline. + * + *

Any attribute with key {@code attributeKey} and value matching the {@code + * attributeValuePredicate} will be removed from the span before it is exported. + * + * @param attributeKey An attribute key to match. + * @param attributeValuePredicate A function that returns true if an attribute with matching + * value should be removed from the span. + * @return {@code this}. + */ + public SpanDataModifier removeSpanAttribute( + AttributeKey attributeKey, Predicate attributeValuePredicate) { + + return replaceSpanAttribute( + attributeKey, old -> attributeValuePredicate.test(old) ? null : old); + } + + /** + * Modify span data before it enters the exporter pipeline. + * + *

The value of any attribute with key {@code attributeKey} will be passed to the {@code + * attributeValueModifier} function. The value returned by the function will replace the + * original value. When the modifier function returns {@code null} the attribute will be removed + * from the span. + * + * @param attributeKey An attribute key to match. + * @param attributeValueModifier A function that receives the old attribute value and returns + * the new one. + * @return {@code this}. + */ + public SpanDataModifier replaceSpanAttribute( + AttributeKey attributeKey, Function attributeValueModifier) { + + spanAttributeReplacements.compute( + attributeKey, + (k, oldValue) -> + oldValue == null + ? attributeValueModifier + : ((Function) oldValue).andThen(attributeValueModifier)); + return this; + } + + public SpanExporter build() { + SpanExporter modifier = delegate; + if (!spanAttributeReplacements.isEmpty()) { + modifier = + new AttributeModifyingSpanExporter( + delegate, new HashMap<>(spanAttributeReplacements)); + } + return FilteringSpanExporter.builder(modifier) + .rejectSpansWithAttributesMatching(new HashMap<>(rejectSpanAttributesPredicates)) + .rejectSpansNamed(rejectSpanNamesPredicate) + .build(); + } +} diff --git a/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/instrumentation/ApplicationStateListener.java b/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/instrumentation/ApplicationStateListener.java new file mode 100644 index 000000000..cf30b877f --- /dev/null +++ b/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/instrumentation/ApplicationStateListener.java @@ -0,0 +1,35 @@ +/* + * Copyright Splunk Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.opentelemetry.rum.internal.instrumentation; + +/** + * Listener interface that is called whenever the instrumented application is brought to foreground + * from the background, or vice versa. + * + *

This class is internal and is hence not for public use. Its APIs are unstable and can change + * at any time. + */ +public interface ApplicationStateListener { + + /** + * Called whenever the application is brought to the foreground (i.e. first activity starts). + */ + void onApplicationForegrounded(); + + /** Called whenever the application is brought to the background (i.e. last activity stops). */ + void onApplicationBackgrounded(); +} diff --git a/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/instrumentation/InstrumentedApplication.java b/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/instrumentation/InstrumentedApplication.java new file mode 100644 index 000000000..f853bd050 --- /dev/null +++ b/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/instrumentation/InstrumentedApplication.java @@ -0,0 +1,49 @@ +/* + * Copyright Splunk Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.opentelemetry.rum.internal.instrumentation; + +import android.app.Application; +import io.opentelemetry.rum.internal.OpenTelemetryRum; +import io.opentelemetry.sdk.OpenTelemetrySdk; + +/** + * Provides access to the {@linkplain OpenTelemetrySdk OpenTelemetry SDK}, the instrumented {@link + * Application}, allows registering {@linkplain ApplicationStateListener listeners}. + * + *

This class is internal and is hence not for public use. Its APIs are unstable and can change + * at any time. + */ +public interface InstrumentedApplication { + + /** Returns the instrumented {@link Application}. */ + Application getApplication(); + + /** + * Returns the {@link OpenTelemetrySdk} that is a part of the constructed {@link + * OpenTelemetryRum}. + */ + OpenTelemetrySdk getOpenTelemetrySdk(); + + /** + * Registers the passed {@link ApplicationStateListener} - from now on it will be called + * whenever the application is moved from background to foreground, and vice versa. + * + *

Users of this method should take care to avoid passing the same listener instance multiple + * times; duplicates are not trimmed. + */ + void registerApplicationStateListener(ApplicationStateListener listener); +} diff --git a/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/instrumentation/RumScreenName.java b/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/instrumentation/RumScreenName.java new file mode 100644 index 000000000..97573888f --- /dev/null +++ b/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/instrumentation/RumScreenName.java @@ -0,0 +1,33 @@ +/* + * Copyright Splunk Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.opentelemetry.rum.internal.instrumentation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * This annotation can be used to customize the {@code screen.name} attribute for an instrumented + * Fragment or Activity. + */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +public @interface RumScreenName { + /** Return the customized screen name. */ + String value(); +} diff --git a/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/instrumentation/ScreenNameExtractor.java b/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/instrumentation/ScreenNameExtractor.java new file mode 100644 index 000000000..edceeb900 --- /dev/null +++ b/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/instrumentation/ScreenNameExtractor.java @@ -0,0 +1,50 @@ +/* + * Copyright Splunk Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.opentelemetry.rum.internal.instrumentation; + +import android.app.Activity; +import androidx.annotation.Nullable; +import androidx.fragment.app.Fragment; + +public interface ScreenNameExtractor { + + @Nullable + String extract(Activity activity); + + @Nullable + String extract(Fragment fragment); + + ScreenNameExtractor DEFAULT = + new ScreenNameExtractor() { + @Nullable + @Override + public String extract(Activity activity) { + return useAnnotationOrClassName(activity.getClass()); + } + + @Nullable + @Override + public String extract(Fragment fragment) { + return useAnnotationOrClassName(fragment.getClass()); + } + + private String useAnnotationOrClassName(Class clazz) { + RumScreenName rumScreenName = clazz.getAnnotation(RumScreenName.class); + return rumScreenName == null ? clazz.getSimpleName() : rumScreenName.value(); + } + }; +} diff --git a/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/instrumentation/activity/ActivityCallbacks.java b/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/instrumentation/activity/ActivityCallbacks.java new file mode 100644 index 000000000..55b1ed25b --- /dev/null +++ b/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/instrumentation/activity/ActivityCallbacks.java @@ -0,0 +1,126 @@ +/* + * Copyright Splunk Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.opentelemetry.rum.internal.instrumentation.activity; + +import android.app.Activity; +import android.os.Bundle; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import io.opentelemetry.rum.internal.DefaultingActivityLifecycleCallbacks; + +public class ActivityCallbacks implements DefaultingActivityLifecycleCallbacks { + + private final ActivityTracerCache tracers; + + public ActivityCallbacks(ActivityTracerCache tracers) { + this.tracers = tracers; + } + + @Override + public void onActivityPreCreated( + @NonNull Activity activity, @Nullable Bundle savedInstanceState) { + tracers.startActivityCreation(activity).addEvent("activityPreCreated"); + } + + @Override + public void onActivityCreated(@NonNull Activity activity, @Nullable Bundle savedInstanceState) { + tracers.addEvent(activity, "activityCreated"); + } + + @Override + public void onActivityPostCreated( + @NonNull Activity activity, @Nullable Bundle savedInstanceState) { + tracers.addEvent(activity, "activityPostCreated"); + } + + @Override + public void onActivityPreStarted(@NonNull Activity activity) { + tracers.initiateRestartSpanIfNecessary(activity).addEvent("activityPreStarted"); + } + + @Override + public void onActivityStarted(@NonNull Activity activity) { + tracers.addEvent(activity, "activityStarted"); + } + + @Override + public void onActivityPostStarted(@NonNull Activity activity) { + tracers.addEvent(activity, "activityPostStarted"); + } + + @Override + public void onActivityPreResumed(@NonNull Activity activity) { + tracers.startSpanIfNoneInProgress(activity, "Resumed").addEvent("activityPreResumed"); + } + + @Override + public void onActivityResumed(@NonNull Activity activity) { + tracers.addEvent(activity, "activityResumed"); + } + + @Override + public void onActivityPostResumed(@NonNull Activity activity) { + tracers.addEvent(activity, "activityPostResumed") + .addPreviousScreenAttribute() + .endSpanForActivityResumed(); + } + + @Override + public void onActivityPrePaused(@NonNull Activity activity) { + tracers.startSpanIfNoneInProgress(activity, "Paused").addEvent("activityPrePaused"); + } + + @Override + public void onActivityPaused(@NonNull Activity activity) { + tracers.addEvent(activity, "activityPaused"); + } + + @Override + public void onActivityPostPaused(@NonNull Activity activity) { + tracers.addEvent(activity, "activityPostPaused").endActiveSpan(); + } + + @Override + public void onActivityPreStopped(@NonNull Activity activity) { + tracers.startSpanIfNoneInProgress(activity, "Stopped").addEvent("activityPreStopped"); + } + + @Override + public void onActivityStopped(@NonNull Activity activity) { + tracers.addEvent(activity, "activityStopped"); + } + + @Override + public void onActivityPostStopped(@NonNull Activity activity) { + tracers.addEvent(activity, "activityPostStopped").endActiveSpan(); + } + + @Override + public void onActivityPreDestroyed(@NonNull Activity activity) { + tracers.startSpanIfNoneInProgress(activity, "Destroyed").addEvent("activityPreDestroyed"); + } + + @Override + public void onActivityDestroyed(@NonNull Activity activity) { + tracers.addEvent(activity, "activityDestroyed"); + } + + @Override + public void onActivityPostDestroyed(@NonNull Activity activity) { + tracers.addEvent(activity, "activityPostDestroyed").endActiveSpan(); + } +} diff --git a/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/instrumentation/activity/ActivityTracer.java b/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/instrumentation/activity/ActivityTracer.java new file mode 100644 index 000000000..6dd307ba4 --- /dev/null +++ b/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/instrumentation/activity/ActivityTracer.java @@ -0,0 +1,207 @@ +/* + * Copyright Splunk Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.opentelemetry.rum.internal.instrumentation.activity; + +import static io.opentelemetry.rum.internal.RumConstants.APP_START_SPAN_NAME; +import static io.opentelemetry.rum.internal.RumConstants.SCREEN_NAME_KEY; +import static io.opentelemetry.rum.internal.RumConstants.START_TYPE_KEY; + +import android.app.Activity; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import io.opentelemetry.api.common.AttributeKey; +import io.opentelemetry.api.trace.Span; +import io.opentelemetry.api.trace.SpanBuilder; +import io.opentelemetry.api.trace.Tracer; +import io.opentelemetry.context.Context; +import io.opentelemetry.rum.internal.instrumentation.startup.AppStartupTimer; +import io.opentelemetry.rum.internal.util.ActiveSpan; +import java.util.concurrent.atomic.AtomicReference; + +public class ActivityTracer { + static final AttributeKey ACTIVITY_NAME_KEY = AttributeKey.stringKey("activityName"); + + private final AtomicReference initialAppActivity; + private final Tracer tracer; + private final String activityName; + private final String screenName; + private final AppStartupTimer appStartupTimer; + private final ActiveSpan activeSpan; + + private ActivityTracer(Builder builder) { + this.initialAppActivity = builder.initialAppActivity; + this.tracer = builder.tracer; + this.activityName = builder.getActivityName(); + this.screenName = builder.screenName; + this.appStartupTimer = builder.appStartupTimer; + this.activeSpan = builder.activeSpan; + } + + ActivityTracer startSpanIfNoneInProgress(String spanName) { + if (activeSpan.spanInProgress()) { + return this; + } + activeSpan.startSpan(() -> createSpan(spanName)); + return this; + } + + ActivityTracer startActivityCreation() { + activeSpan.startSpan(this::makeCreationSpan); + return this; + } + + private Span makeCreationSpan() { + // If the application has never loaded an activity, or this is the initial activity getting + // re-created, + // we name this span specially to show that it's the application starting up. Otherwise, use + // the activity class name as the base of the span name. + boolean isColdStart = initialAppActivity.get() == null; + if (isColdStart) { + return createSpanWithParent("Created", appStartupTimer.getStartupSpan()); + } + if (activityName.equals(initialAppActivity.get())) { + return createAppStartSpan("warm"); + } + return createSpan("Created"); + } + + ActivityTracer initiateRestartSpanIfNecessary(boolean multiActivityApp) { + if (activeSpan.spanInProgress()) { + return this; + } + activeSpan.startSpan(() -> makeRestartSpan(multiActivityApp)); + return this; + } + + @NonNull + private Span makeRestartSpan(boolean multiActivityApp) { + // restarting the first activity is a "hot" AppStart + // Note: in a multi-activity application, navigating back to the first activity can trigger + // this, so it would not be ideal to call it an AppStart. + if (!multiActivityApp && activityName.equals(initialAppActivity.get())) { + return createAppStartSpan("hot"); + } + return createSpan("Restarted"); + } + + private Span createAppStartSpan(String startType) { + Span span = createSpan(APP_START_SPAN_NAME); + span.setAttribute(START_TYPE_KEY, startType); + return span; + } + + private Span createSpan(String spanName) { + return createSpanWithParent(spanName, null); + } + + private Span createSpanWithParent(String spanName, @Nullable Span parentSpan) { + final SpanBuilder spanBuilder = + tracer.spanBuilder(spanName).setAttribute(ACTIVITY_NAME_KEY, activityName); + if (parentSpan != null) { + spanBuilder.setParent(parentSpan.storeInContext(Context.current())); + } + Span span = spanBuilder.startSpan(); + // do this after the span is started, so we can override the default screen.name set by the + // RumAttributeAppender. + span.setAttribute(SCREEN_NAME_KEY, screenName); + return span; + } + + public void endSpanForActivityResumed() { + if (initialAppActivity.get() == null) { + initialAppActivity.set(activityName); + } + endActiveSpan(); + } + + public void endActiveSpan() { + // If we happen to be in app startup, make sure this ends it. It's harmless if we're already + // out of the startup phase. + appStartupTimer.end(); + activeSpan.endActiveSpan(); + } + + public ActivityTracer addPreviousScreenAttribute() { + activeSpan.addPreviousScreenAttribute(activityName); + return this; + } + + public ActivityTracer addEvent(String eventName) { + activeSpan.addEvent(eventName); + return this; + } + + public static Builder builder(Activity activity) { + return new Builder(activity); + } + + static class Builder { + private final Activity activity; + public String screenName; + private AtomicReference initialAppActivity = new AtomicReference<>(); + private Tracer tracer; + private AppStartupTimer appStartupTimer; + private ActiveSpan activeSpan; + + public Builder(Activity activity) { + this.activity = activity; + } + + public Builder setVisibleScreenTracker(VisibleScreenTracker visibleScreenTracker) { + this.activeSpan = new ActiveSpan(visibleScreenTracker::getPreviouslyVisibleScreen); + return this; + } + + public Builder setInitialAppActivity(String activityName) { + initialAppActivity.set(activityName); + return this; + } + + public Builder setInitialAppActivity(AtomicReference initialAppActivity) { + this.initialAppActivity = initialAppActivity; + return this; + } + + public Builder setTracer(Tracer tracer) { + this.tracer = tracer; + return this; + } + + public Builder setAppStartupTimer(AppStartupTimer appStartupTimer) { + this.appStartupTimer = appStartupTimer; + return this; + } + + public Builder setActiveSpan(ActiveSpan activeSpan) { + this.activeSpan = activeSpan; + return this; + } + + private String getActivityName() { + return activity.getClass().getSimpleName(); + } + + public ActivityTracer build() { + return new ActivityTracer(this); + } + + public Builder setScreenName(String screenName) { + this.screenName = screenName; + return this; + } + } +} diff --git a/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/instrumentation/activity/ActivityTracerCache.java b/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/instrumentation/activity/ActivityTracerCache.java new file mode 100644 index 000000000..372e42410 --- /dev/null +++ b/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/instrumentation/activity/ActivityTracerCache.java @@ -0,0 +1,101 @@ +/* + * Copyright Splunk Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.opentelemetry.rum.internal.instrumentation.activity; + +import android.app.Activity; +import androidx.annotation.VisibleForTesting; +import io.opentelemetry.api.trace.Tracer; +import io.opentelemetry.rum.internal.instrumentation.ScreenNameExtractor; +import io.opentelemetry.rum.internal.instrumentation.startup.AppStartupTimer; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Function; + +/** + * Encapsulates the fact that we have an ActivityTracer instance per Activity class, and provides + * convenience methods for adding events and starting spans. + */ +public class ActivityTracerCache { + + private final Map tracersByActivityClassName = new HashMap<>(); + + private final Function tracerFactory; + + public ActivityTracerCache( + Tracer tracer, + VisibleScreenTracker visibleScreenTracker, + AppStartupTimer startupTimer, + ScreenNameExtractor screenNameExtractor) { + this( + tracer, + visibleScreenTracker, + new AtomicReference<>(), + startupTimer, + screenNameExtractor); + } + + @VisibleForTesting + ActivityTracerCache( + Tracer tracer, + VisibleScreenTracker visibleScreenTracker, + AtomicReference initialAppActivity, + AppStartupTimer startupTimer, + ScreenNameExtractor screenNameExtractor) { + this( + activity -> + ActivityTracer.builder(activity) + .setScreenName(screenNameExtractor.extract(activity)) + .setInitialAppActivity(initialAppActivity) + .setTracer(tracer) + .setAppStartupTimer(startupTimer) + .setVisibleScreenTracker(visibleScreenTracker) + .build()); + } + + @VisibleForTesting + ActivityTracerCache(Function tracerFactory) { + this.tracerFactory = tracerFactory; + } + + public ActivityTracer addEvent(Activity activity, String eventName) { + return getTracer(activity).addEvent(eventName); + } + + public ActivityTracer startSpanIfNoneInProgress(Activity activity, String spanName) { + return getTracer(activity).startSpanIfNoneInProgress(spanName); + } + + public ActivityTracer initiateRestartSpanIfNecessary(Activity activity) { + boolean isMultiActivityApp = tracersByActivityClassName.size() > 1; + return getTracer(activity).initiateRestartSpanIfNecessary(isMultiActivityApp); + } + + public ActivityTracer startActivityCreation(Activity activity) { + return getTracer(activity).startActivityCreation(); + } + + private ActivityTracer getTracer(Activity activity) { + ActivityTracer activityTracer = + tracersByActivityClassName.get(activity.getClass().getName()); + if (activityTracer == null) { + activityTracer = tracerFactory.apply(activity); + tracersByActivityClassName.put(activity.getClass().getName(), activityTracer); + } + return activityTracer; + } +} diff --git a/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/instrumentation/activity/Pre29ActivityCallbacks.java b/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/instrumentation/activity/Pre29ActivityCallbacks.java new file mode 100644 index 000000000..60f033bc1 --- /dev/null +++ b/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/instrumentation/activity/Pre29ActivityCallbacks.java @@ -0,0 +1,70 @@ +/* + * Copyright Splunk Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.opentelemetry.rum.internal.instrumentation.activity; + +import android.app.Activity; +import android.os.Bundle; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import io.opentelemetry.rum.internal.DefaultingActivityLifecycleCallbacks; + +public class Pre29ActivityCallbacks implements DefaultingActivityLifecycleCallbacks { + private final ActivityTracerCache tracers; + + public Pre29ActivityCallbacks(ActivityTracerCache tracers) { + this.tracers = tracers; + } + + @Override + public void onActivityCreated(@NonNull Activity activity, @Nullable Bundle savedInstanceState) { + tracers.startActivityCreation(activity).addEvent("activityCreated"); + } + + @Override + public void onActivityStarted(@NonNull Activity activity) { + tracers.initiateRestartSpanIfNecessary(activity).addEvent("activityStarted"); + } + + @Override + public void onActivityResumed(@NonNull Activity activity) { + tracers.startSpanIfNoneInProgress(activity, "Resumed") + .addEvent("activityResumed") + .addPreviousScreenAttribute() + .endSpanForActivityResumed(); + } + + @Override + public void onActivityPaused(@NonNull Activity activity) { + tracers.startSpanIfNoneInProgress(activity, "Paused") + .addEvent("activityPaused") + .endActiveSpan(); + } + + @Override + public void onActivityStopped(@NonNull Activity activity) { + tracers.startSpanIfNoneInProgress(activity, "Stopped") + .addEvent("activityStopped") + .endActiveSpan(); + } + + @Override + public void onActivityDestroyed(@NonNull Activity activity) { + tracers.startSpanIfNoneInProgress(activity, "Destroyed") + .addEvent("activityDestroyed") + .endActiveSpan(); + } +} diff --git a/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/instrumentation/activity/Pre29VisibleScreenLifecycleBinding.java b/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/instrumentation/activity/Pre29VisibleScreenLifecycleBinding.java new file mode 100644 index 000000000..4b8c0c4c4 --- /dev/null +++ b/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/instrumentation/activity/Pre29VisibleScreenLifecycleBinding.java @@ -0,0 +1,45 @@ +/* + * Copyright Splunk Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.opentelemetry.rum.internal.instrumentation.activity; + +import android.app.Activity; +import androidx.annotation.NonNull; +import io.opentelemetry.rum.internal.DefaultingActivityLifecycleCallbacks; + +/** + * An ActivityLifecycleCallbacks that is responsible for telling the VisibleScreenTracker when an + * activity has been resumed and when an activity has been paused. It's just a glue class designed + * for API level before 29. + */ +public class Pre29VisibleScreenLifecycleBinding implements DefaultingActivityLifecycleCallbacks { + + private final VisibleScreenTracker visibleScreenTracker; + + public Pre29VisibleScreenLifecycleBinding(VisibleScreenTracker visibleScreenTracker) { + this.visibleScreenTracker = visibleScreenTracker; + } + + @Override + public void onActivityResumed(@NonNull Activity activity) { + visibleScreenTracker.activityResumed(activity); + } + + @Override + public void onActivityPaused(@NonNull Activity activity) { + visibleScreenTracker.activityPaused(activity); + } +} diff --git a/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/instrumentation/activity/RumFragmentActivityRegisterer.java b/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/instrumentation/activity/RumFragmentActivityRegisterer.java new file mode 100644 index 000000000..b4f104506 --- /dev/null +++ b/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/instrumentation/activity/RumFragmentActivityRegisterer.java @@ -0,0 +1,68 @@ +/* + * Copyright Splunk Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.opentelemetry.rum.internal.instrumentation.activity; + +import android.app.Activity; +import android.app.Application; +import android.os.Bundle; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.fragment.app.FragmentActivity; +import androidx.fragment.app.FragmentManager; +import io.opentelemetry.rum.internal.DefaultingActivityLifecycleCallbacks; + +/** + * Registers the RumFragmentLifecycleCallbacks when an activity is created. There are just 2 factory + * methods here, one for API level before 29, and one for the rest. + */ +public class RumFragmentActivityRegisterer { + + private RumFragmentActivityRegisterer() {} + + public static Application.ActivityLifecycleCallbacks create( + FragmentManager.FragmentLifecycleCallbacks fragmentCallbacks) { + return new DefaultingActivityLifecycleCallbacks() { + @Override + public void onActivityPreCreated( + @NonNull Activity activity, @Nullable Bundle savedInstanceState) { + if (activity instanceof FragmentActivity) { + register((FragmentActivity) activity, fragmentCallbacks); + } + } + }; + } + + public static Application.ActivityLifecycleCallbacks createPre29( + FragmentManager.FragmentLifecycleCallbacks fragmentCallbacks) { + return new DefaultingActivityLifecycleCallbacks() { + @Override + public void onActivityCreated( + @NonNull Activity activity, @Nullable Bundle savedInstanceState) { + if (activity instanceof FragmentActivity) { + register((FragmentActivity) activity, fragmentCallbacks); + } + } + }; + } + + private static void register( + FragmentActivity activity, + FragmentManager.FragmentLifecycleCallbacks fragmentCallbacks) { + FragmentManager fragmentManager = activity.getSupportFragmentManager(); + fragmentManager.registerFragmentLifecycleCallbacks(fragmentCallbacks, true); + } +} diff --git a/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/instrumentation/activity/VisibleScreenLifecycleBinding.java b/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/instrumentation/activity/VisibleScreenLifecycleBinding.java new file mode 100644 index 000000000..bbfa89301 --- /dev/null +++ b/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/instrumentation/activity/VisibleScreenLifecycleBinding.java @@ -0,0 +1,44 @@ +/* + * Copyright Splunk Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.opentelemetry.rum.internal.instrumentation.activity; + +import android.app.Activity; +import androidx.annotation.NonNull; +import io.opentelemetry.rum.internal.DefaultingActivityLifecycleCallbacks; + +/** + * An ActivityLifecycleCallbacks that is responsible for telling the VisibleScreenTracker when an + * activity has been resumed and when an activity has been paused. It's just a glue class. + */ +public class VisibleScreenLifecycleBinding implements DefaultingActivityLifecycleCallbacks { + + private final VisibleScreenTracker visibleScreenTracker; + + public VisibleScreenLifecycleBinding(VisibleScreenTracker visibleScreenTracker) { + this.visibleScreenTracker = visibleScreenTracker; + } + + @Override + public void onActivityPostResumed(@NonNull Activity activity) { + visibleScreenTracker.activityResumed(activity); + } + + @Override + public void onActivityPrePaused(@NonNull Activity activity) { + visibleScreenTracker.activityPaused(activity); + } +} diff --git a/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/instrumentation/activity/VisibleScreenTracker.java b/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/instrumentation/activity/VisibleScreenTracker.java new file mode 100644 index 000000000..87fa24d30 --- /dev/null +++ b/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/instrumentation/activity/VisibleScreenTracker.java @@ -0,0 +1,98 @@ +/* + * Copyright Splunk Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.opentelemetry.rum.internal.instrumentation.activity; + +import android.app.Activity; +import androidx.annotation.Nullable; +import androidx.fragment.app.DialogFragment; +import androidx.fragment.app.Fragment; +import androidx.navigation.fragment.NavHostFragment; +import java.util.concurrent.atomic.AtomicReference; + +/** + * Wherein we do our best to figure out what "screen" is visible and what was the previously visible + * "screen". + * + *

In general, we favor using the last fragment that was resumed, but fall back to the last + * resumed activity in case we don't have a fragment. + * + *

We always ignore NavHostFragment instances since they aren't ever visible to the user. + * + *

We have to treat DialogFragments slightly differently since they don't replace the launching + * screen, and the launching screen never leaves visibility. + */ +public class VisibleScreenTracker { + private final AtomicReference lastResumedActivity = new AtomicReference<>(); + private final AtomicReference previouslyLastResumedActivity = new AtomicReference<>(); + private final AtomicReference lastResumedFragment = new AtomicReference<>(); + private final AtomicReference previouslyLastResumedFragment = new AtomicReference<>(); + + @Nullable + public String getPreviouslyVisibleScreen() { + String previouslyLastFragment = previouslyLastResumedFragment.get(); + if (previouslyLastFragment != null) { + return previouslyLastFragment; + } + return previouslyLastResumedActivity.get(); + } + + public String getCurrentlyVisibleScreen() { + String lastFragment = lastResumedFragment.get(); + if (lastFragment != null) { + return lastFragment; + } + String lastActivity = lastResumedActivity.get(); + if (lastActivity != null) { + return lastActivity; + } + return "unknown"; + } + + public void activityResumed(Activity activity) { + lastResumedActivity.set(activity.getClass().getSimpleName()); + } + + public void activityPaused(Activity activity) { + previouslyLastResumedActivity.set(activity.getClass().getSimpleName()); + lastResumedActivity.compareAndSet(activity.getClass().getSimpleName(), null); + } + + public void fragmentResumed(Fragment fragment) { + // skip the NavHostFragment since it's never really "visible" by itself. + if (fragment instanceof NavHostFragment) { + return; + } + + if (fragment instanceof DialogFragment) { + previouslyLastResumedFragment.set(lastResumedFragment.get()); + } + lastResumedFragment.set(fragment.getClass().getSimpleName()); + } + + public void fragmentPaused(Fragment fragment) { + // skip the NavHostFragment since it's never really "visible" by itself. + if (fragment instanceof NavHostFragment) { + return; + } + if (fragment instanceof DialogFragment) { + lastResumedFragment.set(previouslyLastResumedFragment.get()); + } else { + lastResumedFragment.compareAndSet(fragment.getClass().getSimpleName(), null); + } + previouslyLastResumedFragment.set(fragment.getClass().getSimpleName()); + } +} diff --git a/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/instrumentation/anr/AnrDetector.java b/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/instrumentation/anr/AnrDetector.java new file mode 100644 index 000000000..469e48f3b --- /dev/null +++ b/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/instrumentation/anr/AnrDetector.java @@ -0,0 +1,85 @@ +/* + * Copyright Splunk Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.opentelemetry.rum.internal.instrumentation.anr; + +import android.os.Handler; +import android.os.Looper; +import io.opentelemetry.api.OpenTelemetry; +import io.opentelemetry.api.trace.StatusCode; +import io.opentelemetry.instrumentation.api.instrumenter.AttributesExtractor; +import io.opentelemetry.instrumentation.api.instrumenter.Instrumenter; +import io.opentelemetry.rum.internal.instrumentation.InstrumentedApplication; +import java.util.List; +import java.util.concurrent.ScheduledExecutorService; + +/** Entrypoint for installing the ANR (application not responding) detection instrumentation. */ +public final class AnrDetector { + + /** Returns a new {@link AnrDetector} with the default settings. */ + public static AnrDetector create() { + return builder().build(); + } + + /** Returns a new {@link AnrDetectorBuilder}. */ + public static AnrDetectorBuilder builder() { + return new AnrDetectorBuilder(); + } + + private final List> additionalExtractors; + private final Looper mainLooper; + private final ScheduledExecutorService scheduler; + + AnrDetector(AnrDetectorBuilder builder) { + this.additionalExtractors = builder.additionalExtractors; + this.mainLooper = builder.mainLooper; + this.scheduler = builder.scheduler; + } + + /** + * Installs the ANR detection instrumentation on the given {@link InstrumentedApplication}. + * + *

When the main thread is unresponsive for 5 seconds or more, an event including the main + * thread's stack trace will be reported to the RUM system. + */ + public void installOn(InstrumentedApplication instrumentedApplication) { + Handler uiHandler = new Handler(mainLooper); + AnrWatcher anrWatcher = + new AnrWatcher( + uiHandler, + mainLooper.getThread(), + buildAnrInstrumenter(instrumentedApplication.getOpenTelemetrySdk())); + + AnrDetectorToggler listener = new AnrDetectorToggler(anrWatcher, scheduler); + // call it manually the first time to enable the ANR detection + listener.onApplicationForegrounded(); + + instrumentedApplication.registerApplicationStateListener(listener); + } + + private Instrumenter buildAnrInstrumenter( + OpenTelemetry openTelemetry) { + return Instrumenter.builder( + openTelemetry, "io.opentelemetry.anr", stackTrace -> "ANR") + // it's always an error + .setSpanStatusExtractor( + (spanStatusBuilder, stackTrace, unused, error) -> + spanStatusBuilder.setStatus(StatusCode.ERROR)) + .addAttributesExtractor(new StackTraceFormatter()) + .addAttributesExtractors(additionalExtractors) + .buildInstrumenter(); + } +} diff --git a/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/instrumentation/anr/AnrDetectorBuilder.java b/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/instrumentation/anr/AnrDetectorBuilder.java new file mode 100644 index 000000000..7cd00e95e --- /dev/null +++ b/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/instrumentation/anr/AnrDetectorBuilder.java @@ -0,0 +1,59 @@ +/* + * Copyright Splunk Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.opentelemetry.rum.internal.instrumentation.anr; + +import android.os.Looper; +import io.opentelemetry.instrumentation.api.instrumenter.AttributesExtractor; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; + +/** A builder of {@link AnrDetector}. */ +public final class AnrDetectorBuilder { + + AnrDetectorBuilder() {} + + final List> additionalExtractors = + new ArrayList<>(); + Looper mainLooper = Looper.getMainLooper(); + ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1); + + /** Adds an {@link AttributesExtractor} that will extract additional attributes. */ + public AnrDetectorBuilder addAttributesExtractor( + AttributesExtractor extractor) { + additionalExtractors.add(extractor); + return this; + } + + /** Sets a custom {@link Looper} to run on. Useful for testing. */ + public AnrDetectorBuilder setMainLooper(Looper looper) { + mainLooper = looper; + return this; + } + + // visible for tests + AnrDetectorBuilder setScheduler(ScheduledExecutorService scheduler) { + this.scheduler = scheduler; + return this; + } + + /** Returns a new {@link AnrDetector} with the settings of this {@link AnrDetectorBuilder}. */ + public AnrDetector build() { + return new AnrDetector(this); + } +} diff --git a/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/instrumentation/anr/AnrDetectorToggler.java b/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/instrumentation/anr/AnrDetectorToggler.java new file mode 100644 index 000000000..f28d012cc --- /dev/null +++ b/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/instrumentation/anr/AnrDetectorToggler.java @@ -0,0 +1,51 @@ +/* + * Copyright Splunk Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.opentelemetry.rum.internal.instrumentation.anr; + +import androidx.annotation.Nullable; +import io.opentelemetry.rum.internal.instrumentation.ApplicationStateListener; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; + +final class AnrDetectorToggler implements ApplicationStateListener { + + private final Runnable anrWatcher; + private final ScheduledExecutorService anrScheduler; + + @Nullable private ScheduledFuture future; + + AnrDetectorToggler(Runnable anrWatcher, ScheduledExecutorService anrScheduler) { + this.anrWatcher = anrWatcher; + this.anrScheduler = anrScheduler; + } + + @Override + public void onApplicationForegrounded() { + if (future == null) { + future = anrScheduler.scheduleAtFixedRate(anrWatcher, 1, 1, TimeUnit.SECONDS); + } + } + + @Override + public void onApplicationBackgrounded() { + if (future != null) { + future.cancel(true); + future = null; + } + } +} diff --git a/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/instrumentation/anr/AnrWatcher.java b/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/instrumentation/anr/AnrWatcher.java new file mode 100644 index 000000000..d4abf0ca2 --- /dev/null +++ b/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/instrumentation/anr/AnrWatcher.java @@ -0,0 +1,70 @@ +/* + * Copyright Splunk Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.opentelemetry.rum.internal.instrumentation.anr; + +import android.os.Handler; +import io.opentelemetry.context.Context; +import io.opentelemetry.instrumentation.api.instrumenter.Instrumenter; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +final class AnrWatcher implements Runnable { + private final AtomicInteger anrCounter = new AtomicInteger(); + private final Handler uiHandler; + private final Thread mainThread; + private final Instrumenter instrumenter; + + AnrWatcher( + Handler uiHandler, + Thread mainThread, + Instrumenter instrumenter) { + this.uiHandler = uiHandler; + this.mainThread = mainThread; + this.instrumenter = instrumenter; + } + + @Override + public void run() { + CountDownLatch response = new CountDownLatch(1); + if (!uiHandler.post(response::countDown)) { + // the main thread is probably shutting down. ignore and return. + return; + } + boolean success; + try { + success = response.await(1, TimeUnit.SECONDS); + } catch (InterruptedException e) { + return; + } + if (success) { + anrCounter.set(0); + return; + } + if (anrCounter.incrementAndGet() >= 5) { + StackTraceElement[] stackTrace = mainThread.getStackTrace(); + recordAnr(stackTrace); + // only report once per 5s. + anrCounter.set(0); + } + } + + private void recordAnr(StackTraceElement[] stackTrace) { + Context context = instrumenter.start(Context.current(), stackTrace); + instrumenter.end(context, stackTrace, null, null); + } +} diff --git a/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/instrumentation/anr/StackTraceFormatter.java b/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/instrumentation/anr/StackTraceFormatter.java new file mode 100644 index 000000000..dfd7d9457 --- /dev/null +++ b/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/instrumentation/anr/StackTraceFormatter.java @@ -0,0 +1,43 @@ +/* + * Copyright Splunk Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.opentelemetry.rum.internal.instrumentation.anr; + +import io.opentelemetry.api.common.AttributesBuilder; +import io.opentelemetry.context.Context; +import io.opentelemetry.instrumentation.api.instrumenter.AttributesExtractor; +import io.opentelemetry.semconv.trace.attributes.SemanticAttributes; + +final class StackTraceFormatter implements AttributesExtractor { + + @Override + public void onStart( + AttributesBuilder attributes, Context parentContext, StackTraceElement[] stackTrace) { + StringBuilder stackTraceString = new StringBuilder(); + for (StackTraceElement stackTraceElement : stackTrace) { + stackTraceString.append(stackTraceElement).append("\n"); + } + attributes.put(SemanticAttributes.EXCEPTION_STACKTRACE, stackTraceString.toString()); + } + + @Override + public void onEnd( + AttributesBuilder attributes, + Context context, + StackTraceElement[] stackTraceElements, + Void unused, + Throwable error) {} +} diff --git a/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/instrumentation/crash/CrashDetails.java b/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/instrumentation/crash/CrashDetails.java new file mode 100644 index 000000000..98d7446e9 --- /dev/null +++ b/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/instrumentation/crash/CrashDetails.java @@ -0,0 +1,66 @@ +/* + * Copyright Splunk Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.opentelemetry.rum.internal.instrumentation.crash; + +/** A class representing all the details of an application crash. */ +public final class CrashDetails { + + /** Creates a new {@link CrashDetails} instance. */ + public static CrashDetails create(Thread thread, Throwable cause) { + return new CrashDetails(thread, cause); + } + + private final Thread thread; + private final Throwable cause; + + CrashDetails(Thread thread, Throwable cause) { + this.thread = thread; + this.cause = cause; + } + + /** Returns the thread that crashed. */ + public Thread getThread() { + return thread; + } + + /** Returns the cause of the crash. */ + public Throwable getCause() { + return cause; + } + + String spanName() { + return getCause().getClass().getSimpleName(); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + CrashDetails that = (CrashDetails) o; + + if (!thread.equals(that.thread)) return false; + return cause.equals(that.cause); + } + + @Override + public int hashCode() { + int result = thread.hashCode(); + result = 31 * result + cause.hashCode(); + return result; + } +} diff --git a/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/instrumentation/crash/CrashDetailsAttributesExtractor.java b/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/instrumentation/crash/CrashDetailsAttributesExtractor.java new file mode 100644 index 000000000..d2eb97af8 --- /dev/null +++ b/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/instrumentation/crash/CrashDetailsAttributesExtractor.java @@ -0,0 +1,41 @@ +/* + * Copyright Splunk Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.opentelemetry.rum.internal.instrumentation.crash; + +import io.opentelemetry.api.common.AttributesBuilder; +import io.opentelemetry.context.Context; +import io.opentelemetry.instrumentation.api.instrumenter.AttributesExtractor; +import io.opentelemetry.semconv.trace.attributes.SemanticAttributes; + +final class CrashDetailsAttributesExtractor implements AttributesExtractor { + + @Override + public void onStart( + AttributesBuilder attributes, Context parentContext, CrashDetails crashDetails) { + attributes.put(SemanticAttributes.THREAD_ID, crashDetails.getThread().getId()); + attributes.put(SemanticAttributes.THREAD_NAME, crashDetails.getThread().getName()); + attributes.put(SemanticAttributes.EXCEPTION_ESCAPED, true); + } + + @Override + public void onEnd( + AttributesBuilder attributes, + Context context, + CrashDetails crashDetails, + Void unused, + Throwable error) {} +} diff --git a/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/instrumentation/crash/CrashReporter.java b/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/instrumentation/crash/CrashReporter.java new file mode 100644 index 000000000..4c23724f6 --- /dev/null +++ b/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/instrumentation/crash/CrashReporter.java @@ -0,0 +1,64 @@ +/* + * Copyright Splunk Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.opentelemetry.rum.internal.instrumentation.crash; + +import io.opentelemetry.api.OpenTelemetry; +import io.opentelemetry.instrumentation.api.instrumenter.AttributesExtractor; +import io.opentelemetry.instrumentation.api.instrumenter.Instrumenter; +import io.opentelemetry.rum.internal.instrumentation.InstrumentedApplication; +import java.util.List; + +/** Entrypoint for installing the crash reporting instrumentation. */ +public final class CrashReporter { + + /** Returns a new {@link CrashReporter} with the default settings. */ + public static CrashReporter create() { + return builder().build(); + } + + /** Returns a new {@link CrashReporterBuilder}. */ + public static CrashReporterBuilder builder() { + return new CrashReporterBuilder(); + } + + private final List> additionalExtractors; + + CrashReporter(CrashReporterBuilder builder) { + this.additionalExtractors = builder.additionalExtractors; + } + + /** + * Installs the crash reporting instrumentation on the given {@link InstrumentedApplication}. + */ + public void installOn(InstrumentedApplication instrumentedApplication) { + Thread.UncaughtExceptionHandler existingHandler = + Thread.getDefaultUncaughtExceptionHandler(); + Thread.setDefaultUncaughtExceptionHandler( + new CrashReportingExceptionHandler( + buildInstrumenter(instrumentedApplication.getOpenTelemetrySdk()), + instrumentedApplication.getOpenTelemetrySdk().getSdkTracerProvider(), + existingHandler)); + } + + private Instrumenter buildInstrumenter(OpenTelemetry openTelemetry) { + return Instrumenter.builder( + openTelemetry, "io.opentelemetry.crash", CrashDetails::spanName) + .addAttributesExtractor(new CrashDetailsAttributesExtractor()) + .addAttributesExtractors(additionalExtractors) + .buildInstrumenter(); + } +} diff --git a/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/instrumentation/crash/CrashReporterBuilder.java b/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/instrumentation/crash/CrashReporterBuilder.java new file mode 100644 index 000000000..49e11921e --- /dev/null +++ b/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/instrumentation/crash/CrashReporterBuilder.java @@ -0,0 +1,43 @@ +/* + * Copyright Splunk Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.opentelemetry.rum.internal.instrumentation.crash; + +import io.opentelemetry.instrumentation.api.instrumenter.AttributesExtractor; +import java.util.ArrayList; +import java.util.List; + +/** A builder of {@link CrashReporter}. */ +public final class CrashReporterBuilder { + + CrashReporterBuilder() {} + + final List> additionalExtractors = new ArrayList<>(); + + /** Adds an {@link AttributesExtractor} that will extract additional attributes. */ + public CrashReporterBuilder addAttributesExtractor( + AttributesExtractor extractor) { + additionalExtractors.add(extractor); + return this; + } + + /** + * Returns a new {@link CrashReporter} with the settings of this {@link CrashReporterBuilder}. + */ + public CrashReporter build() { + return new CrashReporter(this); + } +} diff --git a/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/instrumentation/crash/CrashReportingExceptionHandler.java b/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/instrumentation/crash/CrashReportingExceptionHandler.java new file mode 100644 index 000000000..709af643a --- /dev/null +++ b/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/instrumentation/crash/CrashReportingExceptionHandler.java @@ -0,0 +1,60 @@ +/* + * Copyright Splunk Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.opentelemetry.rum.internal.instrumentation.crash; + +import androidx.annotation.NonNull; +import io.opentelemetry.context.Context; +import io.opentelemetry.instrumentation.api.instrumenter.Instrumenter; +import io.opentelemetry.sdk.common.CompletableResultCode; +import io.opentelemetry.sdk.trace.SdkTracerProvider; +import java.util.concurrent.TimeUnit; + +final class CrashReportingExceptionHandler implements Thread.UncaughtExceptionHandler { + + private final Instrumenter instrumenter; + private final SdkTracerProvider sdkTracerProvider; + private final Thread.UncaughtExceptionHandler existingHandler; + + CrashReportingExceptionHandler( + Instrumenter instrumenter, + SdkTracerProvider sdkTracerProvider, + Thread.UncaughtExceptionHandler existingHandler) { + this.instrumenter = instrumenter; + this.sdkTracerProvider = sdkTracerProvider; + this.existingHandler = existingHandler; + } + + @Override + public void uncaughtException(@NonNull Thread t, @NonNull Throwable e) { + reportCrash(t, e); + + // do our best to make sure the crash makes it out of the VM + CompletableResultCode flushResult = sdkTracerProvider.forceFlush(); + flushResult.join(10, TimeUnit.SECONDS); + + // preserve any existing behavior + if (existingHandler != null) { + existingHandler.uncaughtException(t, e); + } + } + + private void reportCrash(Thread t, Throwable e) { + CrashDetails crashDetails = CrashDetails.create(t, e); + Context context = instrumenter.start(Context.current(), crashDetails); + instrumenter.end(context, crashDetails, null, e); + } +} diff --git a/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/instrumentation/fragment/FragmentTracer.java b/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/instrumentation/fragment/FragmentTracer.java new file mode 100644 index 000000000..390f6bea3 --- /dev/null +++ b/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/instrumentation/fragment/FragmentTracer.java @@ -0,0 +1,116 @@ +/* + * Copyright Splunk Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.opentelemetry.rum.internal.instrumentation.fragment; + +import androidx.fragment.app.Fragment; +import io.opentelemetry.api.common.AttributeKey; +import io.opentelemetry.api.trace.Span; +import io.opentelemetry.api.trace.Tracer; +import io.opentelemetry.rum.internal.RumConstants; +import io.opentelemetry.rum.internal.util.ActiveSpan; + +class FragmentTracer { + static final AttributeKey FRAGMENT_NAME_KEY = AttributeKey.stringKey("fragmentName"); + + private final String fragmentName; + private final String screenName; + private final Tracer tracer; + private final ActiveSpan activeSpan; + + private FragmentTracer(Builder builder) { + this.tracer = builder.tracer; + this.fragmentName = builder.getFragmentName(); + this.screenName = builder.screenName; + this.activeSpan = builder.activeSpan; + } + + FragmentTracer startSpanIfNoneInProgress(String action) { + if (activeSpan.spanInProgress()) { + return this; + } + activeSpan.startSpan(() -> createSpan(action)); + return this; + } + + FragmentTracer startFragmentCreation() { + activeSpan.startSpan(() -> createSpan("Created")); + return this; + } + + private Span createSpan(String spanName) { + Span span = + tracer.spanBuilder(spanName) + .setAttribute(FRAGMENT_NAME_KEY, fragmentName) + .startSpan(); + // do this after the span is started, so we can override the default screen.name set by the + // RumAttributeAppender. + span.setAttribute(RumConstants.SCREEN_NAME_KEY, screenName); + return span; + } + + void endActiveSpan() { + activeSpan.endActiveSpan(); + } + + FragmentTracer addPreviousScreenAttribute() { + activeSpan.addPreviousScreenAttribute(fragmentName); + return this; + } + + FragmentTracer addEvent(String eventName) { + activeSpan.addEvent(eventName); + return this; + } + + static Builder builder(Fragment fragment) { + return new Builder(fragment); + } + + static class Builder { + private final Fragment fragment; + public String screenName; + private Tracer tracer; + private ActiveSpan activeSpan; + + public Builder(Fragment fragment) { + this.fragment = fragment; + } + + Builder setTracer(Tracer tracer) { + this.tracer = tracer; + return this; + } + + public Builder setScreenName(String screenName) { + this.screenName = screenName; + return this; + } + + Builder setActiveSpan(ActiveSpan activeSpan) { + this.activeSpan = activeSpan; + return this; + } + + public String getFragmentName() { + return fragment.getClass().getSimpleName(); + } + + FragmentTracer build() { + return new FragmentTracer(this); + } + } +} diff --git a/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/instrumentation/fragment/RumFragmentLifecycleCallbacks.java b/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/instrumentation/fragment/RumFragmentLifecycleCallbacks.java new file mode 100644 index 000000000..a8c4184ff --- /dev/null +++ b/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/instrumentation/fragment/RumFragmentLifecycleCallbacks.java @@ -0,0 +1,174 @@ +/* + * Copyright Splunk Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.opentelemetry.rum.internal.instrumentation.fragment; + +import android.content.Context; +import android.os.Bundle; +import android.view.View; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.fragment.app.Fragment; +import androidx.fragment.app.FragmentManager; +import io.opentelemetry.api.trace.Tracer; +import io.opentelemetry.rum.internal.instrumentation.ScreenNameExtractor; +import io.opentelemetry.rum.internal.instrumentation.activity.VisibleScreenTracker; +import io.opentelemetry.rum.internal.util.ActiveSpan; +import java.util.HashMap; +import java.util.Map; + +public class RumFragmentLifecycleCallbacks extends FragmentManager.FragmentLifecycleCallbacks { + private final Map tracersByFragmentClassName = new HashMap<>(); + + private final Tracer tracer; + private final VisibleScreenTracker visibleScreenTracker; + private final ScreenNameExtractor screenNameExtractor; + + public RumFragmentLifecycleCallbacks( + Tracer tracer, + VisibleScreenTracker visibleScreenTracker, + ScreenNameExtractor screenNameExtractor) { + this.tracer = tracer; + this.visibleScreenTracker = visibleScreenTracker; + this.screenNameExtractor = screenNameExtractor; + } + + @Override + public void onFragmentPreAttached( + @NonNull FragmentManager fm, @NonNull Fragment f, @NonNull Context context) { + super.onFragmentPreAttached(fm, f, context); + getTracer(f).startFragmentCreation().addEvent("fragmentPreAttached"); + } + + @Override + public void onFragmentAttached( + @NonNull FragmentManager fm, @NonNull Fragment f, @NonNull Context context) { + super.onFragmentAttached(fm, f, context); + addEvent(f, "fragmentAttached"); + } + + @Override + public void onFragmentPreCreated( + @NonNull FragmentManager fm, @NonNull Fragment f, @Nullable Bundle savedInstanceState) { + super.onFragmentPreCreated(fm, f, savedInstanceState); + addEvent(f, "fragmentPreCreated"); + } + + @Override + public void onFragmentCreated( + @NonNull FragmentManager fm, @NonNull Fragment f, @Nullable Bundle savedInstanceState) { + super.onFragmentCreated(fm, f, savedInstanceState); + addEvent(f, "fragmentCreated"); + } + + @Override + public void onFragmentViewCreated( + @NonNull FragmentManager fm, + @NonNull Fragment f, + @NonNull View v, + @Nullable Bundle savedInstanceState) { + super.onFragmentViewCreated(fm, f, v, savedInstanceState); + getTracer(f).startSpanIfNoneInProgress("Restored").addEvent("fragmentViewCreated"); + } + + @Override + public void onFragmentStarted(@NonNull FragmentManager fm, @NonNull Fragment f) { + super.onFragmentStarted(fm, f); + addEvent(f, "fragmentStarted"); + } + + @Override + public void onFragmentResumed(@NonNull FragmentManager fm, @NonNull Fragment f) { + super.onFragmentResumed(fm, f); + getTracer(f) + .startSpanIfNoneInProgress("Resumed") + .addEvent("fragmentResumed") + .addPreviousScreenAttribute() + .endActiveSpan(); + visibleScreenTracker.fragmentResumed(f); + } + + @Override + public void onFragmentPaused(@NonNull FragmentManager fm, @NonNull Fragment f) { + super.onFragmentPaused(fm, f); + visibleScreenTracker.fragmentPaused(f); + getTracer(f).startSpanIfNoneInProgress("Paused").addEvent("fragmentPaused"); + } + + @Override + public void onFragmentStopped(@NonNull FragmentManager fm, @NonNull Fragment f) { + super.onFragmentStopped(fm, f); + getTracer(f).addEvent("fragmentStopped").endActiveSpan(); + } + + @Override + public void onFragmentSaveInstanceState( + @NonNull FragmentManager fm, @NonNull Fragment f, @NonNull Bundle outState) { + super.onFragmentSaveInstanceState(fm, f, outState); + } + + @Override + public void onFragmentViewDestroyed(@NonNull FragmentManager fm, @NonNull Fragment f) { + super.onFragmentViewDestroyed(fm, f); + getTracer(f) + .startSpanIfNoneInProgress("ViewDestroyed") + .addEvent("fragmentViewDestroyed") + .endActiveSpan(); + } + + @Override + public void onFragmentDestroyed(@NonNull FragmentManager fm, @NonNull Fragment f) { + super.onFragmentDestroyed(fm, f); + // note: this might not get called if the dev has checked "retainInstance" on the fragment + getTracer(f).startSpanIfNoneInProgress("Destroyed").addEvent("fragmentDestroyed"); + } + + @Override + public void onFragmentDetached(@NonNull FragmentManager fm, @NonNull Fragment f) { + super.onFragmentDetached(fm, f); + // this is a terminal operation, but might also be the only thing we see on app getting + // killed, so + getTracer(f) + .startSpanIfNoneInProgress("Detached") + .addEvent("fragmentDetached") + .endActiveSpan(); + } + + private void addEvent(@NonNull Fragment fragment, String eventName) { + FragmentTracer fragmentTracer = + tracersByFragmentClassName.get(fragment.getClass().getName()); + if (fragmentTracer != null) { + fragmentTracer.addEvent(eventName); + } + } + + private FragmentTracer getTracer(Fragment fragment) { + FragmentTracer activityTracer = + tracersByFragmentClassName.get(fragment.getClass().getName()); + if (activityTracer == null) { + activityTracer = + FragmentTracer.builder(fragment) + .setTracer(tracer) + .setScreenName(screenNameExtractor.extract(fragment)) + .setActiveSpan( + new ActiveSpan( + visibleScreenTracker::getPreviouslyVisibleScreen)) + .build(); + tracersByFragmentClassName.put(fragment.getClass().getName(), activityTracer); + } + return activityTracer; + } +} diff --git a/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/instrumentation/lifecycle/AndroidLifecycleInstrumentation.java b/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/instrumentation/lifecycle/AndroidLifecycleInstrumentation.java new file mode 100644 index 000000000..2440f252a --- /dev/null +++ b/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/instrumentation/lifecycle/AndroidLifecycleInstrumentation.java @@ -0,0 +1,130 @@ +/* + * Copyright Splunk Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.opentelemetry.rum.internal.instrumentation.lifecycle; + +import android.app.Application; +import android.os.Build; +import androidx.annotation.NonNull; +import io.opentelemetry.api.trace.Tracer; +import io.opentelemetry.rum.internal.instrumentation.InstrumentedApplication; +import io.opentelemetry.rum.internal.instrumentation.ScreenNameExtractor; +import io.opentelemetry.rum.internal.instrumentation.activity.ActivityCallbacks; +import io.opentelemetry.rum.internal.instrumentation.activity.ActivityTracerCache; +import io.opentelemetry.rum.internal.instrumentation.activity.Pre29ActivityCallbacks; +import io.opentelemetry.rum.internal.instrumentation.activity.Pre29VisibleScreenLifecycleBinding; +import io.opentelemetry.rum.internal.instrumentation.activity.RumFragmentActivityRegisterer; +import io.opentelemetry.rum.internal.instrumentation.activity.VisibleScreenLifecycleBinding; +import io.opentelemetry.rum.internal.instrumentation.activity.VisibleScreenTracker; +import io.opentelemetry.rum.internal.instrumentation.fragment.RumFragmentLifecycleCallbacks; +import io.opentelemetry.rum.internal.instrumentation.startup.AppStartupTimer; +import java.util.function.Function; + +/** + * This is an umbrella instrumentation that covers several things: * startup timer callback is + * registered so that UI startup time can be measured - activity lifecycle callbacks are registered + * so that lifecycle events can be generated - activity lifecycle callback listener is registered to + * that will register a FragmentLifecycleCallbacks when appropriate - activity lifecycle callback + * listener is registered to dispatch events to the VisibleScreenTracker + */ +public class AndroidLifecycleInstrumentation { + + private static final String INSTRUMENTATION_SCOPE = "io.opentelemetry.lifecycle"; + private final AppStartupTimer startupTimer; + private final VisibleScreenTracker visibleScreenTracker; + + private final Function tracerCustomizer; + private final ScreenNameExtractor screenNameExtractor; + + AndroidLifecycleInstrumentation(AndroidLifecycleInstrumentationBuilder builder) { + this.startupTimer = builder.startupTimer; + this.visibleScreenTracker = builder.visibleScreenTracker; + this.tracerCustomizer = builder.tracerCustomizer; + this.screenNameExtractor = builder.screenNameExtractor; + } + + public static AndroidLifecycleInstrumentationBuilder builder() { + return new AndroidLifecycleInstrumentationBuilder(); + } + + public void installOn(InstrumentedApplication app) { + installStartupTimerInstrumentation(app); + installActivityLifecycleEventsInstrumentation(app); + installFragmentLifecycleInstrumentation(app); + installScreenTrackingInstrumentation(app); + } + + private void installStartupTimerInstrumentation(InstrumentedApplication app) { + app.getApplication() + .registerActivityLifecycleCallbacks(startupTimer.createLifecycleCallback()); + } + + private void installActivityLifecycleEventsInstrumentation(InstrumentedApplication app) { + Application.ActivityLifecycleCallbacks activityCallbacks = buildActivityEventsCallback(app); + app.getApplication().registerActivityLifecycleCallbacks(activityCallbacks); + } + + @NonNull + private Application.ActivityLifecycleCallbacks buildActivityEventsCallback( + InstrumentedApplication instrumentedApp) { + Tracer delegateTracer = + instrumentedApp.getOpenTelemetrySdk().getTracer(INSTRUMENTATION_SCOPE); + Tracer tracer = tracerCustomizer.apply(delegateTracer); + + ActivityTracerCache tracers = + new ActivityTracerCache( + tracer, visibleScreenTracker, startupTimer, screenNameExtractor); + if (Build.VERSION.SDK_INT < 29) { + return new Pre29ActivityCallbacks(tracers); + } + return new ActivityCallbacks(tracers); + } + + private void installFragmentLifecycleInstrumentation(InstrumentedApplication app) { + Application.ActivityLifecycleCallbacks fragmentRegisterer = buildFragmentRegisterer(app); + app.getApplication().registerActivityLifecycleCallbacks(fragmentRegisterer); + } + + @NonNull + private Application.ActivityLifecycleCallbacks buildFragmentRegisterer( + InstrumentedApplication app) { + + Tracer delegateTracer = app.getOpenTelemetrySdk().getTracer(INSTRUMENTATION_SCOPE); + Tracer tracer = tracerCustomizer.apply(delegateTracer); + RumFragmentLifecycleCallbacks fragmentLifecycle = + new RumFragmentLifecycleCallbacks( + tracer, visibleScreenTracker, screenNameExtractor); + if (Build.VERSION.SDK_INT < 29) { + return RumFragmentActivityRegisterer.createPre29(fragmentLifecycle); + } + return RumFragmentActivityRegisterer.create(fragmentLifecycle); + } + + private void installScreenTrackingInstrumentation(InstrumentedApplication app) { + Application.ActivityLifecycleCallbacks screenTrackingBinding = + buildScreenTrackingBinding(visibleScreenTracker); + app.getApplication().registerActivityLifecycleCallbacks(screenTrackingBinding); + } + + @NonNull + private Application.ActivityLifecycleCallbacks buildScreenTrackingBinding( + VisibleScreenTracker visibleScreenTracker) { + if (Build.VERSION.SDK_INT < 29) { + return new Pre29VisibleScreenLifecycleBinding(visibleScreenTracker); + } + return new VisibleScreenLifecycleBinding(visibleScreenTracker); + } +} diff --git a/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/instrumentation/lifecycle/AndroidLifecycleInstrumentationBuilder.java b/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/instrumentation/lifecycle/AndroidLifecycleInstrumentationBuilder.java new file mode 100644 index 000000000..d943b6bb6 --- /dev/null +++ b/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/instrumentation/lifecycle/AndroidLifecycleInstrumentationBuilder.java @@ -0,0 +1,57 @@ +/* + * Copyright Splunk Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.opentelemetry.rum.internal.instrumentation.lifecycle; + +import io.opentelemetry.api.trace.Tracer; +import io.opentelemetry.rum.internal.instrumentation.ScreenNameExtractor; +import io.opentelemetry.rum.internal.instrumentation.activity.VisibleScreenTracker; +import io.opentelemetry.rum.internal.instrumentation.startup.AppStartupTimer; +import java.util.function.Function; + +public class AndroidLifecycleInstrumentationBuilder { + ScreenNameExtractor screenNameExtractor = ScreenNameExtractor.DEFAULT; + AppStartupTimer startupTimer; + VisibleScreenTracker visibleScreenTracker; + Function tracerCustomizer = Function.identity(); + + public AndroidLifecycleInstrumentationBuilder setStartupTimer(AppStartupTimer timer) { + this.startupTimer = timer; + return this; + } + + public AndroidLifecycleInstrumentationBuilder setVisibleScreenTracker( + VisibleScreenTracker tracker) { + this.visibleScreenTracker = tracker; + return this; + } + + public AndroidLifecycleInstrumentationBuilder setTracerCustomizer( + Function customizer) { + this.tracerCustomizer = customizer; + return this; + } + + public AndroidLifecycleInstrumentationBuilder setScreenNameExtractor( + ScreenNameExtractor screenNameExtractor) { + this.screenNameExtractor = screenNameExtractor; + return this; + } + + public AndroidLifecycleInstrumentation build() { + return new AndroidLifecycleInstrumentation(this); + } +} diff --git a/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/instrumentation/network/Carrier.java b/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/instrumentation/network/Carrier.java new file mode 100644 index 000000000..a6f61d292 --- /dev/null +++ b/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/instrumentation/network/Carrier.java @@ -0,0 +1,143 @@ +/* + * Copyright Splunk Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.opentelemetry.rum.internal.instrumentation.network; + +import android.os.Build; +import android.telephony.TelephonyManager; +import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; +import java.util.Objects; + +@RequiresApi(api = Build.VERSION_CODES.P) +final class Carrier { + + private final int id; + private final @Nullable String name; + private final @Nullable String mobileCountryCode; // 3 digits + private final @Nullable String mobileNetworkCode; // 2 or 3 digits + private final @Nullable String isoCountryCode; + + static Builder builder() { + return new Builder(); + } + + Carrier(Builder builder) { + this.id = builder.id; + this.name = builder.name; + this.mobileCountryCode = builder.mobileCountryCode; + this.mobileNetworkCode = builder.mobileNetworkCode; + this.isoCountryCode = builder.isoCountryCode; + } + + int getId() { + return id; + } + + @Nullable + String getName() { + return name; + } + + @Nullable + String getMobileCountryCode() { + return mobileCountryCode; + } + + @Nullable + String getMobileNetworkCode() { + return mobileNetworkCode; + } + + @Nullable + String getIsoCountryCode() { + return isoCountryCode; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + Carrier carrier = (Carrier) o; + return id == carrier.id + && Objects.equals(name, carrier.name) + && Objects.equals(mobileCountryCode, carrier.mobileCountryCode) + && Objects.equals(mobileNetworkCode, carrier.mobileNetworkCode) + && Objects.equals(isoCountryCode, carrier.isoCountryCode); + } + + @Override + public int hashCode() { + return Objects.hash(id, name, mobileCountryCode, mobileNetworkCode, isoCountryCode); + } + + @Override + public String toString() { + return "Carrier{" + + "id=" + + id + + ", name='" + + name + + '\'' + + ", mobileCountryCode='" + + mobileCountryCode + + '\'' + + ", mobileNetworkCode='" + + mobileNetworkCode + + '\'' + + ", isoCountryCode='" + + isoCountryCode + + '\'' + + '}'; + } + + static class Builder { + private int id = TelephonyManager.UNKNOWN_CARRIER_ID; + private @Nullable String name = null; + private @Nullable String mobileCountryCode = null; + private @Nullable String mobileNetworkCode = null; + private @Nullable String isoCountryCode = null; + + Carrier build() { + return new Carrier(this); + } + + Builder id(int id) { + this.id = id; + return this; + } + + Builder name(String name) { + this.name = name; + return this; + } + + Builder mobileCountryCode(String countryCode) { + this.mobileCountryCode = countryCode; + return this; + } + + Builder mobileNetworkCode(String networkCode) { + this.mobileNetworkCode = networkCode; + return this; + } + + Builder isoCountryCode(String isoCountryCode) { + this.isoCountryCode = isoCountryCode; + return this; + } + } +} diff --git a/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/instrumentation/network/CarrierFinder.java b/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/instrumentation/network/CarrierFinder.java new file mode 100644 index 000000000..23ca02a88 --- /dev/null +++ b/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/instrumentation/network/CarrierFinder.java @@ -0,0 +1,56 @@ +/* + * Copyright Splunk Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.opentelemetry.rum.internal.instrumentation.network; + +import android.os.Build; +import android.telephony.TelephonyManager; +import androidx.annotation.RequiresApi; + +@RequiresApi(api = Build.VERSION_CODES.P) +class CarrierFinder { + + private final TelephonyManager telephonyManager; + + CarrierFinder(TelephonyManager telephonyManager) { + this.telephonyManager = telephonyManager; + } + + Carrier get() { + Carrier.Builder builder = Carrier.builder(); + int id = telephonyManager.getSimCarrierId(); + builder.id(id); + CharSequence name = telephonyManager.getSimCarrierIdName(); + if (validString(name)) { + builder.name(name.toString()); + } + String simOperator = telephonyManager.getSimOperator(); + if (validString(simOperator) && simOperator.length() >= 5) { + String countryCode = simOperator.substring(0, 3); + String networkCode = simOperator.substring(3); + builder.mobileCountryCode(countryCode).mobileNetworkCode(networkCode); + } + String isoCountryCode = telephonyManager.getSimCountryIso(); + if (validString(isoCountryCode)) { + builder.isoCountryCode(isoCountryCode); + } + return builder.build(); + } + + private boolean validString(CharSequence str) { + return !(str == null || str.length() == 0); + } +} diff --git a/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/instrumentation/network/CurrentNetwork.java b/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/instrumentation/network/CurrentNetwork.java new file mode 100644 index 000000000..4a7ab9d56 --- /dev/null +++ b/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/instrumentation/network/CurrentNetwork.java @@ -0,0 +1,133 @@ +/* + * Copyright Splunk Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.opentelemetry.rum.internal.instrumentation.network; + +import android.os.Build; +import androidx.annotation.Nullable; +import java.util.Objects; + +/** A value class representing the current network that the device is connected to. */ +public final class CurrentNetwork { + + @Nullable private final Carrier carrier; + private final NetworkState state; + @Nullable private final String subType; + + private CurrentNetwork(Builder builder) { + this.carrier = builder.carrier; + this.state = builder.state; + this.subType = builder.subType; + } + + /** Returns {@code true} if the device has internet connection; {@code false} otherwise. */ + public boolean isOnline() { + return getState() != NetworkState.NO_NETWORK_AVAILABLE; + } + + NetworkState getState() { + return state; + } + + @Nullable + String getSubType() { + return subType; + } + + @SuppressWarnings("NullAway") + @Nullable + String getCarrierCountryCode() { + return haveCarrier() ? carrier.getMobileCountryCode() : null; + } + + @SuppressWarnings("NullAway") + @Nullable + String getCarrierIsoCountryCode() { + return haveCarrier() ? carrier.getIsoCountryCode() : null; + } + + @SuppressWarnings("NullAway") + @Nullable + String getCarrierNetworkCode() { + return haveCarrier() ? carrier.getMobileNetworkCode() : null; + } + + @SuppressWarnings("NullAway") + @Nullable + String getCarrierName() { + return haveCarrier() ? carrier.getName() : null; + } + + private boolean haveCarrier() { + return (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) && (carrier != null); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + CurrentNetwork that = (CurrentNetwork) o; + return Objects.equals(carrier, that.carrier) + && state == that.state + && Objects.equals(subType, that.subType); + } + + @Override + public int hashCode() { + return Objects.hash(carrier, state, subType); + } + + @Override + public String toString() { + return "CurrentNetwork{" + + "carrier=" + + carrier + + ", state=" + + state + + ", subType='" + + subType + + '\'' + + '}'; + } + + static Builder builder(NetworkState state) { + return new Builder(state); + } + + static class Builder { + @Nullable private Carrier carrier; + private final NetworkState state; + @Nullable private String subType; + + private Builder(NetworkState state) { + this.state = state; + } + + Builder carrier(@Nullable Carrier carrier) { + this.carrier = carrier; + return this; + } + + Builder subType(@Nullable String subType) { + this.subType = subType; + return this; + } + + CurrentNetwork build() { + return new CurrentNetwork(this); + } + } +} diff --git a/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/instrumentation/network/CurrentNetworkAttributesExtractor.java b/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/instrumentation/network/CurrentNetworkAttributesExtractor.java new file mode 100644 index 000000000..3662cb1ae --- /dev/null +++ b/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/instrumentation/network/CurrentNetworkAttributesExtractor.java @@ -0,0 +1,53 @@ +/* + * Copyright Splunk Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.opentelemetry.rum.internal.instrumentation.network; + +import static io.opentelemetry.semconv.trace.attributes.SemanticAttributes.NET_HOST_CARRIER_ICC; +import static io.opentelemetry.semconv.trace.attributes.SemanticAttributes.NET_HOST_CARRIER_MCC; +import static io.opentelemetry.semconv.trace.attributes.SemanticAttributes.NET_HOST_CARRIER_MNC; +import static io.opentelemetry.semconv.trace.attributes.SemanticAttributes.NET_HOST_CARRIER_NAME; +import static io.opentelemetry.semconv.trace.attributes.SemanticAttributes.NET_HOST_CONNECTION_SUBTYPE; +import static io.opentelemetry.semconv.trace.attributes.SemanticAttributes.NET_HOST_CONNECTION_TYPE; + +import androidx.annotation.Nullable; +import io.opentelemetry.api.common.AttributeKey; +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.api.common.AttributesBuilder; + +final class CurrentNetworkAttributesExtractor { + + Attributes extract(CurrentNetwork network) { + AttributesBuilder builder = + Attributes.builder() + .put(NET_HOST_CONNECTION_TYPE, network.getState().getHumanName()); + + setIfNotNull(builder, NET_HOST_CONNECTION_SUBTYPE, network.getSubType()); + setIfNotNull(builder, NET_HOST_CARRIER_NAME, network.getCarrierName()); + setIfNotNull(builder, NET_HOST_CARRIER_MCC, network.getCarrierCountryCode()); + setIfNotNull(builder, NET_HOST_CARRIER_MNC, network.getCarrierNetworkCode()); + setIfNotNull(builder, NET_HOST_CARRIER_ICC, network.getCarrierIsoCountryCode()); + + return builder.build(); + } + + private static void setIfNotNull( + AttributesBuilder builder, AttributeKey key, @Nullable String value) { + if (value != null) { + builder.put(key, value); + } + } +} diff --git a/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/instrumentation/network/CurrentNetworkProvider.java b/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/instrumentation/network/CurrentNetworkProvider.java new file mode 100644 index 000000000..33e633b2f --- /dev/null +++ b/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/instrumentation/network/CurrentNetworkProvider.java @@ -0,0 +1,158 @@ +/* + * Copyright Splunk Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.opentelemetry.rum.internal.instrumentation.network; + +import android.app.Application; +import android.content.Context; +import android.net.ConnectivityManager; +import android.net.Network; +import android.net.NetworkCapabilities; +import android.net.NetworkRequest; +import android.os.Build; +import android.util.Log; +import androidx.annotation.NonNull; +import io.opentelemetry.rum.internal.RumConstants; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.function.Supplier; + +// note: based on ideas from stack overflow: +// https://stackoverflow.com/questions/32547006/connectivitymanager-getnetworkinfoint-deprecated + +/** + * A provider of {@link CurrentNetwork} information. Registers itself in the Android {@link + * ConnectivityManager} and listens for network changes. + */ +public final class CurrentNetworkProvider { + + static final CurrentNetwork NO_NETWORK = + CurrentNetwork.builder(NetworkState.NO_NETWORK_AVAILABLE).build(); + static final CurrentNetwork UNKNOWN_NETWORK = + CurrentNetwork.builder(NetworkState.TRANSPORT_UNKNOWN).build(); + + /** + * Creates a new {@link CurrentNetworkProvider} instance and registers network callbacks in the + * Android {@link ConnectivityManager}. + */ + public static CurrentNetworkProvider createAndStart(Application application) { + Context context = application.getApplicationContext(); + CurrentNetworkProvider currentNetworkProvider = + new CurrentNetworkProvider(NetworkDetector.create(context)); + currentNetworkProvider.startMonitoring( + CurrentNetworkProvider::createNetworkMonitoringRequest, + (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE)); + return currentNetworkProvider; + } + + private final NetworkDetector networkDetector; + + private volatile CurrentNetwork currentNetwork = UNKNOWN_NETWORK; + private final List listeners = new CopyOnWriteArrayList<>(); + + // visible for tests + CurrentNetworkProvider(NetworkDetector networkDetector) { + this.networkDetector = networkDetector; + } + + // visible for tests + void startMonitoring( + Supplier createNetworkMonitoringRequest, + ConnectivityManager connectivityManager) { + refreshNetworkStatus(); + try { + registerNetworkCallbacks(createNetworkMonitoringRequest, connectivityManager); + } catch (Exception e) { + // if this fails, we'll go without network change events. + Log.w( + RumConstants.OTEL_RUM_LOG_TAG, + "Failed to register network callbacks. Automatic network monitoring is disabled.", + e); + } + } + + private void registerNetworkCallbacks( + Supplier createNetworkMonitoringRequest, + ConnectivityManager connectivityManager) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + connectivityManager.registerDefaultNetworkCallback(new ConnectionMonitor()); + } else { + NetworkRequest networkRequest = createNetworkMonitoringRequest.get(); + connectivityManager.registerNetworkCallback(networkRequest, new ConnectionMonitor()); + } + } + + /** Returns up-to-date {@linkplain CurrentNetwork current network information}. */ + public CurrentNetwork refreshNetworkStatus() { + try { + currentNetwork = networkDetector.detectCurrentNetwork(); + } catch (Exception e) { + // guard against security issues/bugs when accessing the Android connectivityManager. + // see: https://issuetracker.google.com/issues/175055271 + currentNetwork = UNKNOWN_NETWORK; + } + return currentNetwork; + } + + private static NetworkRequest createNetworkMonitoringRequest() { + // note: this throws an NPE when running in junit without robolectric, due to Android + return new NetworkRequest.Builder() + .addTransportType(NetworkCapabilities.TRANSPORT_CELLULAR) + .addTransportType(NetworkCapabilities.TRANSPORT_WIFI) + .addTransportType(NetworkCapabilities.TRANSPORT_BLUETOOTH) + .addTransportType(NetworkCapabilities.TRANSPORT_ETHERNET) + .addTransportType(NetworkCapabilities.TRANSPORT_VPN) + .build(); + } + + CurrentNetwork getCurrentNetwork() { + return currentNetwork; + } + + void addNetworkChangeListener(NetworkChangeListener listener) { + listeners.add(listener); + } + + private void notifyListeners(CurrentNetwork activeNetwork) { + for (NetworkChangeListener listener : listeners) { + listener.onNetworkChange(activeNetwork); + } + } + + private final class ConnectionMonitor extends ConnectivityManager.NetworkCallback { + + @Override + public void onAvailable(@NonNull Network network) { + CurrentNetwork activeNetwork = refreshNetworkStatus(); + Log.d(RumConstants.OTEL_RUM_LOG_TAG, " onAvailable: currentNetwork=" + activeNetwork); + + notifyListeners(activeNetwork); + } + + @Override + public void onLost(@NonNull Network network) { + // it seems that the "currentNetwork" is still the one that is being lost, so for + // this method, we'll force it to be NO_NETWORK, rather than relying on the + // ConnectivityManager to have the right + // state at the right time during this event. + CurrentNetwork currentNetwork = NO_NETWORK; + CurrentNetworkProvider.this.currentNetwork = currentNetwork; + Log.d(RumConstants.OTEL_RUM_LOG_TAG, " onLost: currentNetwork=" + currentNetwork); + + notifyListeners(currentNetwork); + } + } +} diff --git a/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/instrumentation/network/NetworkApplicationListener.java b/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/instrumentation/network/NetworkApplicationListener.java new file mode 100644 index 000000000..6cdd19919 --- /dev/null +++ b/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/instrumentation/network/NetworkApplicationListener.java @@ -0,0 +1,73 @@ +/* + * Copyright Splunk Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.opentelemetry.rum.internal.instrumentation.network; + +import static io.opentelemetry.api.common.AttributeKey.stringKey; + +import io.opentelemetry.api.common.AttributeKey; +import io.opentelemetry.context.Context; +import io.opentelemetry.instrumentation.api.instrumenter.Instrumenter; +import io.opentelemetry.rum.internal.instrumentation.ApplicationStateListener; +import java.util.concurrent.atomic.AtomicBoolean; + +class NetworkApplicationListener implements ApplicationStateListener { + static final AttributeKey NETWORK_STATUS_KEY = stringKey("network.status"); + + private final CurrentNetworkProvider currentNetworkProvider; + private final AtomicBoolean shouldEmitChangeEvents = new AtomicBoolean(true); + + NetworkApplicationListener(CurrentNetworkProvider currentNetworkProvider) { + this.currentNetworkProvider = currentNetworkProvider; + } + + void startMonitoring(Instrumenter instrumenter) { + currentNetworkProvider.addNetworkChangeListener( + new TracingNetworkChangeListener(instrumenter, shouldEmitChangeEvents)); + } + + @Override + public void onApplicationForegrounded() { + shouldEmitChangeEvents.set(true); + } + + @Override + public void onApplicationBackgrounded() { + shouldEmitChangeEvents.set(false); + } + + private static final class TracingNetworkChangeListener implements NetworkChangeListener { + + private final Instrumenter instrumenter; + private final AtomicBoolean shouldEmitChangeEvents; + + TracingNetworkChangeListener( + Instrumenter instrumenter, + AtomicBoolean shouldEmitChangeEvents) { + this.instrumenter = instrumenter; + this.shouldEmitChangeEvents = shouldEmitChangeEvents; + } + + @Override + public void onNetworkChange(CurrentNetwork currentNetwork) { + if (!shouldEmitChangeEvents.get()) { + return; + } + Context context = instrumenter.start(Context.current(), currentNetwork); + instrumenter.end(context, currentNetwork, null, null); + } + } +} diff --git a/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/instrumentation/network/NetworkAttributesSpanAppender.java b/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/instrumentation/network/NetworkAttributesSpanAppender.java new file mode 100644 index 000000000..948286c51 --- /dev/null +++ b/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/instrumentation/network/NetworkAttributesSpanAppender.java @@ -0,0 +1,64 @@ +/* + * Copyright Splunk Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.opentelemetry.rum.internal.instrumentation.network; + +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.context.Context; +import io.opentelemetry.sdk.trace.ReadWriteSpan; +import io.opentelemetry.sdk.trace.ReadableSpan; +import io.opentelemetry.sdk.trace.SpanProcessor; + +/** + * A {@link SpanProcessor} implementation that appends a set of {@linkplain Attributes attributes} + * describing the {@linkplain CurrentNetwork current network} to every span that is exported. + * + *

This class is internal and not for public use. Its APIs are unstable and can change at any + * time. + */ +public final class NetworkAttributesSpanAppender implements SpanProcessor { + + public static SpanProcessor create(CurrentNetworkProvider currentNetworkProvider) { + return new NetworkAttributesSpanAppender(currentNetworkProvider); + } + + private final CurrentNetworkProvider currentNetworkProvider; + private final CurrentNetworkAttributesExtractor networkAttributesExtractor = + new CurrentNetworkAttributesExtractor(); + + NetworkAttributesSpanAppender(CurrentNetworkProvider currentNetworkProvider) { + this.currentNetworkProvider = currentNetworkProvider; + } + + @Override + public void onStart(Context parentContext, ReadWriteSpan span) { + CurrentNetwork currentNetwork = currentNetworkProvider.getCurrentNetwork(); + span.setAllAttributes(networkAttributesExtractor.extract(currentNetwork)); + } + + @Override + public boolean isStartRequired() { + return true; + } + + @Override + public void onEnd(ReadableSpan span) {} + + @Override + public boolean isEndRequired() { + return false; + } +} diff --git a/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/instrumentation/network/NetworkChangeAttributesExtractor.java b/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/instrumentation/network/NetworkChangeAttributesExtractor.java new file mode 100644 index 000000000..e2eed40b1 --- /dev/null +++ b/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/instrumentation/network/NetworkChangeAttributesExtractor.java @@ -0,0 +1,59 @@ +/* + * Copyright Splunk Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.opentelemetry.rum.internal.instrumentation.network; + +import static io.opentelemetry.api.common.AttributeKey.stringKey; +import static io.opentelemetry.semconv.trace.attributes.SemanticAttributes.NET_HOST_CONNECTION_TYPE; + +import io.opentelemetry.api.common.AttributeKey; +import io.opentelemetry.api.common.AttributesBuilder; +import io.opentelemetry.context.Context; +import io.opentelemetry.instrumentation.api.instrumenter.AttributesExtractor; + +final class NetworkChangeAttributesExtractor implements AttributesExtractor { + + static final AttributeKey NETWORK_STATUS_KEY = stringKey("network.status"); + + private final CurrentNetworkAttributesExtractor networkAttributesExtractor = + new CurrentNetworkAttributesExtractor(); + + @Override + public void onStart( + AttributesBuilder attributes, Context parentContext, CurrentNetwork currentNetwork) { + String status = + currentNetwork.getState() == NetworkState.NO_NETWORK_AVAILABLE + ? "lost" + : "available"; + attributes.put(NETWORK_STATUS_KEY, status); + } + + @Override + public void onEnd( + AttributesBuilder attributes, + Context context, + CurrentNetwork currentNetwork, + Void unused, + Throwable error) { + // put these after span start to override what might be set in the + // NetworkAttributesSpanAppender. + if (currentNetwork.getState() == NetworkState.NO_NETWORK_AVAILABLE) { + attributes.put(NET_HOST_CONNECTION_TYPE, currentNetwork.getState().getHumanName()); + } else { + attributes.putAll(networkAttributesExtractor.extract(currentNetwork)); + } + } +} diff --git a/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/instrumentation/network/NetworkChangeListener.java b/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/instrumentation/network/NetworkChangeListener.java new file mode 100644 index 000000000..8121b3cca --- /dev/null +++ b/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/instrumentation/network/NetworkChangeListener.java @@ -0,0 +1,22 @@ +/* + * Copyright Splunk Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.opentelemetry.rum.internal.instrumentation.network; + +interface NetworkChangeListener { + + void onNetworkChange(CurrentNetwork currentNetwork); +} diff --git a/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/instrumentation/network/NetworkChangeMonitor.java b/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/instrumentation/network/NetworkChangeMonitor.java new file mode 100644 index 000000000..7cd12e03d --- /dev/null +++ b/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/instrumentation/network/NetworkChangeMonitor.java @@ -0,0 +1,70 @@ +/* + * Copyright Splunk Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.opentelemetry.rum.internal.instrumentation.network; + +import io.opentelemetry.api.OpenTelemetry; +import io.opentelemetry.instrumentation.api.instrumenter.AttributesExtractor; +import io.opentelemetry.instrumentation.api.instrumenter.Instrumenter; +import io.opentelemetry.rum.internal.instrumentation.InstrumentedApplication; +import java.util.ArrayList; +import java.util.List; + +/** + * Entrypoint for installing the network change monitoring instrumentation. + * + *

This class is internal and not for public use. Its APIs are unstable and can change at any + * time. + */ +public final class NetworkChangeMonitor { + + public static NetworkChangeMonitor create(CurrentNetworkProvider currentNetworkProvider) { + return builder(currentNetworkProvider).build(); + } + + public static NetworkChangeMonitorBuilder builder( + CurrentNetworkProvider currentNetworkProvider) { + return new NetworkChangeMonitorBuilder(currentNetworkProvider); + } + + private final CurrentNetworkProvider currentNetworkProvider; + private final List> additionalExtractors; + + NetworkChangeMonitor(NetworkChangeMonitorBuilder builder) { + this.currentNetworkProvider = builder.currentNetworkProvider; + this.additionalExtractors = new ArrayList<>(builder.additionalExtractors); + } + + /** + * Installs the network change monitoring instrumentation on the given {@link + * InstrumentedApplication}. + */ + public void installOn(InstrumentedApplication instrumentedApplication) { + NetworkApplicationListener networkApplicationListener = + new NetworkApplicationListener(currentNetworkProvider); + networkApplicationListener.startMonitoring( + buildInstrumenter(instrumentedApplication.getOpenTelemetrySdk())); + instrumentedApplication.registerApplicationStateListener(networkApplicationListener); + } + + private Instrumenter buildInstrumenter(OpenTelemetry openTelemetry) { + return Instrumenter.builder( + openTelemetry, "io.opentelemetry.network", network -> "network.change") + .addAttributesExtractor(new NetworkChangeAttributesExtractor()) + .addAttributesExtractors(additionalExtractors) + .buildInstrumenter(); + } +} diff --git a/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/instrumentation/network/NetworkChangeMonitorBuilder.java b/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/instrumentation/network/NetworkChangeMonitorBuilder.java new file mode 100644 index 000000000..992562287 --- /dev/null +++ b/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/instrumentation/network/NetworkChangeMonitorBuilder.java @@ -0,0 +1,48 @@ +/* + * Copyright Splunk Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.opentelemetry.rum.internal.instrumentation.network; + +import io.opentelemetry.instrumentation.api.instrumenter.AttributesExtractor; +import java.util.ArrayList; +import java.util.List; + +/** + * A builder of {@link NetworkChangeMonitor}. + * + *

This class is internal and not for public use. Its APIs are unstable and can change at any + * time. + */ +public final class NetworkChangeMonitorBuilder { + + final CurrentNetworkProvider currentNetworkProvider; + final List> additionalExtractors = new ArrayList<>(); + + NetworkChangeMonitorBuilder(CurrentNetworkProvider currentNetworkProvider) { + this.currentNetworkProvider = currentNetworkProvider; + } + + /** Adds an {@link AttributesExtractor} that will extract additional attributes. */ + public NetworkChangeMonitorBuilder addAttributesExtractor( + AttributesExtractor extractor) { + additionalExtractors.add(extractor); + return this; + } + + public NetworkChangeMonitor build() { + return new NetworkChangeMonitor(this); + } +} diff --git a/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/instrumentation/network/NetworkDetector.java b/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/instrumentation/network/NetworkDetector.java new file mode 100644 index 000000000..151c2538c --- /dev/null +++ b/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/instrumentation/network/NetworkDetector.java @@ -0,0 +1,39 @@ +/* + * Copyright Splunk Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.opentelemetry.rum.internal.instrumentation.network; + +import android.content.Context; +import android.net.ConnectivityManager; +import android.os.Build; +import android.telephony.TelephonyManager; + +interface NetworkDetector { + CurrentNetwork detectCurrentNetwork(); + + static NetworkDetector create(Context context) { + ConnectivityManager connectivityManager = + (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + TelephonyManager telephonyManager = + (TelephonyManager) context.getSystemService(Context.TELEPHONY_SERVICE); + CarrierFinder carrierFinder = new CarrierFinder(telephonyManager); + return new PostApi28NetworkDetector( + connectivityManager, telephonyManager, carrierFinder, context); + } + return new SimpleNetworkDetector(connectivityManager); + } +} diff --git a/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/instrumentation/network/NetworkState.java b/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/instrumentation/network/NetworkState.java new file mode 100644 index 000000000..45c348027 --- /dev/null +++ b/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/instrumentation/network/NetworkState.java @@ -0,0 +1,38 @@ +/* + * Copyright Splunk Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.opentelemetry.rum.internal.instrumentation.network; + +import io.opentelemetry.semconv.trace.attributes.SemanticAttributes; + +enum NetworkState { + NO_NETWORK_AVAILABLE(SemanticAttributes.NetHostConnectionTypeValues.UNAVAILABLE), + TRANSPORT_CELLULAR(SemanticAttributes.NetHostConnectionTypeValues.CELL), + TRANSPORT_WIFI(SemanticAttributes.NetHostConnectionTypeValues.WIFI), + TRANSPORT_UNKNOWN(SemanticAttributes.NetHostConnectionTypeValues.UNKNOWN), + // this one doesn't seem to have an otel value at this point. + TRANSPORT_VPN("vpn"); + + private final String humanName; + + NetworkState(String humanName) { + this.humanName = humanName; + } + + String getHumanName() { + return humanName; + } +} diff --git a/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/instrumentation/network/PostApi28NetworkDetector.java b/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/instrumentation/network/PostApi28NetworkDetector.java new file mode 100644 index 000000000..1bd9589a8 --- /dev/null +++ b/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/instrumentation/network/PostApi28NetworkDetector.java @@ -0,0 +1,130 @@ +/* + * Copyright Splunk Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.opentelemetry.rum.internal.instrumentation.network; + +import static io.opentelemetry.rum.internal.instrumentation.network.CurrentNetworkProvider.NO_NETWORK; +import static io.opentelemetry.rum.internal.instrumentation.network.CurrentNetworkProvider.UNKNOWN_NETWORK; + +import android.Manifest; +import android.annotation.SuppressLint; +import android.content.Context; +import android.content.pm.PackageManager; +import android.net.ConnectivityManager; +import android.net.NetworkCapabilities; +import android.os.Build; +import android.telephony.TelephonyManager; +import androidx.annotation.RequiresApi; +import androidx.core.app.ActivityCompat; + +@RequiresApi(api = Build.VERSION_CODES.P) +class PostApi28NetworkDetector implements NetworkDetector { + private final ConnectivityManager connectivityManager; + private final TelephonyManager telephonyManager; + private final CarrierFinder carrierFinder; + private final Context context; + + PostApi28NetworkDetector( + ConnectivityManager connectivityManager, + TelephonyManager telephonyManager, + CarrierFinder carrierFinder, + Context context) { + this.connectivityManager = connectivityManager; + this.telephonyManager = telephonyManager; + this.carrierFinder = carrierFinder; + this.context = context; + } + + @SuppressLint("MissingPermission") + @Override + public CurrentNetwork detectCurrentNetwork() { + NetworkCapabilities capabilities = + connectivityManager.getNetworkCapabilities(connectivityManager.getActiveNetwork()); + if (capabilities == null) { + return NO_NETWORK; + } + String subType = null; + Carrier carrier = carrierFinder.get(); + if (capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)) { + // If the app has the permission, use it to get a subtype. + if (hasPermission(Manifest.permission.READ_PHONE_STATE)) { + subType = getDataNetworkTypeName(telephonyManager.getDataNetworkType()); + } + return CurrentNetwork.builder(NetworkState.TRANSPORT_CELLULAR) + .carrier(carrier) + .subType(subType) + .build(); + } else if (capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)) { + return CurrentNetwork.builder(NetworkState.TRANSPORT_WIFI).carrier(carrier).build(); + } else if (capabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN)) { + return CurrentNetwork.builder(NetworkState.TRANSPORT_VPN).carrier(carrier).build(); + } + // there is an active network, but it doesn't fall into the neat buckets above + return UNKNOWN_NETWORK; + } + + // visible for testing + boolean hasPermission(String permission) { + return ActivityCompat.checkSelfPermission(context, permission) + == PackageManager.PERMISSION_GRANTED; + } + + private String getDataNetworkTypeName(int dataNetworkType) { + switch (dataNetworkType) { + case TelephonyManager.NETWORK_TYPE_1xRTT: + return "1xRTT"; + case TelephonyManager.NETWORK_TYPE_CDMA: + return "CDMA"; + case TelephonyManager.NETWORK_TYPE_EDGE: + return "EDGE"; + case TelephonyManager.NETWORK_TYPE_EHRPD: + return "EHRPD"; + case TelephonyManager.NETWORK_TYPE_EVDO_0: + return "EVDO_0"; + case TelephonyManager.NETWORK_TYPE_EVDO_A: + return "EVDO_A"; + case TelephonyManager.NETWORK_TYPE_EVDO_B: + return "EVDO_B"; + case TelephonyManager.NETWORK_TYPE_GPRS: + return "GPRS"; + case TelephonyManager.NETWORK_TYPE_GSM: + return "GSM"; + case TelephonyManager.NETWORK_TYPE_HSDPA: + return "HSDPA"; + case TelephonyManager.NETWORK_TYPE_HSPA: + return "HSPA"; + case TelephonyManager.NETWORK_TYPE_HSPAP: + return "HSPAP"; + case TelephonyManager.NETWORK_TYPE_HSUPA: + return "HSUPA"; + case TelephonyManager.NETWORK_TYPE_IDEN: + return "IDEN"; + case TelephonyManager.NETWORK_TYPE_IWLAN: + return "IWLAN"; + case TelephonyManager.NETWORK_TYPE_LTE: + return "LTE"; + case TelephonyManager.NETWORK_TYPE_NR: + return "NR"; + case TelephonyManager.NETWORK_TYPE_TD_SCDMA: + return "SCDMA"; + case TelephonyManager.NETWORK_TYPE_UMTS: + return "UMTS"; + case TelephonyManager.NETWORK_TYPE_UNKNOWN: + return "UNKNOWN"; + } + return "UNKNOWN"; + } +} diff --git a/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/instrumentation/network/SimpleNetworkDetector.java b/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/instrumentation/network/SimpleNetworkDetector.java new file mode 100644 index 000000000..d24666c16 --- /dev/null +++ b/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/instrumentation/network/SimpleNetworkDetector.java @@ -0,0 +1,56 @@ +/* + * Copyright Splunk Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.opentelemetry.rum.internal.instrumentation.network; + +import static io.opentelemetry.rum.internal.instrumentation.network.CurrentNetworkProvider.NO_NETWORK; +import static io.opentelemetry.rum.internal.instrumentation.network.CurrentNetworkProvider.UNKNOWN_NETWORK; + +import android.net.ConnectivityManager; +import android.net.NetworkInfo; + +class SimpleNetworkDetector implements NetworkDetector { + private final ConnectivityManager connectivityManager; + + SimpleNetworkDetector(ConnectivityManager connectivityManager) { + this.connectivityManager = connectivityManager; + } + + @Override + public CurrentNetwork detectCurrentNetwork() { + NetworkInfo activeNetwork = + connectivityManager.getActiveNetworkInfo(); // Deprecated in API 29 + if (activeNetwork == null) { + return NO_NETWORK; + } + switch (activeNetwork.getType()) { + case ConnectivityManager.TYPE_MOBILE: // Deprecated in API 28 + return CurrentNetwork.builder(NetworkState.TRANSPORT_CELLULAR) + .subType(activeNetwork.getSubtypeName()) + .build(); + case ConnectivityManager.TYPE_WIFI: // Deprecated in API 28 + return CurrentNetwork.builder(NetworkState.TRANSPORT_WIFI) + .subType(activeNetwork.getSubtypeName()) + .build(); + case ConnectivityManager.TYPE_VPN: + return CurrentNetwork.builder(NetworkState.TRANSPORT_VPN) + .subType(activeNetwork.getSubtypeName()) + .build(); + } + // there is an active network, but it doesn't fall into the neat buckets above + return UNKNOWN_NETWORK; + } +} diff --git a/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/instrumentation/slowrendering/SlowRenderListener.java b/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/instrumentation/slowrendering/SlowRenderListener.java new file mode 100644 index 000000000..3f717a195 --- /dev/null +++ b/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/instrumentation/slowrendering/SlowRenderListener.java @@ -0,0 +1,217 @@ +/* + * Copyright Splunk Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.opentelemetry.rum.internal.instrumentation.slowrendering; + +import static android.view.FrameMetrics.DRAW_DURATION; +import static android.view.FrameMetrics.FIRST_DRAW_FRAME; + +import android.app.Activity; +import android.os.Build; +import android.os.Handler; +import android.os.HandlerThread; +import android.os.Looper; +import android.util.Log; +import android.util.SparseIntArray; +import android.view.FrameMetrics; +import android.view.Window; +import androidx.annotation.GuardedBy; +import androidx.annotation.NonNull; +import androidx.annotation.RequiresApi; +import io.opentelemetry.api.trace.Span; +import io.opentelemetry.api.trace.Tracer; +import io.opentelemetry.rum.internal.DefaultingActivityLifecycleCallbacks; +import io.opentelemetry.rum.internal.RumConstants; +import java.time.Duration; +import java.time.Instant; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; + +@RequiresApi(api = Build.VERSION_CODES.N) +class SlowRenderListener implements DefaultingActivityLifecycleCallbacks { + + static final int SLOW_THRESHOLD_MS = 16; + static final int FROZEN_THRESHOLD_MS = 700; + + private static final int NANOS_PER_MS = (int) TimeUnit.MILLISECONDS.toNanos(1); + // rounding value adds half a millisecond, for rounding to nearest ms + private static final int NANOS_ROUNDING_VALUE = NANOS_PER_MS / 2; + + private static final HandlerThread frameMetricsThread = + new HandlerThread("FrameMetricsCollector"); + + private final Tracer tracer; + private final ScheduledExecutorService executorService; + private final Handler frameMetricsHandler; + private final Duration pollInterval; + + private final ConcurrentMap activities = + new ConcurrentHashMap<>(); + + SlowRenderListener(Tracer tracer, Duration pollInterval) { + this( + tracer, + Executors.newScheduledThreadPool(1), + new Handler(startFrameMetricsLoop()), + pollInterval); + } + + // Exists for testing + SlowRenderListener( + Tracer tracer, + ScheduledExecutorService executorService, + Handler frameMetricsHandler, + Duration pollInterval) { + this.tracer = tracer; + this.executorService = executorService; + this.frameMetricsHandler = frameMetricsHandler; + this.pollInterval = pollInterval; + } + + private static Looper startFrameMetricsLoop() { + // just a precaution: this is supposed to be called only once, and the thread should always + // be not started here + if (!frameMetricsThread.isAlive()) { + frameMetricsThread.start(); + } + return frameMetricsThread.getLooper(); + } + + // the returned future is very unlikely to fail + @SuppressWarnings("FutureReturnValueIgnored") + void start() { + executorService.scheduleAtFixedRate( + this::reportSlowRenders, + pollInterval.toMillis(), + pollInterval.toMillis(), + TimeUnit.MILLISECONDS); + } + + @Override + public void onActivityResumed(@NonNull Activity activity) { + PerActivityListener listener = new PerActivityListener(activity); + PerActivityListener existing = activities.putIfAbsent(activity, listener); + if (existing == null) { + activity.getWindow().addOnFrameMetricsAvailableListener(listener, frameMetricsHandler); + } + } + + @Override + public void onActivityPaused(@NonNull Activity activity) { + PerActivityListener listener = activities.remove(activity); + if (listener != null) { + activity.getWindow().removeOnFrameMetricsAvailableListener(listener); + reportSlow(listener); + } + } + + static class PerActivityListener implements Window.OnFrameMetricsAvailableListener { + + private final Activity activity; + private final Object lock = new Object(); + + @GuardedBy("lock") + private SparseIntArray drawDurationHistogram = new SparseIntArray(); + + PerActivityListener(Activity activity) { + this.activity = activity; + } + + @Override + public void onFrameMetricsAvailable( + Window window, FrameMetrics frameMetrics, int dropCountSinceLastInvocation) { + + long firstDrawFrame = frameMetrics.getMetric(FIRST_DRAW_FRAME); + if (firstDrawFrame == 1) { + return; + } + + long drawDurationsNs = frameMetrics.getMetric(DRAW_DURATION); + // ignore values < 0; something must have gone wrong + if (drawDurationsNs >= 0) { + synchronized (lock) { + // calculation copied from FrameMetricsAggregator + int durationMs = + (int) ((drawDurationsNs + NANOS_ROUNDING_VALUE) / NANOS_PER_MS); + int oldValue = drawDurationHistogram.get(durationMs); + drawDurationHistogram.put(durationMs, (oldValue + 1)); + } + } + } + + SparseIntArray resetMetrics() { + synchronized (lock) { + SparseIntArray metrics = drawDurationHistogram; + drawDurationHistogram = new SparseIntArray(); + return metrics; + } + } + + public String getActivityName() { + return activity.getComponentName().flattenToShortString(); + } + } + + private void reportSlowRenders() { + try { + activities.forEach((activity, listener) -> reportSlow(listener)); + } catch (Exception e) { + Log.w(RumConstants.OTEL_RUM_LOG_TAG, "Exception while processing frame metrics", e); + } + } + + private void reportSlow(PerActivityListener listener) { + int slowCount = 0; + int frozenCount = 0; + SparseIntArray durationToCountHistogram = listener.resetMetrics(); + for (int i = 0; i < durationToCountHistogram.size(); i++) { + int duration = durationToCountHistogram.keyAt(i); + int count = durationToCountHistogram.get(duration); + if (duration > FROZEN_THRESHOLD_MS) { + Log.d( + RumConstants.OTEL_RUM_LOG_TAG, + "* FROZEN RENDER DETECTED: " + duration + " ms." + count + " times"); + frozenCount += count; + } else if (duration > SLOW_THRESHOLD_MS) { + Log.d( + RumConstants.OTEL_RUM_LOG_TAG, + "* Slow render detected: " + duration + " ms. " + count + " times"); + slowCount += count; + } + } + + Instant now = Instant.now(); + if (slowCount > 0) { + makeSpan("slowRenders", listener.getActivityName(), slowCount, now); + } + if (frozenCount > 0) { + makeSpan("frozenRenders", listener.getActivityName(), frozenCount, now); + } + } + + private void makeSpan(String spanName, String activityName, int slowCount, Instant now) { + Span span = + tracer.spanBuilder(spanName) + .setAttribute("count", slowCount) + .setAttribute("activity.name", activityName) + .setStartTimestamp(now) + .startSpan(); + span.end(now); + } +} diff --git a/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/instrumentation/slowrendering/SlowRenderingDetector.java b/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/instrumentation/slowrendering/SlowRenderingDetector.java new file mode 100644 index 000000000..152cfe3f2 --- /dev/null +++ b/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/instrumentation/slowrendering/SlowRenderingDetector.java @@ -0,0 +1,69 @@ +/* + * Copyright Splunk Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.opentelemetry.rum.internal.instrumentation.slowrendering; + +import android.os.Build; +import android.util.Log; +import io.opentelemetry.rum.internal.RumConstants; +import io.opentelemetry.rum.internal.instrumentation.InstrumentedApplication; +import java.time.Duration; + +/** + * Entrypoint for installing the slow rendering detection instrumentation. + * + *

This class is internal and not for public use. Its APIs are unstable and can change at any + * time. + */ +public final class SlowRenderingDetector { + + public static SlowRenderingDetector create() { + return builder().build(); + } + + public static SlowRenderingDetectorBuilder builder() { + return new SlowRenderingDetectorBuilder(); + } + + private final Duration slowRenderingDetectionPollInterval; + + SlowRenderingDetector(SlowRenderingDetectorBuilder builder) { + this.slowRenderingDetectionPollInterval = builder.slowRenderingDetectionPollInterval; + } + + /** + * Installs the slow rendering detection instrumentation on the given {@link + * InstrumentedApplication}. + */ + public void installOn(InstrumentedApplication instrumentedApplication) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) { + Log.w( + RumConstants.OTEL_RUM_LOG_TAG, + "Slow/frozen rendering detection is not supported on platforms older than Android N (SDK version 24)."); + return; + } + + SlowRenderListener detector = + new SlowRenderListener( + instrumentedApplication + .getOpenTelemetrySdk() + .getTracer("io.opentelemetry.slow-rendering"), + slowRenderingDetectionPollInterval); + + instrumentedApplication.getApplication().registerActivityLifecycleCallbacks(detector); + detector.start(); + } +} diff --git a/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/instrumentation/slowrendering/SlowRenderingDetectorBuilder.java b/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/instrumentation/slowrendering/SlowRenderingDetectorBuilder.java new file mode 100644 index 000000000..4ec87c72e --- /dev/null +++ b/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/instrumentation/slowrendering/SlowRenderingDetectorBuilder.java @@ -0,0 +1,57 @@ +/* + * Copyright Splunk Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.opentelemetry.rum.internal.instrumentation.slowrendering; + +import android.util.Log; +import io.opentelemetry.rum.internal.RumConstants; +import java.time.Duration; + +/** + * A builder of {@link SlowRenderingDetector}. + * + *

This class is internal and not for public use. Its APIs are unstable and can change at any + * time. + */ +public final class SlowRenderingDetectorBuilder { + + SlowRenderingDetectorBuilder() {} + + Duration slowRenderingDetectionPollInterval = Duration.ofSeconds(1); + + /** + * Configures the rate at which frame render durations are polled. + * + * @param interval The period that should be used for polling. + * @return {@code this} + */ + public SlowRenderingDetectorBuilder setSlowRenderingDetectionPollInterval(Duration interval) { + if (interval.toMillis() <= 0) { + Log.e( + RumConstants.OTEL_RUM_LOG_TAG, + "Invalid slowRenderingDetectionPollInterval: " + + interval + + "; must be positive"); + return this; + } + this.slowRenderingDetectionPollInterval = interval; + return this; + } + + public SlowRenderingDetector build() { + return new SlowRenderingDetector(this); + } +} diff --git a/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/instrumentation/startup/AppStartupTimer.java b/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/instrumentation/startup/AppStartupTimer.java new file mode 100644 index 000000000..259b22b23 --- /dev/null +++ b/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/instrumentation/startup/AppStartupTimer.java @@ -0,0 +1,164 @@ +/* + * Copyright Splunk Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.opentelemetry.rum.internal.instrumentation.startup; + +import android.app.Activity; +import android.app.Application; +import android.os.Bundle; +import android.os.Handler; +import android.util.Log; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import io.opentelemetry.api.trace.Span; +import io.opentelemetry.api.trace.Tracer; +import io.opentelemetry.rum.internal.DefaultingActivityLifecycleCallbacks; +import io.opentelemetry.rum.internal.RumConstants; +import io.opentelemetry.rum.internal.util.AnchoredClock; +import io.opentelemetry.sdk.common.Clock; +import java.util.concurrent.TimeUnit; + +public class AppStartupTimer { + // Maximum time from app start to creation of the UI. If this time is exceeded we will not + // create the app start span. Long app startup could indicate that the app was really started in + // background, in which case the measured startup time is misleading. + private static final long MAX_TIME_TO_UI_INIT = TimeUnit.MINUTES.toNanos(1); + + // exposed so it can be used for the rest of the startup sequence timing. + private final AnchoredClock startupClock = AnchoredClock.create(Clock.getDefault()); + private final long firstPossibleTimestamp = startupClock.now(); + @Nullable private volatile Span overallAppStartSpan = null; + @Nullable private volatile Runnable completionCallback = null; + // whether activity has been created + // accessed only from UI thread + private boolean uiInitStarted = false; + // whether MAX_TIME_TO_UI_INIT has been exceeded + // accessed only from UI thread + private boolean uiInitTooLate = false; + // accessed only from UI thread + private boolean isStartedFromBackground = false; + + public Span start(Tracer tracer) { + // guard against a double-start and just return what's already in flight. + if (overallAppStartSpan != null) { + return overallAppStartSpan; + } + final Span appStart = + tracer.spanBuilder("AppStart") + .setStartTimestamp(firstPossibleTimestamp, TimeUnit.NANOSECONDS) + .setAttribute(RumConstants.START_TYPE_KEY, "cold") + .startSpan(); + overallAppStartSpan = appStart; + return appStart; + } + + /** + * @return epoch timestamp in nanos calculated by the startupClock. + */ + public long clockNow() { + return startupClock.now(); + } + + /** + * Creates a lifecycle listener that starts the UI init when an activity is created. + * + * @return a new Application.ActivityLifecycleCallbacks instance + */ + public Application.ActivityLifecycleCallbacks createLifecycleCallback() { + return new DefaultingActivityLifecycleCallbacks() { + @Override + public void onActivityCreated( + @NonNull Activity activity, @Nullable Bundle savedInstanceState) { + startUiInit(); + } + }; + } + + /** Called when Activity is created. */ + private void startUiInit() { + if (uiInitStarted || isStartedFromBackground) { + return; + } + uiInitStarted = true; + if (firstPossibleTimestamp + MAX_TIME_TO_UI_INIT < startupClock.now()) { + Log.d(RumConstants.OTEL_RUM_LOG_TAG, "Max time to UI init exceeded"); + uiInitTooLate = true; + clear(); + } + } + + public void setCompletionCallback(Runnable completionCallback) { + this.completionCallback = completionCallback; + } + + public void end() { + Span overallAppStartSpan = this.overallAppStartSpan; + if (overallAppStartSpan != null && !uiInitTooLate && !isStartedFromBackground) { + runCompletionCallback(); + overallAppStartSpan.end(startupClock.now(), TimeUnit.NANOSECONDS); + } + clear(); + } + + @Nullable + public Span getStartupSpan() { + return overallAppStartSpan; + } + + // visibleForTesting + public void runCompletionCallback() { + Runnable completionCallback = this.completionCallback; + if (completionCallback != null) { + completionCallback.run(); + } + } + + private void clear() { + overallAppStartSpan = null; + completionCallback = null; + } + + public void detectBackgroundStart(Handler handler) { + handler.post(new StartFromBackgroundRunnable(this)); + } + + /** + * See + * https://github.com/firebase/firebase-android-sdk/blob/939f90edd74373d42772518d04826657c2ef2e21/firebase-perf/src/main/java/com/google/firebase/perf/metrics/AppStartTrace.java#L283 + * When a runnable posted to main UI thread is executed before any activity's onCreate() method + * then the app is started in background. If app is started from foreground, activity's + * onCreate() method is executed before this runnable. Firebase does this check from a + * ContentProvider, we do it from whatever used OpenTelemetryRum first. If the first use of + * OpenTelemetryRum happens when the app is already started for us it will look the same as a + * background start, which is fine as it wouldn't report correct time anyway. + */ + private static class StartFromBackgroundRunnable implements Runnable { + private final AppStartupTimer startupTimer; + + public StartFromBackgroundRunnable(AppStartupTimer startupTimer) { + this.startupTimer = startupTimer; + } + + @Override + public void run() { + // check whether an activity has been created + if (!startupTimer.uiInitStarted) { + Log.d(RumConstants.OTEL_RUM_LOG_TAG, "Detected background app start"); + startupTimer.isStartedFromBackground = true; + } + } + } +} diff --git a/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/util/ActiveSpan.java b/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/util/ActiveSpan.java new file mode 100644 index 000000000..2e4430bfc --- /dev/null +++ b/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/util/ActiveSpan.java @@ -0,0 +1,77 @@ +/* + * Copyright Splunk Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.opentelemetry.rum.internal.util; + +import static io.opentelemetry.rum.internal.RumConstants.LAST_SCREEN_NAME_KEY; + +import androidx.annotation.Nullable; +import io.opentelemetry.api.trace.Span; +import io.opentelemetry.context.Scope; +import java.util.function.Supplier; + +public class ActiveSpan { + private final Supplier lastVisibleScreen; + + @Nullable private Span span; + @Nullable private Scope scope; + + public ActiveSpan(Supplier lastVisibleScreen) { + this.lastVisibleScreen = lastVisibleScreen; + } + + public boolean spanInProgress() { + return span != null; + } + + // it's fine to not close the scope here, will be closed in endActiveSpan() + @SuppressWarnings("MustBeClosedChecker") + public void startSpan(Supplier spanCreator) { + // don't start one if there's already one in progress + if (span != null) { + return; + } + this.span = spanCreator.get(); + scope = span.makeCurrent(); + } + + public void endActiveSpan() { + if (scope != null) { + scope.close(); + scope = null; + } + if (this.span != null) { + this.span.end(); + this.span = null; + } + } + + public void addEvent(String eventName) { + if (span != null) { + span.addEvent(eventName); + } + } + + public void addPreviousScreenAttribute(String screenName) { + if (span == null) { + return; + } + String previouslyVisibleScreen = lastVisibleScreen.get(); + if (previouslyVisibleScreen != null && !screenName.equals(previouslyVisibleScreen)) { + span.setAttribute(LAST_SCREEN_NAME_KEY, previouslyVisibleScreen); + } + } +} diff --git a/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/util/AnchoredClock.java b/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/util/AnchoredClock.java new file mode 100644 index 000000000..ce3c2260f --- /dev/null +++ b/opentelemetry-android-instrumentation/src/main/java/io/opentelemetry/rum/internal/util/AnchoredClock.java @@ -0,0 +1,41 @@ +/* + * Copyright Splunk Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.opentelemetry.rum.internal.util; + +import io.opentelemetry.sdk.common.Clock; + +// copied from otel-java +public final class AnchoredClock { + private final Clock clock; + private final long epochNanos; + private final long nanoTime; + + private AnchoredClock(Clock clock, long epochNanos, long nanoTime) { + this.clock = clock; + this.epochNanos = epochNanos; + this.nanoTime = nanoTime; + } + + public static AnchoredClock create(Clock clock) { + return new AnchoredClock(clock, clock.now(), clock.nanoTime()); + } + + public long now() { + long deltaNanos = this.clock.nanoTime() - this.nanoTime; + return this.epochNanos + deltaNanos; + } +} diff --git a/opentelemetry-android-instrumentation/src/test/java/io/opentelemetry/rum/internal/AndroidResourceTest.java b/opentelemetry-android-instrumentation/src/test/java/io/opentelemetry/rum/internal/AndroidResourceTest.java new file mode 100644 index 000000000..0b65acda5 --- /dev/null +++ b/opentelemetry-android-instrumentation/src/test/java/io/opentelemetry/rum/internal/AndroidResourceTest.java @@ -0,0 +1,97 @@ +/* + * Copyright Splunk Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.opentelemetry.rum.internal; + +import static io.opentelemetry.rum.internal.RumConstants.RUM_SDK_VERSION; +import static io.opentelemetry.semconv.resource.attributes.ResourceAttributes.DEVICE_MODEL_IDENTIFIER; +import static io.opentelemetry.semconv.resource.attributes.ResourceAttributes.DEVICE_MODEL_NAME; +import static io.opentelemetry.semconv.resource.attributes.ResourceAttributes.OS_NAME; +import static io.opentelemetry.semconv.resource.attributes.ResourceAttributes.OS_TYPE; +import static io.opentelemetry.semconv.resource.attributes.ResourceAttributes.OS_VERSION; +import static io.opentelemetry.semconv.resource.attributes.ResourceAttributes.SERVICE_NAME; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.when; + +import android.app.Application; +import android.content.pm.ApplicationInfo; +import android.os.Build; +import io.opentelemetry.sdk.resources.Resource; +import opentelemetry.rum.instrumentation.R; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Answers; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class AndroidResourceTest { + + String appName = "robotron"; + String rumSdkVersion = "1.2.3"; + + @Mock(answer = Answers.RETURNS_DEEP_STUBS) + Application app; + + @Test + void testFullResource() { + ApplicationInfo appInfo = new ApplicationInfo(); + appInfo.labelRes = 12345; + when(app.getApplicationContext().getApplicationInfo()).thenReturn(appInfo); + when(app.getApplicationContext().getString(appInfo.labelRes)).thenReturn(appName); + when(app.getApplicationContext().getResources().getString(R.string.rum_version)) + .thenReturn(rumSdkVersion); + + Resource expected = + Resource.getDefault() + .merge( + Resource.builder() + .put(SERVICE_NAME, appName) + .put(RUM_SDK_VERSION, rumSdkVersion) + .put(DEVICE_MODEL_NAME, Build.MODEL) + .put(DEVICE_MODEL_IDENTIFIER, Build.MODEL) + .put(OS_NAME, "Android") + .put(OS_TYPE, "linux") + .put(OS_VERSION, Build.VERSION.RELEASE) + .build()); + + Resource result = AndroidResource.createDefault(app); + assertEquals(expected, result); + } + + @Test + void testProblematicContext() { + when(app.getApplicationContext().getApplicationInfo()) + .thenThrow(new SecurityException("cannot do that")); + when(app.getApplicationContext().getResources()).thenThrow(new SecurityException("boom")); + + Resource expected = + Resource.getDefault() + .merge( + Resource.builder() + .put(SERVICE_NAME, "unknown_service:android") + .put(RUM_SDK_VERSION, "unknown") + .put(DEVICE_MODEL_NAME, Build.MODEL) + .put(DEVICE_MODEL_IDENTIFIER, Build.MODEL) + .put(OS_NAME, "Android") + .put(OS_TYPE, "linux") + .put(OS_VERSION, Build.VERSION.RELEASE) + .build()); + + Resource result = AndroidResource.createDefault(app); + assertEquals(expected, result); + } +} diff --git a/opentelemetry-android-instrumentation/src/test/java/io/opentelemetry/rum/internal/ApplicationStateWatcherTest.java b/opentelemetry-android-instrumentation/src/test/java/io/opentelemetry/rum/internal/ApplicationStateWatcherTest.java new file mode 100644 index 000000000..618278a02 --- /dev/null +++ b/opentelemetry-android-instrumentation/src/test/java/io/opentelemetry/rum/internal/ApplicationStateWatcherTest.java @@ -0,0 +1,74 @@ +/* + * Copyright Splunk Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.opentelemetry.rum.internal; + +import static org.mockito.Mockito.inOrder; + +import android.app.Activity; +import io.opentelemetry.rum.internal.instrumentation.ApplicationStateListener; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InOrder; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class ApplicationStateWatcherTest { + + @Mock Activity activity; + @Mock ApplicationStateListener listener1; + @Mock ApplicationStateListener listener2; + + ApplicationStateWatcher underTest; + + @BeforeEach + void setUp() { + underTest = new ApplicationStateWatcher(); + underTest.registerListener(listener1); + underTest.registerListener(listener2); + } + + @Test + void appForegrounded() { + underTest.onActivityStarted(activity); + underTest.onActivityStarted(activity); + underTest.onActivityStopped(activity); + underTest.onActivityStarted(activity); + underTest.onActivityStopped(activity); + + InOrder io = inOrder(listener1, listener2); + io.verify(listener1).onApplicationForegrounded(); + io.verify(listener2).onApplicationForegrounded(); + io.verifyNoMoreInteractions(); + } + + @Test + void appBackgrounded() { + underTest.onActivityStarted(activity); + underTest.onActivityStarted(activity); + underTest.onActivityStopped(activity); + underTest.onActivityStopped(activity); + + InOrder io = inOrder(listener1, listener2); + io.verify(listener1).onApplicationForegrounded(); + io.verify(listener2).onApplicationForegrounded(); + io.verify(listener1).onApplicationBackgrounded(); + io.verify(listener2).onApplicationBackgrounded(); + io.verifyNoMoreInteractions(); + } +} diff --git a/opentelemetry-android-instrumentation/src/test/java/io/opentelemetry/rum/internal/GlobalAttributesSpanAppenderTest.java b/opentelemetry-android-instrumentation/src/test/java/io/opentelemetry/rum/internal/GlobalAttributesSpanAppenderTest.java new file mode 100644 index 000000000..6660cc793 --- /dev/null +++ b/opentelemetry-android-instrumentation/src/test/java/io/opentelemetry/rum/internal/GlobalAttributesSpanAppenderTest.java @@ -0,0 +1,56 @@ +/* + * Copyright Splunk Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.opentelemetry.rum.internal; + +import static io.opentelemetry.api.common.AttributeKey.longKey; +import static io.opentelemetry.api.common.AttributeKey.stringKey; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.verify; + +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.context.Context; +import io.opentelemetry.sdk.trace.ReadWriteSpan; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class GlobalAttributesSpanAppenderTest { + + @Mock private ReadWriteSpan span; + + private final GlobalAttributesSpanAppender globalAttributes = + GlobalAttributesSpanAppender.create(Attributes.of(stringKey("key"), "value")); + + @Test + void shouldAppendGlobalAttributes() { + globalAttributes.update(attributesBuilder -> attributesBuilder.put("key", "value2")); + globalAttributes.update( + attributesBuilder -> attributesBuilder.put(longKey("otherKey"), 1234L)); + + assertTrue(globalAttributes.isStartRequired()); + globalAttributes.onStart(Context.root(), span); + + verify(span) + .setAllAttributes( + Attributes.of(stringKey("key"), "value2", longKey("otherKey"), 1234L)); + + assertFalse(globalAttributes.isEndRequired()); + } +} diff --git a/opentelemetry-android-instrumentation/src/test/java/io/opentelemetry/rum/internal/OpenTelemetryRumBuilderTest.java b/opentelemetry-android-instrumentation/src/test/java/io/opentelemetry/rum/internal/OpenTelemetryRumBuilderTest.java new file mode 100644 index 000000000..97cdac65c --- /dev/null +++ b/opentelemetry-android-instrumentation/src/test/java/io/opentelemetry/rum/internal/OpenTelemetryRumBuilderTest.java @@ -0,0 +1,106 @@ +/* + * Copyright Splunk Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.opentelemetry.rum.internal; + +import static io.opentelemetry.rum.internal.RumConstants.SESSION_ID_KEY; +import static io.opentelemetry.sdk.testing.assertj.OpenTelemetryAssertions.assertThat; +import static io.opentelemetry.sdk.testing.assertj.OpenTelemetryAssertions.equalTo; +import static org.mockito.ArgumentMatchers.isA; +import static org.mockito.Mockito.verify; + +import android.app.Activity; +import android.app.Application; +import io.opentelemetry.rum.internal.instrumentation.ApplicationStateListener; +import io.opentelemetry.sdk.resources.Resource; +import io.opentelemetry.sdk.testing.exporter.InMemorySpanExporter; +import io.opentelemetry.sdk.trace.data.SpanData; +import io.opentelemetry.sdk.trace.export.SimpleSpanProcessor; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class OpenTelemetryRumBuilderTest { + + final Resource resource = + Resource.getDefault().toBuilder().put("test.attribute", "abcdef").build(); + final InMemorySpanExporter spanExporter = InMemorySpanExporter.create(); + + @Mock Application application; + @Mock Activity activity; + @Mock ApplicationStateListener listener; + + @Captor ArgumentCaptor activityCallbacksCaptor; + + @Test + void shouldRegisterApplicationStateWatcher() { + OpenTelemetryRum.builder(application).build(); + + verify(application).registerActivityLifecycleCallbacks(isA(ApplicationStateWatcher.class)); + } + + @Test + void shouldBuildTracerProvider() { + OpenTelemetryRum openTelemetryRum = + OpenTelemetryRum.builder(application) + .setResource(resource) + .addTracerProviderCustomizer( + (tracerProviderBuilder, app) -> + tracerProviderBuilder.addSpanProcessor( + SimpleSpanProcessor.create(spanExporter))) + .build(); + + String sessionId = openTelemetryRum.getRumSessionId(); + openTelemetryRum + .getOpenTelemetry() + .getTracer("test") + .spanBuilder("test span") + .startSpan() + .end(); + + List spans = spanExporter.getFinishedSpanItems(); + assertThat(spans).hasSize(1); + assertThat(spans.get(0)) + .hasName("test span") + .hasResource(resource) + .hasAttributesSatisfyingExactly(equalTo(SESSION_ID_KEY, sessionId)); + } + + @Test + void shouldInstallInstrumentation() { + OpenTelemetryRum.builder(application) + .addInstrumentation( + instrumentedApplication -> { + assertThat(instrumentedApplication.getApplication()) + .isSameAs(application); + instrumentedApplication.registerApplicationStateListener(listener); + }) + .build(); + + verify(application).registerActivityLifecycleCallbacks(activityCallbacksCaptor.capture()); + + activityCallbacksCaptor.getValue().onActivityStarted(activity); + verify(listener).onApplicationForegrounded(); + + activityCallbacksCaptor.getValue().onActivityStopped(activity); + verify(listener).onApplicationBackgrounded(); + } +} diff --git a/opentelemetry-android-instrumentation/src/test/java/io/opentelemetry/rum/internal/RuntimeDetailsExtractorTest.java b/opentelemetry-android-instrumentation/src/test/java/io/opentelemetry/rum/internal/RuntimeDetailsExtractorTest.java new file mode 100644 index 000000000..2dbae3ea4 --- /dev/null +++ b/opentelemetry-android-instrumentation/src/test/java/io/opentelemetry/rum/internal/RuntimeDetailsExtractorTest.java @@ -0,0 +1,64 @@ +/* + * Copyright Splunk Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.opentelemetry.rum.internal; + +import static io.opentelemetry.context.Context.root; +import static io.opentelemetry.rum.internal.RumConstants.BATTERY_PERCENT_KEY; +import static io.opentelemetry.rum.internal.RumConstants.HEAP_FREE_KEY; +import static io.opentelemetry.rum.internal.RumConstants.STORAGE_SPACE_FREE_KEY; +import static io.opentelemetry.sdk.testing.assertj.OpenTelemetryAssertions.assertThat; +import static org.mockito.Mockito.when; + +import android.content.Context; +import android.content.Intent; +import android.os.BatteryManager; +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.api.common.AttributesBuilder; +import java.io.File; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class RuntimeDetailsExtractorTest { + + @Mock Context context; + @Mock Intent intent; + @Mock File filesDir; + + @Test + void shouldCollectRuntimeDetails() { + when(context.getFilesDir()).thenReturn(filesDir); + when(filesDir.getFreeSpace()).thenReturn(4200L); + + Integer level = 690; + when(intent.getIntExtra(BatteryManager.EXTRA_LEVEL, -1)).thenReturn(level); + when(intent.getIntExtra(BatteryManager.EXTRA_SCALE, -1)).thenReturn(1000); + + RuntimeDetailsExtractor details = RuntimeDetailsExtractor.create(context); + details.onReceive(context, intent); + + AttributesBuilder attributes = Attributes.builder(); + details.onStart(attributes, root(), null); + assertThat(attributes.build()) + .hasSize(3) + .containsEntry(STORAGE_SPACE_FREE_KEY, 4200L) + .containsKey(HEAP_FREE_KEY) + .containsEntry(BATTERY_PERCENT_KEY, 69.0); + } +} diff --git a/opentelemetry-android-instrumentation/src/test/java/io/opentelemetry/rum/internal/SessionIdChangeTracerTest.java b/opentelemetry-android-instrumentation/src/test/java/io/opentelemetry/rum/internal/SessionIdChangeTracerTest.java new file mode 100644 index 000000000..d5a4fb1f6 --- /dev/null +++ b/opentelemetry-android-instrumentation/src/test/java/io/opentelemetry/rum/internal/SessionIdChangeTracerTest.java @@ -0,0 +1,55 @@ +/* + * Copyright Splunk Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.opentelemetry.rum.internal; + +import static io.opentelemetry.rum.internal.RumConstants.PREVIOUS_SESSION_ID_KEY; +import static org.junit.jupiter.api.Assertions.assertEquals; + +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.api.trace.Tracer; +import io.opentelemetry.sdk.testing.junit5.OpenTelemetryExtension; +import io.opentelemetry.sdk.trace.data.SpanData; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +class SessionIdChangeTracerTest { + @RegisterExtension final OpenTelemetryExtension otelTesting = OpenTelemetryExtension.create(); + + private SessionIdChangeListener underTest; + + @BeforeEach + void setup() { + Tracer tracer = otelTesting.getOpenTelemetry().getTracer("testTracer"); + underTest = new SessionIdChangeTracer(tracer); + } + + @Test + void shouldEmitSessionIdChangeSpan() { + underTest.onChange("123", "456"); + + List spans = otelTesting.getSpans(); + assertEquals(1, spans.size()); + SpanData span = spans.get(0); + assertEquals("sessionId.change", span.getName()); + Attributes attributes = span.getAttributes(); + assertEquals(1, attributes.size()); + assertEquals("123", attributes.get(PREVIOUS_SESSION_ID_KEY)); + // splunk.rumSessionId attribute is set in the RumAttributeAppender class + } +} diff --git a/opentelemetry-android-instrumentation/src/test/java/io/opentelemetry/rum/internal/SessionIdRatioBasedSamplerTest.java b/opentelemetry-android-instrumentation/src/test/java/io/opentelemetry/rum/internal/SessionIdRatioBasedSamplerTest.java new file mode 100644 index 000000000..bef9749ff --- /dev/null +++ b/opentelemetry-android-instrumentation/src/test/java/io/opentelemetry/rum/internal/SessionIdRatioBasedSamplerTest.java @@ -0,0 +1,105 @@ +/* + * Copyright Splunk Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.opentelemetry.rum.internal; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.when; + +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.api.trace.Span; +import io.opentelemetry.api.trace.SpanContext; +import io.opentelemetry.api.trace.SpanKind; +import io.opentelemetry.context.Context; +import io.opentelemetry.sdk.trace.IdGenerator; +import io.opentelemetry.sdk.trace.data.LinkData; +import io.opentelemetry.sdk.trace.samplers.Sampler; +import io.opentelemetry.sdk.trace.samplers.SamplingDecision; +import java.util.Collections; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class SessionIdRatioBasedSamplerTest { + private static final String HIGH_ID = "00000000000000008fffffffffffffff"; + private static final String LOW_ID = "00000000000000000000000000000000"; + private static final IdGenerator idsGenerator = IdGenerator.random(); + + @Mock SessionId sessionId; + private final String traceId = idsGenerator.generateTraceId(); + private final Context parentContext = Context.root().with(Span.getInvalid()); + private final List parentLinks = + Collections.singletonList(LinkData.create(SpanContext.getInvalid())); + + @Test + void samplerDropsHigh() { + when(sessionId.getSessionId()).thenReturn(HIGH_ID); + + SessionIdRatioBasedSampler sampler = new SessionIdRatioBasedSampler(0.5, sessionId); + + // Sampler drops if TraceIdRatioBasedSampler would drop this sessionId + assertEquals(shouldSample(sampler), SamplingDecision.DROP); + } + + @Test + void samplerKeepsLowestId() { + // Sampler accepts if TraceIdRatioBasedSampler would accept this sessionId + when(sessionId.getSessionId()).thenReturn(LOW_ID); + + SessionIdRatioBasedSampler sampler = new SessionIdRatioBasedSampler(0.5, sessionId); + assertEquals(shouldSample(sampler), SamplingDecision.RECORD_AND_SAMPLE); + } + + @Test + void zeroRatioDropsAll() { + when(sessionId.getSessionId()).thenReturn(HIGH_ID); + + SessionIdRatioBasedSampler samplerHigh = new SessionIdRatioBasedSampler(0.0, sessionId); + assertEquals(shouldSample(samplerHigh), SamplingDecision.DROP); + + when(sessionId.getSessionId()).thenReturn(LOW_ID); + + SessionIdRatioBasedSampler samplerLow = new SessionIdRatioBasedSampler(0.0, sessionId); + assertEquals(shouldSample(samplerLow), SamplingDecision.DROP); + } + + @Test + void oneRatioAcceptsAll() { + when(sessionId.getSessionId()).thenReturn(HIGH_ID); + + SessionIdRatioBasedSampler samplerHigh = new SessionIdRatioBasedSampler(1.0, sessionId); + assertEquals(shouldSample(samplerHigh), SamplingDecision.RECORD_AND_SAMPLE); + + when(sessionId.getSessionId()).thenReturn(LOW_ID); + + SessionIdRatioBasedSampler samplerLow = new SessionIdRatioBasedSampler(1.0, sessionId); + assertEquals(shouldSample(samplerLow), SamplingDecision.RECORD_AND_SAMPLE); + } + + private SamplingDecision shouldSample(Sampler sampler) { + return sampler.shouldSample( + parentContext, + traceId, + "name", + SpanKind.INTERNAL, + Attributes.empty(), + parentLinks) + .getDecision(); + } +} diff --git a/opentelemetry-android-instrumentation/src/test/java/io/opentelemetry/rum/internal/SessionIdSpanAppenderTest.java b/opentelemetry-android-instrumentation/src/test/java/io/opentelemetry/rum/internal/SessionIdSpanAppenderTest.java new file mode 100644 index 000000000..c065113b0 --- /dev/null +++ b/opentelemetry-android-instrumentation/src/test/java/io/opentelemetry/rum/internal/SessionIdSpanAppenderTest.java @@ -0,0 +1,51 @@ +/* + * Copyright Splunk Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.opentelemetry.rum.internal; + +import static io.opentelemetry.rum.internal.RumConstants.SESSION_ID_KEY; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import io.opentelemetry.context.Context; +import io.opentelemetry.sdk.trace.ReadWriteSpan; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class SessionIdSpanAppenderTest { + + @Mock SessionId sessionId; + @Mock ReadWriteSpan span; + + @Test + void shouldSetSessionIdAsSpanAttribute() { + when(sessionId.getSessionId()).thenReturn("42"); + + SessionIdSpanAppender underTest = new SessionIdSpanAppender(sessionId); + + assertTrue(underTest.isStartRequired()); + underTest.onStart(Context.root(), span); + + verify(span).setAttribute(SESSION_ID_KEY, "42"); + + assertFalse(underTest.isEndRequired()); + } +} diff --git a/opentelemetry-android-instrumentation/src/test/java/io/opentelemetry/rum/internal/SessionIdTest.java b/opentelemetry-android-instrumentation/src/test/java/io/opentelemetry/rum/internal/SessionIdTest.java new file mode 100644 index 000000000..0fa2facaa --- /dev/null +++ b/opentelemetry-android-instrumentation/src/test/java/io/opentelemetry/rum/internal/SessionIdTest.java @@ -0,0 +1,109 @@ +/* + * Copyright Splunk Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.opentelemetry.rum.internal; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import io.opentelemetry.sdk.testing.time.TestClock; +import java.util.concurrent.TimeUnit; +import java.util.regex.Pattern; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InOrder; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class SessionIdTest { + + @Mock SessionIdTimeoutHandler timeoutHandler; + + @Test + void valueValid() { + String sessionId = new SessionId(TestClock.create(), timeoutHandler).getSessionId(); + assertNotNull(sessionId); + assertEquals(32, sessionId.length()); + assertTrue(Pattern.compile("[a-f0-9]+").matcher(sessionId).matches()); + } + + @Test + void valueSameUntil4Hours() { + TestClock clock = TestClock.create(); + SessionId sessionId = new SessionId(clock, timeoutHandler); + String value = sessionId.getSessionId(); + assertEquals(value, sessionId.getSessionId()); + clock.advance(3, TimeUnit.HOURS); + assertEquals(value, sessionId.getSessionId()); + clock.advance(59, TimeUnit.MINUTES); + assertEquals(value, sessionId.getSessionId()); + clock.advance(59, TimeUnit.SECONDS); + assertEquals(value, sessionId.getSessionId()); + + // now it should change. + clock.advance(1, TimeUnit.SECONDS); + String newSessionId = sessionId.getSessionId(); + assertNotNull(newSessionId); + assertNotEquals(value, newSessionId); + } + + @Test + void shouldCallSessionIdChangeListener() { + TestClock clock = TestClock.create(); + SessionIdChangeListener listener = mock(SessionIdChangeListener.class); + SessionId sessionId = new SessionId(clock, timeoutHandler); + sessionId.setSessionIdChangeListener(listener); + + String firstSessionId = sessionId.getSessionId(); + clock.advance(3, TimeUnit.HOURS); + sessionId.getSessionId(); + verify(timeoutHandler, times(2)).bump(); + verify(listener, never()).onChange(anyString(), anyString()); + + clock.advance(1, TimeUnit.HOURS); + String secondSessionId = sessionId.getSessionId(); + InOrder io = inOrder(timeoutHandler, listener); + io.verify(timeoutHandler).bump(); + io.verify(listener).onChange(firstSessionId, secondSessionId); + io.verifyNoMoreInteractions(); + } + + @Test + void shouldCreateNewSessionIdAfterTimeout() { + SessionId sessionId = new SessionId(timeoutHandler); + + String value = sessionId.getSessionId(); + verify(timeoutHandler).bump(); + + assertEquals(value, sessionId.getSessionId()); + verify(timeoutHandler, times(2)).bump(); + + when(timeoutHandler.hasTimedOut()).thenReturn(true); + + assertNotEquals(value, sessionId.getSessionId()); + verify(timeoutHandler, times(3)).bump(); + } +} diff --git a/opentelemetry-android-instrumentation/src/test/java/io/opentelemetry/rum/internal/SessionIdTimeoutHandlerTest.java b/opentelemetry-android-instrumentation/src/test/java/io/opentelemetry/rum/internal/SessionIdTimeoutHandlerTest.java new file mode 100644 index 000000000..e0265ca73 --- /dev/null +++ b/opentelemetry-android-instrumentation/src/test/java/io/opentelemetry/rum/internal/SessionIdTimeoutHandlerTest.java @@ -0,0 +1,91 @@ +/* + * Copyright Splunk Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.opentelemetry.rum.internal; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import io.opentelemetry.sdk.testing.time.TestClock; +import java.time.Duration; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.Test; + +class SessionIdTimeoutHandlerTest { + + @Test + void shouldNeverTimeOutInForeground() { + TestClock clock = TestClock.create(); + SessionIdTimeoutHandler timeoutHandler = new SessionIdTimeoutHandler(clock); + + assertFalse(timeoutHandler.hasTimedOut()); + timeoutHandler.bump(); + + // never time out in foreground + clock.advance(Duration.ofHours(4)); + assertFalse(timeoutHandler.hasTimedOut()); + } + + @Test + void shouldApply15MinutesTimeoutToAppsInBackground() { + TestClock clock = TestClock.create(); + SessionIdTimeoutHandler timeoutHandler = new SessionIdTimeoutHandler(clock); + + timeoutHandler.onApplicationBackgrounded(); + timeoutHandler.bump(); + + assertFalse(timeoutHandler.hasTimedOut()); + timeoutHandler.bump(); + + // do not timeout if <15 minutes have passed + clock.advance(14, TimeUnit.MINUTES); + clock.advance(59, TimeUnit.SECONDS); + assertFalse(timeoutHandler.hasTimedOut()); + timeoutHandler.bump(); + + // restart the timeout counter after bump() + clock.advance(1, TimeUnit.MINUTES); + assertFalse(timeoutHandler.hasTimedOut()); + timeoutHandler.bump(); + + // timeout after 15 minutes + clock.advance(15, TimeUnit.MINUTES); + assertTrue(timeoutHandler.hasTimedOut()); + + // bump() resets the counter + timeoutHandler.bump(); + assertFalse(timeoutHandler.hasTimedOut()); + } + + @Test + void shouldApplyTimeoutToFirstSpanAfterAppBeingMovedToForeground() { + TestClock clock = TestClock.create(); + SessionIdTimeoutHandler timeoutHandler = new SessionIdTimeoutHandler(clock); + + timeoutHandler.onApplicationBackgrounded(); + timeoutHandler.bump(); + + // the first span after app is moved to the foreground gets timed out + timeoutHandler.onApplicationForegrounded(); + clock.advance(20, TimeUnit.MINUTES); + assertTrue(timeoutHandler.hasTimedOut()); + timeoutHandler.bump(); + + // after the initial span it's the same as the usual foreground scenario + clock.advance(Duration.ofHours(4)); + assertFalse(timeoutHandler.hasTimedOut()); + } +} diff --git a/opentelemetry-android-instrumentation/src/test/java/io/opentelemetry/rum/internal/export/AttributeModifyingSpanExporterTest.java b/opentelemetry-android-instrumentation/src/test/java/io/opentelemetry/rum/internal/export/AttributeModifyingSpanExporterTest.java new file mode 100644 index 000000000..2082730fb --- /dev/null +++ b/opentelemetry-android-instrumentation/src/test/java/io/opentelemetry/rum/internal/export/AttributeModifyingSpanExporterTest.java @@ -0,0 +1,138 @@ +/* + * Copyright Splunk Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.opentelemetry.rum.internal.export; + +import static io.opentelemetry.api.common.AttributeKey.stringKey; +import static io.opentelemetry.rum.internal.export.TestSpanHelper.span; +import static io.opentelemetry.sdk.testing.assertj.OpenTelemetryAssertions.assertThat; +import static io.opentelemetry.sdk.testing.assertj.OpenTelemetryAssertions.equalTo; +import static java.util.Collections.emptyMap; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import io.opentelemetry.api.common.AttributeKey; +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.sdk.common.CompletableResultCode; +import io.opentelemetry.sdk.trace.data.SpanData; +import io.opentelemetry.sdk.trace.export.SpanExporter; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.function.Function; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class AttributeModifyingSpanExporterTest { + + @Mock SpanExporter exporter; + @Captor ArgumentCaptor> spansCaptor; + + @Test + void testEmptyMap() { + SpanData span1 = span("span1"); + SpanData span2 = span("span2"); + SpanData span3 = span("span3"); + Collection spans = Arrays.asList(span1, span2, span3); + CompletableResultCode expectedResult = mock(CompletableResultCode.class); + when(exporter.export(spans)).thenReturn(expectedResult); + + AttributeModifyingSpanExporter underTest = + new AttributeModifyingSpanExporter(exporter, emptyMap()); + + CompletableResultCode result = underTest.export(spans); + assertSame(expectedResult, result); + } + + @Test + void testRemappedToNull() { + AttributeKey key = stringKey("foo"); + SpanData span1 = span("span1", Attributes.of(key, "bar")); + Collection originalSpans = Collections.singletonList(span1); + + Map, Function> remappers = new HashMap<>(); + remappers.put(key, s -> null); + + CompletableResultCode expectedResult = mock(CompletableResultCode.class); + when(exporter.export(spansCaptor.capture())).thenReturn(expectedResult); + + AttributeModifyingSpanExporter underTest = + new AttributeModifyingSpanExporter(exporter, remappers); + + underTest.export(originalSpans); + assertThat(spansCaptor.getValue()) + .satisfiesExactly(span -> assertThat(span).hasTotalAttributeCount(0)); + } + + @Test + void modify() { + Attributes attr1 = buildAttr(1); + SpanData span1 = span("span1", attr1); + Attributes attr2 = buildAttr(2); + SpanData span2 = span("span2", attr2); + Attributes attr3 = buildAttr(3); + SpanData span3 = span("span3", attr3); + Collection spans = Arrays.asList(span1, span2, span3); + Map, Function> modifiers = new HashMap<>(); + modifiers.put(stringKey("foo1"), x -> "" + x + x); + modifiers.put(stringKey("foo3"), x -> "3" + x + x); + modifiers.put(stringKey("boop2"), x -> "2" + x + x); + + CompletableResultCode expectedResult = mock(CompletableResultCode.class); + when(exporter.export(spansCaptor.capture())).thenReturn(expectedResult); + + AttributeModifyingSpanExporter underTest = + new AttributeModifyingSpanExporter(exporter, modifiers); + CompletableResultCode result = underTest.export(spans); + assertSame(expectedResult, result); + assertThat(spansCaptor.getValue()) + .satisfiesExactly( + s -> + assertThat(s) + .hasAttributesSatisfyingExactly( + equalTo(stringKey("foo1"), "bar1bar1"), + equalTo(stringKey("bar1"), "baz1"), + equalTo(stringKey("boop1"), "beep1")), + s -> + assertThat(s) + .hasAttributesSatisfyingExactly( + equalTo(stringKey("foo2"), "bar2"), + equalTo(stringKey("bar2"), "baz2"), + equalTo(stringKey("boop2"), "2beep2beep2")), + s -> + assertThat(s) + .hasAttributesSatisfyingExactly( + equalTo(stringKey("foo3"), "3bar3bar3"), + equalTo(stringKey("bar3"), "baz3"), + equalTo(stringKey("boop3"), "beep3"))); + } + + private static Attributes buildAttr(int num) { + return Attributes.builder() + .put("foo" + num, "bar" + num) + .put("bar" + num, "baz" + num) + .put("boop" + num, "beep" + num) + .build(); + } +} diff --git a/opentelemetry-android-instrumentation/src/test/java/io/opentelemetry/rum/internal/export/FilteringSpanExporterTest.java b/opentelemetry-android-instrumentation/src/test/java/io/opentelemetry/rum/internal/export/FilteringSpanExporterTest.java new file mode 100644 index 000000000..ffa124554 --- /dev/null +++ b/opentelemetry-android-instrumentation/src/test/java/io/opentelemetry/rum/internal/export/FilteringSpanExporterTest.java @@ -0,0 +1,85 @@ +/* + * Copyright Splunk Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.opentelemetry.rum.internal.export; + +import static io.opentelemetry.api.common.AttributeKey.stringKey; +import static io.opentelemetry.rum.internal.export.TestSpanHelper.span; +import static io.opentelemetry.sdk.testing.assertj.OpenTelemetryAssertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import io.opentelemetry.api.common.AttributeKey; +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.sdk.common.CompletableResultCode; +import io.opentelemetry.sdk.trace.data.SpanData; +import io.opentelemetry.sdk.trace.export.SpanExporter; +import java.util.Arrays; +import java.util.Collection; +import java.util.HashMap; +import java.util.Map; +import java.util.function.Predicate; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class FilteringSpanExporterTest { + + @Captor ArgumentCaptor> spansCaptor; + + @Test + void filter() { + SpanData span1 = span("one"); + SpanData span2 = span("two"); + SpanData span3 = span("three"); + SpanData span4 = span("four"); + SpanData span5 = span("FIVE"); + Attributes attr6 = Attributes.of(stringKey("herp"), "derp"); + SpanData span6 = span("six", attr6); + Attributes attr7 = Attributes.of(stringKey("dig"), "dug"); + SpanData span7 = span("seven", attr7); + SpanData span8 = span("eight"); + Collection spans = + Arrays.asList(span1, span2, span3, span4, span5, span6, span7, span8); + Map, Predicate> attrRejects = new HashMap<>(); + attrRejects.put(stringKey("herp"), "derp"::equals); + attrRejects.put(stringKey("dig"), v -> ((String) v).startsWith("d")); + + SpanExporter exporter = mock(SpanExporter.class); + CompletableResultCode expectedResult = mock(CompletableResultCode.class); + + when(exporter.export(spansCaptor.capture())).thenReturn(expectedResult); + + SpanExporter underTest = + FilteringSpanExporter.builder(exporter) + .rejecting(x -> x == span2) + .rejectSpansWithNameContaining("hree") + .rejectSpansNamed("four") + .rejectSpansNamed(x -> x.equalsIgnoreCase("five")) + .rejectSpansWithAttributesMatching(attrRejects) + .build(); + + CompletableResultCode result = underTest.export(spans); + assertThat(result).isSameAs(expectedResult); + Collection resultSpans = spansCaptor.getValue(); + assertThat(resultSpans) + .satisfiesExactly( + s -> assertThat(s).isSameAs(span1), s -> assertThat(s).isSameAs(span8)); + } +} diff --git a/opentelemetry-android-instrumentation/src/test/java/io/opentelemetry/rum/internal/export/ModifiedSpanDataTest.java b/opentelemetry-android-instrumentation/src/test/java/io/opentelemetry/rum/internal/export/ModifiedSpanDataTest.java new file mode 100644 index 000000000..c7760f593 --- /dev/null +++ b/opentelemetry-android-instrumentation/src/test/java/io/opentelemetry/rum/internal/export/ModifiedSpanDataTest.java @@ -0,0 +1,90 @@ +/* + * Copyright Splunk Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.opentelemetry.rum.internal.export; + +import static io.opentelemetry.api.common.AttributeKey.stringKey; +import static java.util.Collections.emptyList; +import static org.junit.jupiter.api.Assertions.assertEquals; + +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.api.trace.SpanContext; +import io.opentelemetry.api.trace.SpanId; +import io.opentelemetry.api.trace.SpanKind; +import io.opentelemetry.api.trace.TraceFlags; +import io.opentelemetry.api.trace.TraceId; +import io.opentelemetry.api.trace.TraceState; +import io.opentelemetry.sdk.common.InstrumentationLibraryInfo; +import io.opentelemetry.sdk.resources.Resource; +import io.opentelemetry.sdk.testing.trace.TestSpanData; +import io.opentelemetry.sdk.trace.data.SpanData; +import io.opentelemetry.sdk.trace.data.StatusData; +import org.junit.jupiter.api.Test; + +class ModifiedSpanDataTest { + private static final String TRACE_ID = TraceId.fromLongs(0, 42); + private static final String SPAN_ID = SpanId.fromLong(123); + + @Test + void shouldForwardAllCallsExceptAttributesToTheOriginal() { + SpanData original = + TestSpanData.builder() + .setName("test") + .setKind(SpanKind.CLIENT) + .setSpanContext( + SpanContext.create( + TRACE_ID, + SPAN_ID, + TraceFlags.getSampled(), + TraceState.getDefault())) + .setParentSpanContext(SpanContext.getInvalid()) + .setStatus(StatusData.ok()) + .setStartEpochNanos(123) + .setAttributes(Attributes.of(stringKey("attribute"), "original value")) + .setEvents(emptyList()) + .setLinks(emptyList()) + .setEndEpochNanos(456) + .setHasEnded(true) + .setTotalRecordedEvents(0) + .setTotalRecordedLinks(0) + .setTotalAttributeCount(12) + .setInstrumentationLibraryInfo( + InstrumentationLibraryInfo.create("test", "0.0.1")) + .setResource(Resource.getDefault()) + .build(); + + SpanData modified = + new ModifiedSpanData(original, Attributes.of(stringKey("attribute"), "modified")); + + assertEquals(original.getName(), modified.getName()); + assertEquals(original.getKind(), modified.getKind()); + assertEquals(original.getSpanContext(), modified.getSpanContext()); + assertEquals(original.getParentSpanContext(), modified.getParentSpanContext()); + assertEquals(original.getStatus(), modified.getStatus()); + assertEquals(original.getStartEpochNanos(), modified.getStartEpochNanos()); + assertEquals(Attributes.of(stringKey("attribute"), "modified"), modified.getAttributes()); + assertEquals(original.getEvents(), modified.getEvents()); + assertEquals(original.getLinks(), modified.getLinks()); + assertEquals(original.getEndEpochNanos(), modified.getEndEpochNanos()); + assertEquals(original.hasEnded(), modified.hasEnded()); + assertEquals(original.getTotalRecordedEvents(), modified.getTotalRecordedEvents()); + assertEquals(original.getTotalRecordedLinks(), modified.getTotalRecordedLinks()); + assertEquals(1, modified.getTotalAttributeCount()); + assertEquals( + original.getInstrumentationLibraryInfo(), modified.getInstrumentationLibraryInfo()); + assertEquals(original.getResource(), modified.getResource()); + } +} diff --git a/opentelemetry-android-instrumentation/src/test/java/io/opentelemetry/rum/internal/export/SpanDataModifierTest.java b/opentelemetry-android-instrumentation/src/test/java/io/opentelemetry/rum/internal/export/SpanDataModifierTest.java new file mode 100644 index 000000000..42e4bd23a --- /dev/null +++ b/opentelemetry-android-instrumentation/src/test/java/io/opentelemetry/rum/internal/export/SpanDataModifierTest.java @@ -0,0 +1,262 @@ +/* + * Copyright Splunk Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.opentelemetry.rum.internal.export; + +import static io.opentelemetry.api.common.AttributeKey.longKey; +import static io.opentelemetry.api.common.AttributeKey.stringKey; +import static io.opentelemetry.rum.internal.export.TestSpanHelper.span; +import static io.opentelemetry.sdk.testing.assertj.OpenTelemetryAssertions.assertThat; +import static java.util.Arrays.asList; +import static java.util.Collections.singletonList; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import io.opentelemetry.api.common.AttributeKey; +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.sdk.common.CompletableResultCode; +import io.opentelemetry.sdk.trace.data.SpanData; +import io.opentelemetry.sdk.trace.export.SpanExporter; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class SpanDataModifierTest { + static final AttributeKey ATTRIBUTE = stringKey("attribute"); + static final AttributeKey OTHER_ATTRIBUTE = stringKey("other_attribute"); + static final AttributeKey LONG_ATTRIBUTE = longKey("long_attribute"); + + @Mock SpanExporter delegate; + + @Captor ArgumentCaptor> spansCaptor; + + @Test + void shouldRejectSpansByName() { + // given + SpanExporter underTest = + SpanDataModifier.builder(delegate) + .rejectSpansByName(spanName -> spanName.equals("span2")) + .rejectSpansByName(spanName -> spanName.equals("span4")) + .build(); + + SpanData span1 = span("span1"); + SpanData span2 = span("span2"); + SpanData span3 = span("span3"); + SpanData span4 = span("span4"); + + CompletableResultCode expectedResult = new CompletableResultCode(); + when(delegate.export(spansCaptor.capture())).thenReturn(expectedResult); + + // when + CompletableResultCode result = underTest.export(asList(span1, span2, span3, span4)); + + // then + assertSame(expectedResult, result); + + assertThat(spansCaptor.getValue()) + .satisfiesExactly( + s -> assertThat(s).hasName(span1.getName()), + s -> assertThat(s).hasName(span3.getName())); + } + + @Test + void shouldRejectSpansByAttributeValue() { + // given + SpanExporter underTest = + SpanDataModifier.builder(delegate) + .rejectSpansByAttributeValue(ATTRIBUTE, value -> value.equals("test")) + .rejectSpansByAttributeValue(ATTRIBUTE, value -> value.equals("rejected!")) + .rejectSpansByAttributeValue(LONG_ATTRIBUTE, value -> value > 100) + .build(); + + SpanData rejected = span("span", Attributes.of(ATTRIBUTE, "test")); + SpanData differentKey = + span("span", Attributes.of(OTHER_ATTRIBUTE, "test", LONG_ATTRIBUTE, 42L)); + SpanData anotherRejected = span("span", Attributes.of(ATTRIBUTE, "rejected!")); + SpanData differentValue = span("span", Attributes.of(ATTRIBUTE, "not really test")); + SpanData yetAnotherRejected = + span("span", Attributes.of(ATTRIBUTE, "pass", LONG_ATTRIBUTE, 123L)); + + CompletableResultCode expectedResult = new CompletableResultCode(); + when(delegate.export(spansCaptor.capture())).thenReturn(expectedResult); + + // when + CompletableResultCode result = + underTest.export( + asList( + rejected, + differentKey, + anotherRejected, + differentValue, + yetAnotherRejected)); + + // then + assertSame(expectedResult, result); + + assertThat(spansCaptor.getValue()) + .satisfiesExactly( + s -> + assertThat(s) + .hasName(differentKey.getName()) + .hasAttributes(differentKey.getAttributes()), + s -> + assertThat(s) + .hasName(differentValue.getName()) + .hasAttributes(differentValue.getAttributes())); + } + + @Test + void shouldRemoveSpanAttributes() { + // given + SpanExporter underTest = + SpanDataModifier.builder(delegate) + .removeSpanAttribute(ATTRIBUTE, value -> value.equals("test")) + // make sure that attribute types are taken into account + .removeSpanAttribute(stringKey("long_attribute")) + .build(); + + SpanData span1 = span("first", Attributes.of(ATTRIBUTE, "test", LONG_ATTRIBUTE, 42L)); + SpanData span2 = + span("second", Attributes.of(ATTRIBUTE, "not test", OTHER_ATTRIBUTE, "test")); + + CompletableResultCode expectedResult = new CompletableResultCode(); + when(delegate.export(spansCaptor.capture())).thenReturn(expectedResult); + + // when + CompletableResultCode result = underTest.export(asList(span1, span2)); + + // then + assertSame(expectedResult, result); + + List exportedSpans = new ArrayList<>(spansCaptor.getValue()); + assertEquals(2, exportedSpans.size()); + assertEquals("first", exportedSpans.get(0).getName()); + assertEquals(Attributes.of(LONG_ATTRIBUTE, 42L), exportedSpans.get(0).getAttributes()); + assertEquals("second", exportedSpans.get(1).getName()); + assertEquals( + Attributes.of(ATTRIBUTE, "not test", OTHER_ATTRIBUTE, "test"), + exportedSpans.get(1).getAttributes()); + } + + @Test + void shouldReplaceSpanAttributes() { + // given + SpanExporter underTest = + SpanDataModifier.builder(delegate) + .replaceSpanAttribute(ATTRIBUTE, value -> value + "!!!") + .replaceSpanAttribute(ATTRIBUTE, value -> value + "1") + .replaceSpanAttribute(LONG_ATTRIBUTE, value -> value + 1) + // make sure that attribute types are taken into account + .replaceSpanAttribute(stringKey("long_attribute"), value -> "abc") + .build(); + + SpanData span1 = span("first", Attributes.of(ATTRIBUTE, "test", LONG_ATTRIBUTE, 42L)); + SpanData span2 = span("second", Attributes.of(OTHER_ATTRIBUTE, "test")); + + CompletableResultCode expectedResult = new CompletableResultCode(); + when(delegate.export(spansCaptor.capture())).thenReturn(expectedResult); + + // when + CompletableResultCode result = underTest.export(asList(span1, span2)); + + // then + assertSame(expectedResult, result); + + List exportedSpans = new ArrayList<>(spansCaptor.getValue()); + assertEquals(2, exportedSpans.size()); + assertEquals("first", exportedSpans.get(0).getName()); + assertEquals( + Attributes.of(ATTRIBUTE, "test!!!1", LONG_ATTRIBUTE, 43L), + exportedSpans.get(0).getAttributes()); + assertEquals("second", exportedSpans.get(1).getName()); + assertEquals(Attributes.of(OTHER_ATTRIBUTE, "test"), exportedSpans.get(1).getAttributes()); + } + + @Test + void shouldReplaceSpanAttributes_removeAttributeByReturningNull() { + // given + SpanExporter underTest = + SpanDataModifier.builder(delegate) + .replaceSpanAttribute(ATTRIBUTE, value -> null) + .build(); + + SpanData span = span("first", Attributes.of(ATTRIBUTE, "test", LONG_ATTRIBUTE, 42L)); + + CompletableResultCode expectedResult = new CompletableResultCode(); + when(delegate.export(spansCaptor.capture())).thenReturn(expectedResult); + + // when + CompletableResultCode result = underTest.export(singletonList(span)); + + // then + assertSame(expectedResult, result); + + List exportedSpans = new ArrayList<>(spansCaptor.getValue()); + assertEquals(1, exportedSpans.size()); + assertEquals("first", exportedSpans.get(0).getName()); + assertEquals(Attributes.of(LONG_ATTRIBUTE, 42L), exportedSpans.get(0).getAttributes()); + } + + @Test + void builderChangesShouldNotApplyToAlreadyDecoratedExporter() { + // given + SpanDataModifier builder = SpanDataModifier.builder(delegate); + SpanExporter underTest = builder.build(); + + builder.rejectSpansByName(spanName -> spanName.equals("span")) + .rejectSpansByAttributeValue(ATTRIBUTE, value -> true) + .removeSpanAttribute(ATTRIBUTE, value -> true) + .replaceSpanAttribute(ATTRIBUTE, value -> "abc"); + + SpanData span = span("span", Attributes.of(ATTRIBUTE, "test")); + + CompletableResultCode expectedResult = new CompletableResultCode(); + when(delegate.export(spansCaptor.capture())).thenReturn(expectedResult); + + // when + CompletableResultCode result = underTest.export(singletonList(span)); + + // then + assertSame(expectedResult, result); + + assertThat(spansCaptor.getValue()) + .satisfiesExactly( + s -> + assertThat(s) + .hasName(span.getName()) + .hasAttributes(span.getAttributes())); + } + + @Test + void shouldDelegateCalls() { + SpanExporter underTest = SpanDataModifier.builder(delegate).build(); + + underTest.flush(); + verify(delegate).flush(); + + underTest.shutdown(); + verify(delegate).shutdown(); + } +} diff --git a/opentelemetry-android-instrumentation/src/test/java/io/opentelemetry/rum/internal/export/TestSpanHelper.java b/opentelemetry-android-instrumentation/src/test/java/io/opentelemetry/rum/internal/export/TestSpanHelper.java new file mode 100644 index 000000000..0366f1c78 --- /dev/null +++ b/opentelemetry-android-instrumentation/src/test/java/io/opentelemetry/rum/internal/export/TestSpanHelper.java @@ -0,0 +1,42 @@ +/* + * Copyright Splunk Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.opentelemetry.rum.internal.export; + +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.api.trace.SpanKind; +import io.opentelemetry.sdk.testing.trace.TestSpanData; +import io.opentelemetry.sdk.trace.data.SpanData; +import io.opentelemetry.sdk.trace.data.StatusData; + +public class TestSpanHelper { + + static SpanData span(String name) { + return span(name, Attributes.empty()); + } + + static SpanData span(String name, Attributes attributes) { + return TestSpanData.builder() + .setName(name) + .setKind(SpanKind.INTERNAL) + .setStatus(StatusData.unset()) + .setHasEnded(true) + .setStartEpochNanos(0) + .setEndEpochNanos(123) + .setAttributes(attributes) + .build(); + } +} diff --git a/opentelemetry-android-instrumentation/src/test/java/io/opentelemetry/rum/internal/instrumentation/activity/ActivityCallbackTestHarness.java b/opentelemetry-android-instrumentation/src/test/java/io/opentelemetry/rum/internal/instrumentation/activity/ActivityCallbackTestHarness.java new file mode 100644 index 000000000..9e2847e5f --- /dev/null +++ b/opentelemetry-android-instrumentation/src/test/java/io/opentelemetry/rum/internal/instrumentation/activity/ActivityCallbackTestHarness.java @@ -0,0 +1,97 @@ +/* + * Copyright Splunk Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.opentelemetry.rum.internal.instrumentation.activity; + +import static org.mockito.Mockito.mock; + +import android.app.Activity; +import android.os.Bundle; + +class ActivityCallbackTestHarness { + + private final ActivityCallbacks callbacks; + + ActivityCallbackTestHarness(ActivityCallbacks callbacks) { + this.callbacks = callbacks; + } + + void runAppStartupLifecycle(Activity mainActivity) { + // app startup lifecycle is the same as a normal activity lifecycle + runActivityCreationLifecycle(mainActivity); + } + + void runActivityCreationLifecycle(Activity activity) { + Bundle bundle = mock(Bundle.class); + + callbacks.onActivityPreCreated(activity, bundle); + callbacks.onActivityCreated(activity, bundle); + callbacks.onActivityPostCreated(activity, bundle); + + runActivityStartedLifecycle(activity); + runActivityResumedLifecycle(activity); + } + + void runActivityStartedLifecycle(Activity activity) { + callbacks.onActivityPreStarted(activity); + callbacks.onActivityStarted(activity); + callbacks.onActivityPostStarted(activity); + } + + void runActivityPausedLifecycle(Activity activity) { + callbacks.onActivityPrePaused(activity); + callbacks.onActivityPaused(activity); + callbacks.onActivityPostPaused(activity); + } + + void runActivityResumedLifecycle(Activity activity) { + callbacks.onActivityPreResumed(activity); + callbacks.onActivityResumed(activity); + callbacks.onActivityPostResumed(activity); + } + + void runActivityStoppedFromRunningLifecycle(Activity activity) { + runActivityPausedLifecycle(activity); + runActivityStoppedFromPausedLifecycle(activity); + } + + void runActivityStoppedFromPausedLifecycle(Activity activity) { + callbacks.onActivityPreStopped(activity); + callbacks.onActivityStopped(activity); + callbacks.onActivityPostStopped(activity); + } + + void runActivityDestroyedFromStoppedLifecycle(Activity activity) { + callbacks.onActivityPreDestroyed(activity); + callbacks.onActivityDestroyed(activity); + callbacks.onActivityPostDestroyed(activity); + } + + void runActivityDestroyedFromPausedLifecycle(Activity activity) { + runActivityStoppedFromPausedLifecycle(activity); + runActivityDestroyedFromStoppedLifecycle(activity); + } + + void runActivityDestroyedFromRunningLifecycle(Activity activity) { + runActivityStoppedFromRunningLifecycle(activity); + runActivityDestroyedFromStoppedLifecycle(activity); + } + + void runActivityRestartedLifecycle(Activity activity) { + runActivityStartedLifecycle(activity); + runActivityResumedLifecycle(activity); + } +} diff --git a/opentelemetry-android-instrumentation/src/test/java/io/opentelemetry/rum/internal/instrumentation/activity/ActivityCallbacksTest.java b/opentelemetry-android-instrumentation/src/test/java/io/opentelemetry/rum/internal/instrumentation/activity/ActivityCallbacksTest.java new file mode 100644 index 000000000..4015ea71e --- /dev/null +++ b/opentelemetry-android-instrumentation/src/test/java/io/opentelemetry/rum/internal/instrumentation/activity/ActivityCallbacksTest.java @@ -0,0 +1,360 @@ +/* + * Copyright Splunk Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.opentelemetry.rum.internal.instrumentation.activity; + +import static io.opentelemetry.rum.internal.RumConstants.LAST_SCREEN_NAME_KEY; +import static io.opentelemetry.rum.internal.RumConstants.SCREEN_NAME_KEY; +import static io.opentelemetry.rum.internal.RumConstants.START_TYPE_KEY; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.isA; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import android.app.Activity; +import io.opentelemetry.api.trace.Tracer; +import io.opentelemetry.rum.internal.instrumentation.ScreenNameExtractor; +import io.opentelemetry.rum.internal.instrumentation.startup.AppStartupTimer; +import io.opentelemetry.sdk.testing.junit5.OpenTelemetryExtension; +import io.opentelemetry.sdk.trace.data.EventData; +import io.opentelemetry.sdk.trace.data.SpanData; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +class ActivityCallbacksTest { + @RegisterExtension final OpenTelemetryExtension otelTesting = OpenTelemetryExtension.create(); + + private ActivityTracerCache tracers; + private VisibleScreenTracker visibleScreenTracker; + + @BeforeEach + public void setup() { + Tracer tracer = otelTesting.getOpenTelemetry().getTracer("testTracer"); + AppStartupTimer startupTimer = new AppStartupTimer(); + visibleScreenTracker = mock(VisibleScreenTracker.class); + ScreenNameExtractor extractor = mock(ScreenNameExtractor.class); + when(extractor.extract(isA(Activity.class))).thenReturn("Activity"); + tracers = new ActivityTracerCache(tracer, visibleScreenTracker, startupTimer, extractor); + } + + @Test + void appStartup() { + ActivityCallbacks activityCallbacks = new ActivityCallbacks(tracers); + ActivityCallbackTestHarness testHarness = + new ActivityCallbackTestHarness(activityCallbacks); + + Activity activity = mock(Activity.class); + testHarness.runAppStartupLifecycle(activity); + + List spans = otelTesting.getSpans(); + assertEquals(1, spans.size()); + + SpanData creationSpan = spans.get(0); + + // TODO: ADD THIS TEST TO THE NEW COMPONENT(S) + // assertEquals("AppStart", startupSpan.getName()); + // assertEquals("cold", startupSpan.getAttributes().get(SplunkRum.START_TYPE_KEY)); + + assertEquals( + activity.getClass().getSimpleName(), + creationSpan.getAttributes().get(ActivityTracer.ACTIVITY_NAME_KEY)); + assertEquals( + activity.getClass().getSimpleName(), + creationSpan.getAttributes().get(SCREEN_NAME_KEY)); + assertNull(creationSpan.getAttributes().get(LAST_SCREEN_NAME_KEY)); + + List events = creationSpan.getEvents(); + assertEquals(9, events.size()); + + checkEventExists(events, "activityPreCreated"); + checkEventExists(events, "activityCreated"); + checkEventExists(events, "activityPostCreated"); + + checkEventExists(events, "activityPreStarted"); + checkEventExists(events, "activityStarted"); + checkEventExists(events, "activityPostStarted"); + + checkEventExists(events, "activityPreResumed"); + checkEventExists(events, "activityResumed"); + checkEventExists(events, "activityPostResumed"); + } + + @Test + void activityCreation() { + ActivityCallbacks activityCallbacks = new ActivityCallbacks(tracers); + + ActivityCallbackTestHarness testHarness = + new ActivityCallbackTestHarness(activityCallbacks); + startupAppAndClearSpans(testHarness); + + Activity activity = mock(Activity.class); + testHarness.runActivityCreationLifecycle(activity); + List spans = otelTesting.getSpans(); + assertEquals(1, spans.size()); + + SpanData span = spans.get(0); + + assertEquals("AppStart", span.getName()); + assertEquals("warm", span.getAttributes().get(START_TYPE_KEY)); + assertEquals( + activity.getClass().getSimpleName(), + span.getAttributes().get(ActivityTracer.ACTIVITY_NAME_KEY)); + assertEquals( + activity.getClass().getSimpleName(), span.getAttributes().get(SCREEN_NAME_KEY)); + assertNull(span.getAttributes().get(LAST_SCREEN_NAME_KEY)); + + List events = span.getEvents(); + assertEquals(9, events.size()); + + checkEventExists(events, "activityPreCreated"); + checkEventExists(events, "activityCreated"); + checkEventExists(events, "activityPostCreated"); + + checkEventExists(events, "activityPreStarted"); + checkEventExists(events, "activityStarted"); + checkEventExists(events, "activityPostStarted"); + + checkEventExists(events, "activityPreResumed"); + checkEventExists(events, "activityResumed"); + checkEventExists(events, "activityPostResumed"); + } + + private void startupAppAndClearSpans(ActivityCallbackTestHarness testHarness) { + // make sure that the initial state has been set up & the application is started. + testHarness.runAppStartupLifecycle(mock(Activity.class)); + otelTesting.clearSpans(); + } + + @Test + void activityRestart() { + ActivityCallbacks activityCallbacks = new ActivityCallbacks(tracers); + + ActivityCallbackTestHarness testHarness = + new ActivityCallbackTestHarness(activityCallbacks); + + startupAppAndClearSpans(testHarness); + + Activity activity = mock(Activity.class); + testHarness.runActivityRestartedLifecycle(activity); + + List spans = otelTesting.getSpans(); + assertEquals(1, spans.size()); + + SpanData span = spans.get(0); + + assertEquals("AppStart", span.getName()); + assertEquals("hot", span.getAttributes().get(START_TYPE_KEY)); + assertEquals( + activity.getClass().getSimpleName(), + span.getAttributes().get(ActivityTracer.ACTIVITY_NAME_KEY)); + assertEquals( + activity.getClass().getSimpleName(), span.getAttributes().get(SCREEN_NAME_KEY)); + assertNull(span.getAttributes().get(LAST_SCREEN_NAME_KEY)); + + List events = span.getEvents(); + assertEquals(6, events.size()); + + checkEventExists(events, "activityPreStarted"); + checkEventExists(events, "activityStarted"); + checkEventExists(events, "activityPostStarted"); + + checkEventExists(events, "activityPreResumed"); + checkEventExists(events, "activityResumed"); + checkEventExists(events, "activityPostResumed"); + } + + @Test + void activityResumed() { + when(visibleScreenTracker.getPreviouslyVisibleScreen()).thenReturn("previousScreen"); + ActivityCallbacks activityCallbacks = new ActivityCallbacks(tracers); + + ActivityCallbackTestHarness testHarness = + new ActivityCallbackTestHarness(activityCallbacks); + + startupAppAndClearSpans(testHarness); + + Activity activity = mock(Activity.class); + testHarness.runActivityResumedLifecycle(activity); + + List spans = otelTesting.getSpans(); + assertEquals(1, spans.size()); + + SpanData span = spans.get(0); + + assertEquals("Resumed", span.getName()); + assertEquals( + activity.getClass().getSimpleName(), + span.getAttributes().get(ActivityTracer.ACTIVITY_NAME_KEY)); + assertEquals( + activity.getClass().getSimpleName(), span.getAttributes().get(SCREEN_NAME_KEY)); + assertEquals("previousScreen", span.getAttributes().get(LAST_SCREEN_NAME_KEY)); + + List events = span.getEvents(); + assertEquals(3, events.size()); + + checkEventExists(events, "activityPreResumed"); + checkEventExists(events, "activityResumed"); + checkEventExists(events, "activityPostResumed"); + } + + @Test + void activityDestroyedFromStopped() { + ActivityCallbacks activityCallbacks = new ActivityCallbacks(tracers); + + ActivityCallbackTestHarness testHarness = + new ActivityCallbackTestHarness(activityCallbacks); + + startupAppAndClearSpans(testHarness); + + Activity activity = mock(Activity.class); + testHarness.runActivityDestroyedFromStoppedLifecycle(activity); + + List spans = otelTesting.getSpans(); + assertEquals(1, spans.size()); + + SpanData span = spans.get(0); + + assertEquals("Destroyed", span.getName()); + assertEquals( + activity.getClass().getSimpleName(), + span.getAttributes().get(ActivityTracer.ACTIVITY_NAME_KEY)); + assertEquals( + activity.getClass().getSimpleName(), span.getAttributes().get(SCREEN_NAME_KEY)); + assertNull(span.getAttributes().get(LAST_SCREEN_NAME_KEY)); + + List events = span.getEvents(); + assertEquals(3, events.size()); + + checkEventExists(events, "activityPreDestroyed"); + checkEventExists(events, "activityDestroyed"); + checkEventExists(events, "activityPostDestroyed"); + } + + @Test + void activityDestroyedFromPaused() { + ActivityCallbacks activityCallbacks = new ActivityCallbacks(tracers); + + ActivityCallbackTestHarness testHarness = + new ActivityCallbackTestHarness(activityCallbacks); + + startupAppAndClearSpans(testHarness); + + Activity activity = mock(Activity.class); + testHarness.runActivityDestroyedFromPausedLifecycle(activity); + + List spans = otelTesting.getSpans(); + assertEquals(2, spans.size()); + + SpanData stoppedSpan = spans.get(0); + + assertEquals("Stopped", stoppedSpan.getName()); + assertEquals( + activity.getClass().getSimpleName(), + stoppedSpan.getAttributes().get(ActivityTracer.ACTIVITY_NAME_KEY)); + assertEquals( + activity.getClass().getSimpleName(), + stoppedSpan.getAttributes().get(SCREEN_NAME_KEY)); + assertNull(stoppedSpan.getAttributes().get(LAST_SCREEN_NAME_KEY)); + + List events = stoppedSpan.getEvents(); + assertEquals(3, events.size()); + + checkEventExists(events, "activityPreStopped"); + checkEventExists(events, "activityStopped"); + checkEventExists(events, "activityPostStopped"); + + SpanData destroyedSpan = spans.get(1); + + assertEquals("Destroyed", destroyedSpan.getName()); + assertEquals( + activity.getClass().getSimpleName(), + destroyedSpan.getAttributes().get(ActivityTracer.ACTIVITY_NAME_KEY)); + assertEquals( + activity.getClass().getSimpleName(), + destroyedSpan.getAttributes().get(SCREEN_NAME_KEY)); + assertNull(destroyedSpan.getAttributes().get(LAST_SCREEN_NAME_KEY)); + + events = destroyedSpan.getEvents(); + assertEquals(3, events.size()); + + checkEventExists(events, "activityPreDestroyed"); + checkEventExists(events, "activityDestroyed"); + checkEventExists(events, "activityPostDestroyed"); + } + + @Test + void activityStoppedFromRunning() { + ActivityCallbacks activityCallbacks = new ActivityCallbacks(tracers); + + ActivityCallbackTestHarness testHarness = + new ActivityCallbackTestHarness(activityCallbacks); + + startupAppAndClearSpans(testHarness); + + Activity activity = mock(Activity.class); + testHarness.runActivityStoppedFromRunningLifecycle(activity); + + List spans = otelTesting.getSpans(); + assertEquals(2, spans.size()); + + SpanData stoppedSpan = spans.get(0); + + assertEquals("Paused", stoppedSpan.getName()); + assertEquals( + activity.getClass().getSimpleName(), + stoppedSpan.getAttributes().get(ActivityTracer.ACTIVITY_NAME_KEY)); + assertEquals( + activity.getClass().getSimpleName(), + stoppedSpan.getAttributes().get(SCREEN_NAME_KEY)); + assertNull(stoppedSpan.getAttributes().get(LAST_SCREEN_NAME_KEY)); + + List events = stoppedSpan.getEvents(); + assertEquals(3, events.size()); + + checkEventExists(events, "activityPrePaused"); + checkEventExists(events, "activityPaused"); + checkEventExists(events, "activityPostPaused"); + + SpanData destroyedSpan = spans.get(1); + + assertEquals("Stopped", destroyedSpan.getName()); + assertEquals( + activity.getClass().getSimpleName(), + destroyedSpan.getAttributes().get(ActivityTracer.ACTIVITY_NAME_KEY)); + assertEquals( + activity.getClass().getSimpleName(), + destroyedSpan.getAttributes().get(SCREEN_NAME_KEY)); + assertNull(destroyedSpan.getAttributes().get(LAST_SCREEN_NAME_KEY)); + + events = destroyedSpan.getEvents(); + assertEquals(3, events.size()); + + checkEventExists(events, "activityPreStopped"); + checkEventExists(events, "activityStopped"); + checkEventExists(events, "activityPostStopped"); + } + + private void checkEventExists(List events, String eventName) { + Optional event = + events.stream().filter(e -> e.getName().equals(eventName)).findAny(); + assertTrue(event.isPresent(), "Event with name " + eventName + " not found"); + } +} diff --git a/opentelemetry-android-instrumentation/src/test/java/io/opentelemetry/rum/internal/instrumentation/activity/ActivityTracerCacheTest.java b/opentelemetry-android-instrumentation/src/test/java/io/opentelemetry/rum/internal/instrumentation/activity/ActivityTracerCacheTest.java new file mode 100644 index 000000000..776be8946 --- /dev/null +++ b/opentelemetry-android-instrumentation/src/test/java/io/opentelemetry/rum/internal/instrumentation/activity/ActivityTracerCacheTest.java @@ -0,0 +1,138 @@ +/* + * Copyright Splunk Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.opentelemetry.rum.internal.instrumentation.activity; + +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; + +import android.app.Activity; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Function; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class ActivityTracerCacheTest { + + @Mock Activity activity; + + @Mock ActivityTracer activityTracer; + @Mock Function tracerCreator; + AtomicReference initialActivity; + + @BeforeEach + void setup() { + initialActivity = new AtomicReference<>(); + } + + @Test + void addEventNewActivity() { + when(tracerCreator.apply(activity)).thenReturn(activityTracer); + when(activityTracer.addEvent(anyString())).thenReturn(activityTracer); + + ActivityTracerCache underTest = new ActivityTracerCache(tracerCreator); + ActivityTracer result = underTest.addEvent(activity, "beep"); + assertSame(activityTracer, result); + verify(activityTracer).addEvent("beep"); + verifyNoMoreInteractions(tracerCreator); + } + + @Test + void addEventExistingActivity() { + when(tracerCreator.apply(activity)).thenReturn(activityTracer); + when(activityTracer.addEvent(anyString())).thenReturn(activityTracer); + + ActivityTracerCache underTest = new ActivityTracerCache(tracerCreator); + ActivityTracer result1 = underTest.addEvent(activity, "beep1"); + ActivityTracer result2 = underTest.addEvent(activity, "beep2"); + ActivityTracer result3 = underTest.addEvent(activity, "beep3"); + assertSame(activityTracer, result1); + assertSame(activityTracer, result2); + assertSame(activityTracer, result3); + verify(activityTracer).addEvent("beep1"); + verify(activityTracer).addEvent("beep2"); + verify(activityTracer).addEvent("beep3"); + verify(tracerCreator).apply(activity); + } + + @Test + void startSpanIfNoneInProgress() { + when(tracerCreator.apply(activity)).thenReturn(activityTracer); + when(activityTracer.startSpanIfNoneInProgress("wrenchy")).thenReturn(activityTracer); + + ActivityTracerCache underTest = new ActivityTracerCache(tracerCreator); + + ActivityTracer result = underTest.startSpanIfNoneInProgress(activity, "wrenchy"); + assertSame(activityTracer, result); + verify(activityTracer).startSpanIfNoneInProgress("wrenchy"); + verifyNoMoreInteractions(tracerCreator); + } + + @Test + void initiateRestartSpanIfNecessary_singleActivity() { + + when(tracerCreator.apply(activity)).thenReturn(activityTracer); + when(activityTracer.initiateRestartSpanIfNecessary(false)).thenReturn(activityTracer); + + ActivityTracerCache underTest = new ActivityTracerCache(tracerCreator); + + ActivityTracer result = underTest.initiateRestartSpanIfNecessary(activity); + assertSame(activityTracer, result); + verify(activityTracer).initiateRestartSpanIfNecessary(false); + verifyNoMoreInteractions(tracerCreator); + } + + @Test + void initiateRestartSpanIfNecessary_multiActivity() { + Activity activity2 = new Activity() { + // to get a new class name used in the cache + }; + ActivityTracer activityTracer2 = mock(ActivityTracer.class); + + when(tracerCreator.apply(activity)).thenReturn(activityTracer); + when(tracerCreator.apply(activity2)).thenReturn(activityTracer2); + when(activityTracer.addEvent(anyString())).thenReturn(activityTracer); + when(activityTracer.initiateRestartSpanIfNecessary(true)).thenReturn(activityTracer); + + ActivityTracerCache underTest = new ActivityTracerCache(tracerCreator); + + underTest.addEvent(activity, "foo"); + underTest.addEvent(activity2, "bar"); + ActivityTracer result = underTest.initiateRestartSpanIfNecessary(activity); + assertSame(activityTracer, result); + verify(activityTracer).initiateRestartSpanIfNecessary(true); + } + + @Test + void startActivityCreation() { + when(tracerCreator.apply(activity)).thenReturn(activityTracer); + when(activityTracer.startActivityCreation()).thenReturn(activityTracer); + + ActivityTracerCache underTest = new ActivityTracerCache(tracerCreator); + + ActivityTracer result = underTest.startActivityCreation(activity); + assertSame(activityTracer, result); + verify(activityTracer).startActivityCreation(); + } +} diff --git a/opentelemetry-android-instrumentation/src/test/java/io/opentelemetry/rum/internal/instrumentation/activity/ActivityTracerTest.java b/opentelemetry-android-instrumentation/src/test/java/io/opentelemetry/rum/internal/instrumentation/activity/ActivityTracerTest.java new file mode 100644 index 000000000..30096f1d9 --- /dev/null +++ b/opentelemetry-android-instrumentation/src/test/java/io/opentelemetry/rum/internal/instrumentation/activity/ActivityTracerTest.java @@ -0,0 +1,233 @@ +/* + * Copyright Splunk Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.opentelemetry.rum.internal.instrumentation.activity; + +import static io.opentelemetry.rum.internal.RumConstants.LAST_SCREEN_NAME_KEY; +import static io.opentelemetry.rum.internal.RumConstants.SCREEN_NAME_KEY; +import static io.opentelemetry.rum.internal.RumConstants.START_TYPE_KEY; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import android.app.Activity; +import io.opentelemetry.api.trace.Tracer; +import io.opentelemetry.rum.internal.instrumentation.startup.AppStartupTimer; +import io.opentelemetry.rum.internal.util.ActiveSpan; +import io.opentelemetry.sdk.testing.junit5.OpenTelemetryExtension; +import io.opentelemetry.sdk.trace.data.SpanData; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +public class ActivityTracerTest { + @RegisterExtension final OpenTelemetryExtension otelTesting = OpenTelemetryExtension.create(); + + private Tracer tracer; + private final VisibleScreenTracker visibleScreenTracker = mock(VisibleScreenTracker.class); + private final AppStartupTimer appStartupTimer = new AppStartupTimer(); + private ActiveSpan activeSpan; + + @BeforeEach + public void setup() { + tracer = otelTesting.getOpenTelemetry().getTracer("testTracer"); + activeSpan = new ActiveSpan(visibleScreenTracker::getPreviouslyVisibleScreen); + } + + @Test + void restart_nonInitialActivity() { + ActivityTracer trackableTracer = + ActivityTracer.builder(mock(Activity.class)) + .setInitialAppActivity("FirstActivity") + .setTracer(tracer) + .setAppStartupTimer(appStartupTimer) + .setActiveSpan(activeSpan) + .build(); + trackableTracer.initiateRestartSpanIfNecessary(false); + trackableTracer.endActiveSpan(); + SpanData span = getSingleSpan(); + assertEquals("Restarted", span.getName()); + assertNull(span.getAttributes().get(START_TYPE_KEY)); + } + + @Test + public void restart_initialActivity() { + ActivityTracer trackableTracer = + ActivityTracer.builder(mock(Activity.class)) + .setInitialAppActivity("Activity") + .setTracer(tracer) + .setAppStartupTimer(appStartupTimer) + .setActiveSpan(activeSpan) + .build(); + trackableTracer.initiateRestartSpanIfNecessary(false); + trackableTracer.endActiveSpan(); + SpanData span = getSingleSpan(); + assertEquals("AppStart", span.getName()); + assertEquals("hot", span.getAttributes().get(START_TYPE_KEY)); + } + + @Test + public void restart_initialActivity_multiActivityApp() { + ActivityTracer trackableTracer = + ActivityTracer.builder(mock(Activity.class)) + .setInitialAppActivity("Activity") + .setTracer(tracer) + .setAppStartupTimer(appStartupTimer) + .setActiveSpan(activeSpan) + .build(); + trackableTracer.initiateRestartSpanIfNecessary(true); + trackableTracer.endActiveSpan(); + SpanData span = getSingleSpan(); + assertEquals("Restarted", span.getName()); + assertNull(span.getAttributes().get(START_TYPE_KEY)); + } + + @Test + public void create_nonInitialActivity() { + ActivityTracer trackableTracer = + ActivityTracer.builder(mock(Activity.class)) + .setInitialAppActivity("FirstActivity") + .setTracer(tracer) + .setAppStartupTimer(appStartupTimer) + .setActiveSpan(activeSpan) + .build(); + + trackableTracer.startActivityCreation(); + trackableTracer.endActiveSpan(); + SpanData span = getSingleSpan(); + assertEquals("Created", span.getName()); + assertNull(span.getAttributes().get(START_TYPE_KEY)); + } + + @Test + public void create_initialActivity() { + ActivityTracer trackableTracer = + ActivityTracer.builder(mock(Activity.class)) + .setInitialAppActivity("Activity") + .setTracer(tracer) + .setAppStartupTimer(appStartupTimer) + .setActiveSpan(activeSpan) + .build(); + trackableTracer.startActivityCreation(); + trackableTracer.endActiveSpan(); + SpanData span = getSingleSpan(); + assertEquals("AppStart", span.getName()); + assertEquals("warm", span.getAttributes().get(START_TYPE_KEY)); + } + + @Test + public void create_initialActivity_firstTime() { + appStartupTimer.start(tracer); + ActivityTracer trackableTracer = + ActivityTracer.builder(mock(Activity.class)) + .setTracer(tracer) + .setAppStartupTimer(appStartupTimer) + .setActiveSpan(activeSpan) + .build(); + trackableTracer.startActivityCreation(); + trackableTracer.endActiveSpan(); + appStartupTimer.end(); + + List spans = otelTesting.getSpans(); + assertEquals(2, spans.size()); + + SpanData appStartSpan = spans.get(0); + assertEquals("AppStart", appStartSpan.getName()); + assertEquals("cold", appStartSpan.getAttributes().get(START_TYPE_KEY)); + + SpanData innerSpan = spans.get(1); + assertEquals("Created", innerSpan.getName()); + } + + @Test + public void addPreviousScreen_noPrevious() { + ActivityTracer trackableTracer = + ActivityTracer.builder(mock(Activity.class)) + .setTracer(tracer) + .setAppStartupTimer(appStartupTimer) + .setActiveSpan(activeSpan) + .build(); + + trackableTracer.startSpanIfNoneInProgress("starting"); + trackableTracer.addPreviousScreenAttribute(); + trackableTracer.endActiveSpan(); + + SpanData span = getSingleSpan(); + assertNull(span.getAttributes().get(LAST_SCREEN_NAME_KEY)); + } + + @Test + public void addPreviousScreen_currentSameAsPrevious() { + VisibleScreenTracker visibleScreenTracker = mock(VisibleScreenTracker.class); + when(visibleScreenTracker.getPreviouslyVisibleScreen()).thenReturn("Activity"); + + ActivityTracer trackableTracer = + ActivityTracer.builder(mock(Activity.class)) + .setTracer(tracer) + .setAppStartupTimer(appStartupTimer) + .setActiveSpan(activeSpan) + .build(); + + trackableTracer.startSpanIfNoneInProgress("starting"); + trackableTracer.addPreviousScreenAttribute(); + trackableTracer.endActiveSpan(); + + SpanData span = getSingleSpan(); + assertNull(span.getAttributes().get(LAST_SCREEN_NAME_KEY)); + } + + @Test + public void addPreviousScreen() { + when(visibleScreenTracker.getPreviouslyVisibleScreen()).thenReturn("previousScreen"); + + ActivityTracer trackableTracer = + ActivityTracer.builder(mock(Activity.class)) + .setTracer(tracer) + .setAppStartupTimer(appStartupTimer) + .setActiveSpan(activeSpan) + .build(); + + trackableTracer.startSpanIfNoneInProgress("starting"); + trackableTracer.addPreviousScreenAttribute(); + trackableTracer.endActiveSpan(); + + SpanData span = getSingleSpan(); + assertEquals("previousScreen", span.getAttributes().get(LAST_SCREEN_NAME_KEY)); + } + + @Test + public void testScreenName() { + ActivityTracer activityTracer = + ActivityTracer.builder(mock(Activity.class)) + .setTracer(tracer) + .setScreenName("squarely") + .setAppStartupTimer(appStartupTimer) + .setActiveSpan(activeSpan) + .build(); + activityTracer.startActivityCreation(); + activityTracer.endActiveSpan(); + SpanData span = getSingleSpan(); + assertEquals("squarely", span.getAttributes().get(SCREEN_NAME_KEY)); + } + + private SpanData getSingleSpan() { + List generatedSpans = otelTesting.getSpans(); + assertEquals(1, generatedSpans.size()); + return generatedSpans.get(0); + } +} diff --git a/opentelemetry-android-instrumentation/src/test/java/io/opentelemetry/rum/internal/instrumentation/activity/Pre29ActivityCallbackTestHarness.java b/opentelemetry-android-instrumentation/src/test/java/io/opentelemetry/rum/internal/instrumentation/activity/Pre29ActivityCallbackTestHarness.java new file mode 100644 index 000000000..e8c7eb1d7 --- /dev/null +++ b/opentelemetry-android-instrumentation/src/test/java/io/opentelemetry/rum/internal/instrumentation/activity/Pre29ActivityCallbackTestHarness.java @@ -0,0 +1,85 @@ +/* + * Copyright Splunk Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.opentelemetry.rum.internal.instrumentation.activity; + +import static org.mockito.Mockito.mock; + +import android.app.Activity; +import android.os.Bundle; + +class Pre29ActivityCallbackTestHarness { + + private final Pre29ActivityCallbacks callbacks; + + Pre29ActivityCallbackTestHarness(Pre29ActivityCallbacks callbacks) { + this.callbacks = callbacks; + } + + void runAppStartupLifecycle(Activity mainActivity) { + // app startup lifecycle is the same as a normal activity lifecycle + runActivityCreationLifecycle(mainActivity); + } + + void runActivityCreationLifecycle(Activity activity) { + Bundle bundle = mock(Bundle.class); + + callbacks.onActivityCreated(activity, bundle); + + runActivityStartedLifecycle(activity); + runActivityResumedLifecycle(activity); + } + + void runActivityStartedLifecycle(Activity activity) { + callbacks.onActivityStarted(activity); + } + + void runActivityPausedLifecycle(Activity activity) { + callbacks.onActivityPaused(activity); + } + + void runActivityResumedLifecycle(Activity activity) { + callbacks.onActivityResumed(activity); + } + + void runActivityStoppedFromRunningLifecycle(Activity activity) { + runActivityPausedLifecycle(activity); + runActivityStoppedFromPausedLifecycle(activity); + } + + void runActivityStoppedFromPausedLifecycle(Activity activity) { + callbacks.onActivityStopped(activity); + } + + void runActivityDestroyedFromStoppedLifecycle(Activity activity) { + callbacks.onActivityDestroyed(activity); + } + + void runActivityDestroyedFromPausedLifecycle(Activity activity) { + runActivityStoppedFromPausedLifecycle(activity); + runActivityDestroyedFromStoppedLifecycle(activity); + } + + void runActivityDestroyedFromRunningLifecycle(Activity activity) { + runActivityStoppedFromRunningLifecycle(activity); + runActivityDestroyedFromStoppedLifecycle(activity); + } + + void runActivityRestartedLifecycle(Activity activity) { + runActivityStartedLifecycle(activity); + runActivityResumedLifecycle(activity); + } +} diff --git a/opentelemetry-android-instrumentation/src/test/java/io/opentelemetry/rum/internal/instrumentation/activity/Pre29ActivityLifecycleCallbacksTest.java b/opentelemetry-android-instrumentation/src/test/java/io/opentelemetry/rum/internal/instrumentation/activity/Pre29ActivityLifecycleCallbacksTest.java new file mode 100644 index 000000000..8a5207858 --- /dev/null +++ b/opentelemetry-android-instrumentation/src/test/java/io/opentelemetry/rum/internal/instrumentation/activity/Pre29ActivityLifecycleCallbacksTest.java @@ -0,0 +1,322 @@ +/* + * Copyright Splunk Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.opentelemetry.rum.internal.instrumentation.activity; + +import static io.opentelemetry.rum.internal.RumConstants.LAST_SCREEN_NAME_KEY; +import static io.opentelemetry.rum.internal.RumConstants.SCREEN_NAME_KEY; +import static io.opentelemetry.rum.internal.RumConstants.START_TYPE_KEY; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.isA; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import android.app.Activity; +import io.opentelemetry.api.trace.Tracer; +import io.opentelemetry.rum.internal.instrumentation.ScreenNameExtractor; +import io.opentelemetry.rum.internal.instrumentation.startup.AppStartupTimer; +import io.opentelemetry.sdk.testing.junit5.OpenTelemetryExtension; +import io.opentelemetry.sdk.trace.data.EventData; +import io.opentelemetry.sdk.trace.data.SpanData; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +class Pre29ActivityLifecycleCallbacksTest { + @RegisterExtension final OpenTelemetryExtension otelTesting = OpenTelemetryExtension.create(); + private ActivityTracerCache tracers; + + private VisibleScreenTracker visibleScreenTracker; + + @BeforeEach + void setup() { + AppStartupTimer appStartupTimer = new AppStartupTimer(); + Tracer tracer = otelTesting.getOpenTelemetry().getTracer("testTracer"); + visibleScreenTracker = mock(VisibleScreenTracker.class); + ScreenNameExtractor extractor = mock(ScreenNameExtractor.class); + when(extractor.extract(isA(Activity.class))).thenReturn("Activity"); + tracers = new ActivityTracerCache(tracer, visibleScreenTracker, appStartupTimer, extractor); + } + + @Test + void appStartup() { + Pre29ActivityCallbacks rumLifecycleCallbacks = new Pre29ActivityCallbacks(tracers); + Pre29ActivityCallbackTestHarness testHarness = + new Pre29ActivityCallbackTestHarness(rumLifecycleCallbacks); + + Activity activity = mock(Activity.class); + testHarness.runAppStartupLifecycle(activity); + + List spans = otelTesting.getSpans(); + assertEquals(1, spans.size()); + + SpanData creationSpan = spans.get(0); + + // TODO: Add test to relevant components + // assertEquals("AppStart", appStartSpan.getName()); + // assertEquals("cold", appStartSpan.getAttributes().get(SplunkRum.START_TYPE_KEY)); + + assertEquals( + activity.getClass().getSimpleName(), + creationSpan.getAttributes().get(ActivityTracer.ACTIVITY_NAME_KEY)); + assertEquals( + activity.getClass().getSimpleName(), + creationSpan.getAttributes().get(SCREEN_NAME_KEY)); + assertNull(creationSpan.getAttributes().get(LAST_SCREEN_NAME_KEY)); + + List events = creationSpan.getEvents(); + assertEquals(3, events.size()); + + checkEventExists(events, "activityCreated"); + checkEventExists(events, "activityStarted"); + checkEventExists(events, "activityResumed"); + } + + @Test + void activityCreation() { + Pre29ActivityCallbacks rumLifecycleCallbacks = new Pre29ActivityCallbacks(tracers); + Pre29ActivityCallbackTestHarness testHarness = + new Pre29ActivityCallbackTestHarness(rumLifecycleCallbacks); + startupAppAndClearSpans(testHarness); + + Activity activity = mock(Activity.class); + testHarness.runActivityCreationLifecycle(activity); + List spans = otelTesting.getSpans(); + assertEquals(1, spans.size()); + + SpanData span = spans.get(0); + + assertEquals("AppStart", span.getName()); + assertEquals("warm", span.getAttributes().get(START_TYPE_KEY)); + assertEquals( + activity.getClass().getSimpleName(), + span.getAttributes().get(ActivityTracer.ACTIVITY_NAME_KEY)); + assertEquals( + activity.getClass().getSimpleName(), span.getAttributes().get(SCREEN_NAME_KEY)); + assertNull(span.getAttributes().get(LAST_SCREEN_NAME_KEY)); + + List events = span.getEvents(); + assertEquals(3, events.size()); + + checkEventExists(events, "activityCreated"); + checkEventExists(events, "activityStarted"); + checkEventExists(events, "activityResumed"); + } + + private void startupAppAndClearSpans(Pre29ActivityCallbackTestHarness testHarness) { + // make sure that the initial state has been set up & the application is started. + testHarness.runAppStartupLifecycle(mock(Activity.class)); + otelTesting.clearSpans(); + } + + @Test + void activityRestart() { + Pre29ActivityCallbacks rumLifecycleCallbacks = new Pre29ActivityCallbacks(tracers); + Pre29ActivityCallbackTestHarness testHarness = + new Pre29ActivityCallbackTestHarness(rumLifecycleCallbacks); + + startupAppAndClearSpans(testHarness); + + Activity activity = mock(Activity.class); + testHarness.runActivityRestartedLifecycle(activity); + + List spans = otelTesting.getSpans(); + assertEquals(1, spans.size()); + + SpanData span = spans.get(0); + + assertEquals("AppStart", span.getName()); + assertEquals("hot", span.getAttributes().get(START_TYPE_KEY)); + assertEquals( + activity.getClass().getSimpleName(), + span.getAttributes().get(ActivityTracer.ACTIVITY_NAME_KEY)); + assertEquals( + activity.getClass().getSimpleName(), span.getAttributes().get(SCREEN_NAME_KEY)); + assertNull(span.getAttributes().get(LAST_SCREEN_NAME_KEY)); + + List events = span.getEvents(); + assertEquals(2, events.size()); + + checkEventExists(events, "activityStarted"); + checkEventExists(events, "activityResumed"); + } + + @Test + void activityResumed() { + when(visibleScreenTracker.getPreviouslyVisibleScreen()).thenReturn("previousScreen"); + + Pre29ActivityCallbacks rumLifecycleCallbacks = new Pre29ActivityCallbacks(tracers); + Pre29ActivityCallbackTestHarness testHarness = + new Pre29ActivityCallbackTestHarness(rumLifecycleCallbacks); + + startupAppAndClearSpans(testHarness); + + Activity activity = mock(Activity.class); + testHarness.runActivityResumedLifecycle(activity); + + List spans = otelTesting.getSpans(); + assertEquals(1, spans.size()); + + SpanData span = spans.get(0); + + assertEquals("Resumed", span.getName()); + assertEquals( + activity.getClass().getSimpleName(), + span.getAttributes().get(ActivityTracer.ACTIVITY_NAME_KEY)); + assertEquals( + activity.getClass().getSimpleName(), span.getAttributes().get(SCREEN_NAME_KEY)); + assertEquals("previousScreen", span.getAttributes().get(LAST_SCREEN_NAME_KEY)); + + List events = span.getEvents(); + assertEquals(1, events.size()); + + checkEventExists(events, "activityResumed"); + } + + @Test + void activityDestroyedFromStopped() { + Pre29ActivityCallbacks rumLifecycleCallbacks = new Pre29ActivityCallbacks(tracers); + Pre29ActivityCallbackTestHarness testHarness = + new Pre29ActivityCallbackTestHarness(rumLifecycleCallbacks); + + startupAppAndClearSpans(testHarness); + + Activity activity = mock(Activity.class); + testHarness.runActivityDestroyedFromStoppedLifecycle(activity); + + List spans = otelTesting.getSpans(); + assertEquals(1, spans.size()); + + SpanData span = spans.get(0); + + assertEquals("Destroyed", span.getName()); + assertEquals( + activity.getClass().getSimpleName(), + span.getAttributes().get(ActivityTracer.ACTIVITY_NAME_KEY)); + assertEquals( + activity.getClass().getSimpleName(), span.getAttributes().get(SCREEN_NAME_KEY)); + assertNull(span.getAttributes().get(LAST_SCREEN_NAME_KEY)); + + List events = span.getEvents(); + assertEquals(1, events.size()); + + checkEventExists(events, "activityDestroyed"); + } + + @Test + void activityDestroyedFromPaused() { + Pre29ActivityCallbacks rumLifecycleCallbacks = new Pre29ActivityCallbacks(tracers); + Pre29ActivityCallbackTestHarness testHarness = + new Pre29ActivityCallbackTestHarness(rumLifecycleCallbacks); + + startupAppAndClearSpans(testHarness); + + Activity activity = mock(Activity.class); + testHarness.runActivityDestroyedFromPausedLifecycle(activity); + + List spans = otelTesting.getSpans(); + assertEquals(2, spans.size()); + + SpanData stoppedSpan = spans.get(0); + + assertEquals("Stopped", stoppedSpan.getName()); + assertEquals( + activity.getClass().getSimpleName(), + stoppedSpan.getAttributes().get(ActivityTracer.ACTIVITY_NAME_KEY)); + assertEquals( + activity.getClass().getSimpleName(), + stoppedSpan.getAttributes().get(SCREEN_NAME_KEY)); + assertNull(stoppedSpan.getAttributes().get(LAST_SCREEN_NAME_KEY)); + + List events = stoppedSpan.getEvents(); + assertEquals(1, events.size()); + + checkEventExists(events, "activityStopped"); + + SpanData destroyedSpan = spans.get(1); + + assertEquals("Destroyed", destroyedSpan.getName()); + assertEquals( + activity.getClass().getSimpleName(), + destroyedSpan.getAttributes().get(ActivityTracer.ACTIVITY_NAME_KEY)); + assertEquals( + activity.getClass().getSimpleName(), + destroyedSpan.getAttributes().get(SCREEN_NAME_KEY)); + assertNull(destroyedSpan.getAttributes().get(LAST_SCREEN_NAME_KEY)); + + events = destroyedSpan.getEvents(); + assertEquals(1, events.size()); + + checkEventExists(events, "activityDestroyed"); + } + + @Test + void activityStoppedFromRunning() { + Pre29ActivityCallbacks rumLifecycleCallbacks = new Pre29ActivityCallbacks(tracers); + Pre29ActivityCallbackTestHarness testHarness = + new Pre29ActivityCallbackTestHarness(rumLifecycleCallbacks); + + startupAppAndClearSpans(testHarness); + + Activity activity = mock(Activity.class); + testHarness.runActivityStoppedFromRunningLifecycle(activity); + + List spans = otelTesting.getSpans(); + assertEquals(2, spans.size()); + + SpanData stoppedSpan = spans.get(0); + + assertEquals("Paused", stoppedSpan.getName()); + assertEquals( + activity.getClass().getSimpleName(), + stoppedSpan.getAttributes().get(ActivityTracer.ACTIVITY_NAME_KEY)); + assertEquals( + activity.getClass().getSimpleName(), + stoppedSpan.getAttributes().get(SCREEN_NAME_KEY)); + assertNull(stoppedSpan.getAttributes().get(LAST_SCREEN_NAME_KEY)); + + List events = stoppedSpan.getEvents(); + assertEquals(1, events.size()); + + checkEventExists(events, "activityPaused"); + + SpanData destroyedSpan = spans.get(1); + + assertEquals("Stopped", destroyedSpan.getName()); + assertEquals( + activity.getClass().getSimpleName(), + destroyedSpan.getAttributes().get(ActivityTracer.ACTIVITY_NAME_KEY)); + assertEquals( + activity.getClass().getSimpleName(), + destroyedSpan.getAttributes().get(SCREEN_NAME_KEY)); + assertNull(destroyedSpan.getAttributes().get(LAST_SCREEN_NAME_KEY)); + + events = destroyedSpan.getEvents(); + assertEquals(1, events.size()); + + checkEventExists(events, "activityStopped"); + } + + private void checkEventExists(List events, String eventName) { + Optional event = + events.stream().filter(e -> e.getName().equals(eventName)).findAny(); + assertTrue(event.isPresent(), "Event with name " + eventName + " not found"); + } +} diff --git a/opentelemetry-android-instrumentation/src/test/java/io/opentelemetry/rum/internal/instrumentation/activity/Pre29VisibleScreenLifecycleBindingTest.java b/opentelemetry-android-instrumentation/src/test/java/io/opentelemetry/rum/internal/instrumentation/activity/Pre29VisibleScreenLifecycleBindingTest.java new file mode 100644 index 000000000..65a2ccea1 --- /dev/null +++ b/opentelemetry-android-instrumentation/src/test/java/io/opentelemetry/rum/internal/instrumentation/activity/Pre29VisibleScreenLifecycleBindingTest.java @@ -0,0 +1,50 @@ +/* + * Copyright Splunk Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.opentelemetry.rum.internal.instrumentation.activity; + +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; + +import android.app.Activity; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class Pre29VisibleScreenLifecycleBindingTest { + @Mock Activity activity; + @Mock VisibleScreenTracker tracker; + + @Test + void postResumed() { + Pre29VisibleScreenLifecycleBinding underTest = + new Pre29VisibleScreenLifecycleBinding(tracker); + underTest.onActivityResumed(activity); + verify(tracker).activityResumed(activity); + verifyNoMoreInteractions(tracker); + } + + @Test + void prePaused() { + Pre29VisibleScreenLifecycleBinding underTest = + new Pre29VisibleScreenLifecycleBinding(tracker); + underTest.onActivityPaused(activity); + verify(tracker).activityPaused(activity); + verifyNoMoreInteractions(tracker); + } +} diff --git a/opentelemetry-android-instrumentation/src/test/java/io/opentelemetry/rum/internal/instrumentation/activity/RumFragmentActivityRegistererTest.java b/opentelemetry-android-instrumentation/src/test/java/io/opentelemetry/rum/internal/instrumentation/activity/RumFragmentActivityRegistererTest.java new file mode 100644 index 000000000..fa141703c --- /dev/null +++ b/opentelemetry-android-instrumentation/src/test/java/io/opentelemetry/rum/internal/instrumentation/activity/RumFragmentActivityRegistererTest.java @@ -0,0 +1,84 @@ +/* + * Copyright Splunk Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.opentelemetry.rum.internal.instrumentation.activity; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import android.app.Activity; +import android.app.Application; +import androidx.fragment.app.FragmentActivity; +import androidx.fragment.app.FragmentManager; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class RumFragmentActivityRegistererTest { + + @Mock FragmentManager.FragmentLifecycleCallbacks fragmentCallbacks; + + @Test + void createHappyPath() { + FragmentActivity activity = mock(FragmentActivity.class); + FragmentManager manager = mock(FragmentManager.class); + + when(activity.getSupportFragmentManager()).thenReturn(manager); + + Application.ActivityLifecycleCallbacks underTest = + RumFragmentActivityRegisterer.create(fragmentCallbacks); + + underTest.onActivityPreCreated(activity, null); + verify(manager).registerFragmentLifecycleCallbacks(fragmentCallbacks, true); + } + + @Test + void callbackIgnoresNonFragmentActivity() { + Activity activity = mock(Activity.class); + + Application.ActivityLifecycleCallbacks underTest = + RumFragmentActivityRegisterer.create(fragmentCallbacks); + + underTest.onActivityPreCreated(activity, null); + } + + @Test + void createPre29HappyPath() { + FragmentActivity activity = mock(FragmentActivity.class); + FragmentManager manager = mock(FragmentManager.class); + + when(activity.getSupportFragmentManager()).thenReturn(manager); + + Application.ActivityLifecycleCallbacks underTest = + RumFragmentActivityRegisterer.createPre29(fragmentCallbacks); + + underTest.onActivityCreated(activity, null); + verify(manager).registerFragmentLifecycleCallbacks(fragmentCallbacks, true); + } + + @Test + void pre29CallbackIgnoresNonFragmentActivity() { + Activity activity = mock(Activity.class); + + Application.ActivityLifecycleCallbacks underTest = + RumFragmentActivityRegisterer.createPre29(fragmentCallbacks); + + underTest.onActivityCreated(activity, null); + } +} diff --git a/opentelemetry-android-instrumentation/src/test/java/io/opentelemetry/rum/internal/instrumentation/activity/VisibleScreenLifecycleBindingTest.java b/opentelemetry-android-instrumentation/src/test/java/io/opentelemetry/rum/internal/instrumentation/activity/VisibleScreenLifecycleBindingTest.java new file mode 100644 index 000000000..d25864f53 --- /dev/null +++ b/opentelemetry-android-instrumentation/src/test/java/io/opentelemetry/rum/internal/instrumentation/activity/VisibleScreenLifecycleBindingTest.java @@ -0,0 +1,48 @@ +/* + * Copyright Splunk Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.opentelemetry.rum.internal.instrumentation.activity; + +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; + +import android.app.Activity; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class VisibleScreenLifecycleBindingTest { + + @Mock Activity activity; + @Mock VisibleScreenTracker tracker; + + @Test + void postResumed() { + VisibleScreenLifecycleBinding underTest = new VisibleScreenLifecycleBinding(tracker); + underTest.onActivityPostResumed(activity); + verify(tracker).activityResumed(activity); + verifyNoMoreInteractions(tracker); + } + + @Test + void prePaused() { + VisibleScreenLifecycleBinding underTest = new VisibleScreenLifecycleBinding(tracker); + underTest.onActivityPrePaused(activity); + verify(tracker).activityPaused(activity); + } +} diff --git a/opentelemetry-android-instrumentation/src/test/java/io/opentelemetry/rum/internal/instrumentation/activity/VisibleScreenTrackerTest.java b/opentelemetry-android-instrumentation/src/test/java/io/opentelemetry/rum/internal/instrumentation/activity/VisibleScreenTrackerTest.java new file mode 100644 index 000000000..efd3ff3ef --- /dev/null +++ b/opentelemetry-android-instrumentation/src/test/java/io/opentelemetry/rum/internal/instrumentation/activity/VisibleScreenTrackerTest.java @@ -0,0 +1,143 @@ +/* + * Copyright Splunk Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.opentelemetry.rum.internal.instrumentation.activity; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.mockito.Mockito.mock; + +import android.app.Activity; +import androidx.fragment.app.DialogFragment; +import androidx.fragment.app.Fragment; +import androidx.navigation.fragment.NavHostFragment; +import org.junit.jupiter.api.Test; + +class VisibleScreenTrackerTest { + + @Test + void activityLifecycle() { + VisibleScreenTracker visibleScreenTracker = new VisibleScreenTracker(); + Activity activity = mock(Activity.class); + + assertEquals("unknown", visibleScreenTracker.getCurrentlyVisibleScreen()); + + visibleScreenTracker.activityResumed(activity); + assertEquals( + activity.getClass().getSimpleName(), + visibleScreenTracker.getCurrentlyVisibleScreen()); + assertNull(visibleScreenTracker.getPreviouslyVisibleScreen()); + + visibleScreenTracker.activityPaused(activity); + assertEquals("unknown", visibleScreenTracker.getCurrentlyVisibleScreen()); + assertEquals( + activity.getClass().getSimpleName(), + visibleScreenTracker.getPreviouslyVisibleScreen()); + } + + @Test + void fragmentLifecycle() { + VisibleScreenTracker visibleScreenTracker = new VisibleScreenTracker(); + Fragment fragment = mock(Fragment.class); + + assertEquals("unknown", visibleScreenTracker.getCurrentlyVisibleScreen()); + + visibleScreenTracker.fragmentResumed(fragment); + assertEquals( + fragment.getClass().getSimpleName(), + visibleScreenTracker.getCurrentlyVisibleScreen()); + assertNull(visibleScreenTracker.getPreviouslyVisibleScreen()); + + visibleScreenTracker.fragmentPaused(fragment); + assertEquals("unknown", visibleScreenTracker.getCurrentlyVisibleScreen()); + assertEquals( + fragment.getClass().getSimpleName(), + visibleScreenTracker.getPreviouslyVisibleScreen()); + } + + @Test + void fragmentLifecycle_navHostIgnored() { + VisibleScreenTracker visibleScreenTracker = new VisibleScreenTracker(); + Fragment fragment = mock(Fragment.class); + NavHostFragment navHostFragment = mock(NavHostFragment.class); + + assertEquals("unknown", visibleScreenTracker.getCurrentlyVisibleScreen()); + + visibleScreenTracker.fragmentResumed(fragment); + visibleScreenTracker.fragmentResumed(navHostFragment); + assertEquals( + fragment.getClass().getSimpleName(), + visibleScreenTracker.getCurrentlyVisibleScreen()); + assertNull(visibleScreenTracker.getPreviouslyVisibleScreen()); + + visibleScreenTracker.fragmentPaused(navHostFragment); + visibleScreenTracker.fragmentPaused(fragment); + assertEquals("unknown", visibleScreenTracker.getCurrentlyVisibleScreen()); + assertEquals( + fragment.getClass().getSimpleName(), + visibleScreenTracker.getPreviouslyVisibleScreen()); + } + + @Test + void fragmentLifecycle_dialogFragment() { + VisibleScreenTracker visibleScreenTracker = new VisibleScreenTracker(); + Fragment fragment = mock(Fragment.class); + DialogFragment dialogFragment = mock(DialogFragment.class); + + assertEquals("unknown", visibleScreenTracker.getCurrentlyVisibleScreen()); + + visibleScreenTracker.fragmentResumed(fragment); + visibleScreenTracker.fragmentResumed(dialogFragment); + assertEquals( + dialogFragment.getClass().getSimpleName(), + visibleScreenTracker.getCurrentlyVisibleScreen()); + assertEquals( + fragment.getClass().getSimpleName(), + visibleScreenTracker.getPreviouslyVisibleScreen()); + + visibleScreenTracker.fragmentPaused(dialogFragment); + assertEquals( + fragment.getClass().getSimpleName(), + visibleScreenTracker.getCurrentlyVisibleScreen()); + assertEquals( + dialogFragment.getClass().getSimpleName(), + visibleScreenTracker.getPreviouslyVisibleScreen()); + } + + @Test + void fragmentWinsOverActivityLifecycle() { + VisibleScreenTracker visibleScreenTracker = new VisibleScreenTracker(); + Activity activity = mock(Activity.class); + Fragment fragment = mock(Fragment.class); + + assertEquals("unknown", visibleScreenTracker.getCurrentlyVisibleScreen()); + + visibleScreenTracker.activityResumed(activity); + visibleScreenTracker.fragmentResumed(fragment); + assertEquals( + fragment.getClass().getSimpleName(), + visibleScreenTracker.getCurrentlyVisibleScreen()); + assertNull(visibleScreenTracker.getPreviouslyVisibleScreen()); + + visibleScreenTracker.fragmentPaused(fragment); + assertEquals( + activity.getClass().getSimpleName(), + visibleScreenTracker.getCurrentlyVisibleScreen()); + assertEquals( + fragment.getClass().getSimpleName(), + visibleScreenTracker.getPreviouslyVisibleScreen()); + } +} diff --git a/opentelemetry-android-instrumentation/src/test/java/io/opentelemetry/rum/internal/instrumentation/anr/AnrDetectorTest.java b/opentelemetry-android-instrumentation/src/test/java/io/opentelemetry/rum/internal/instrumentation/anr/AnrDetectorTest.java new file mode 100644 index 000000000..15c103a70 --- /dev/null +++ b/opentelemetry-android-instrumentation/src/test/java/io/opentelemetry/rum/internal/instrumentation/anr/AnrDetectorTest.java @@ -0,0 +1,63 @@ +/* + * Copyright Splunk Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.opentelemetry.rum.internal.instrumentation.anr; + +import static io.opentelemetry.api.common.AttributeKey.stringKey; +import static io.opentelemetry.instrumentation.api.instrumenter.AttributesExtractor.constant; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isA; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import android.os.Looper; +import io.opentelemetry.rum.internal.instrumentation.InstrumentedApplication; +import io.opentelemetry.sdk.OpenTelemetrySdk; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class AnrDetectorTest { + + @Mock Looper mainLooper; + @Mock ScheduledExecutorService scheduler; + @Mock InstrumentedApplication instrumentedApplication; + + @Test + void shouldInstallInstrumentation() { + when(instrumentedApplication.getOpenTelemetrySdk()) + .thenReturn(OpenTelemetrySdk.builder().build()); + + AnrDetector anrDetector = + AnrDetector.builder() + .setMainLooper(mainLooper) + .setScheduler(scheduler) + .addAttributesExtractor(constant(stringKey("test.key"), "abc")) + .build(); + anrDetector.installOn(instrumentedApplication); + + // verify that the ANR scheduler was started + verify(scheduler) + .scheduleAtFixedRate(isA(AnrWatcher.class), eq(1L), eq(1L), eq(TimeUnit.SECONDS)); + // verify that an application listener was installed + verify(instrumentedApplication) + .registerApplicationStateListener(isA(AnrDetectorToggler.class)); + } +} diff --git a/opentelemetry-android-instrumentation/src/test/java/io/opentelemetry/rum/internal/instrumentation/anr/AnrDetectorTogglerTest.java b/opentelemetry-android-instrumentation/src/test/java/io/opentelemetry/rum/internal/instrumentation/anr/AnrDetectorTogglerTest.java new file mode 100644 index 000000000..ee6210b64 --- /dev/null +++ b/opentelemetry-android-instrumentation/src/test/java/io/opentelemetry/rum/internal/instrumentation/anr/AnrDetectorTogglerTest.java @@ -0,0 +1,64 @@ +/* + * Copyright Splunk Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.opentelemetry.rum.internal.instrumentation.anr; + +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class AnrDetectorTogglerTest { + + @Mock Runnable anrWatcher; + @Mock ScheduledExecutorService scheduler; + @Mock ScheduledFuture future; + + @InjectMocks AnrDetectorToggler underTest; + + @Test + void testOnApplicationForegrounded() { + doReturn(future).when(scheduler).scheduleAtFixedRate(anrWatcher, 1, 1, TimeUnit.SECONDS); + + underTest.onApplicationForegrounded(); + underTest.onApplicationForegrounded(); + underTest.onApplicationForegrounded(); + + verify(scheduler, times(1)).scheduleAtFixedRate(anrWatcher, 1, 1, TimeUnit.SECONDS); + } + + @Test + void testOnApplicationBackgrounded() { + doReturn(future).when(scheduler).scheduleAtFixedRate(anrWatcher, 1, 1, TimeUnit.SECONDS); + + underTest.onApplicationForegrounded(); + + underTest.onApplicationBackgrounded(); + underTest.onApplicationBackgrounded(); + underTest.onApplicationBackgrounded(); + + verify(future, times(1)).cancel(true); + } +} diff --git a/opentelemetry-android-instrumentation/src/test/java/io/opentelemetry/rum/internal/instrumentation/anr/AnrWatcherTest.java b/opentelemetry-android-instrumentation/src/test/java/io/opentelemetry/rum/internal/instrumentation/anr/AnrWatcherTest.java new file mode 100644 index 000000000..3bf34b08a --- /dev/null +++ b/opentelemetry-android-instrumentation/src/test/java/io/opentelemetry/rum/internal/instrumentation/anr/AnrWatcherTest.java @@ -0,0 +1,115 @@ +/* + * Copyright Splunk Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.opentelemetry.rum.internal.instrumentation.anr; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.isA; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.ArgumentMatchers.same; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; + +import android.os.Handler; +import io.opentelemetry.instrumentation.api.instrumenter.Instrumenter; +import io.opentelemetry.sdk.testing.junit5.OpenTelemetryExtension; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.extension.RegisterExtension; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class AnrWatcherTest { + + @RegisterExtension + static final OpenTelemetryExtension testing = OpenTelemetryExtension.create(); + + @Mock Handler handler; + @Mock Thread mainThread; + @Mock Instrumenter instrumenter; + + @Test + void mainThreadDisappearing() { + AnrWatcher anrWatcher = new AnrWatcher(handler, mainThread, instrumenter); + for (int i = 0; i < 5; i++) { + when(handler.post(isA(Runnable.class))).thenReturn(false); + anrWatcher.run(); + } + verifyNoInteractions(instrumenter); + } + + @Test + void noAnr() { + AnrWatcher anrWatcher = new AnrWatcher(handler, mainThread, instrumenter); + for (int i = 0; i < 5; i++) { + when(handler.post(isA(Runnable.class))) + .thenAnswer( + invocation -> { + Runnable callback = (Runnable) invocation.getArgument(0); + callback.run(); + return true; + }); + anrWatcher.run(); + } + verifyNoInteractions(instrumenter); + } + + @Test + void noAnr_temporaryPause() { + AnrWatcher anrWatcher = new AnrWatcher(handler, mainThread, instrumenter); + for (int i = 0; i < 5; i++) { + int index = i; + when(handler.post(isA(Runnable.class))) + .thenAnswer( + invocation -> { + Runnable callback = invocation.getArgument(0); + // have it fail once + if (index != 3) { + callback.run(); + } + return true; + }); + anrWatcher.run(); + } + verifyNoInteractions(instrumenter); + } + + @Test + void anr_detected() { + StackTraceElement[] stackTrace = new StackTraceElement[0]; + when(mainThread.getStackTrace()).thenReturn(stackTrace); + + AnrWatcher anrWatcher = new AnrWatcher(handler, mainThread, instrumenter); + when(handler.post(isA(Runnable.class))).thenReturn(true); + for (int i = 0; i < 5; i++) { + anrWatcher.run(); + } + verify(instrumenter, times(1)).start(any(), same(stackTrace)); + verify(instrumenter, times(1)).end(any(), same(stackTrace), isNull(), isNull()); + for (int i = 0; i < 4; i++) { + anrWatcher.run(); + } + verifyNoMoreInteractions(instrumenter); + + anrWatcher.run(); + verify(instrumenter, times(2)).start(any(), same(stackTrace)); + verify(instrumenter, times(2)).end(any(), same(stackTrace), isNull(), isNull()); + } +} diff --git a/opentelemetry-android-instrumentation/src/test/java/io/opentelemetry/rum/internal/instrumentation/anr/StackTraceFormatterTest.java b/opentelemetry-android-instrumentation/src/test/java/io/opentelemetry/rum/internal/instrumentation/anr/StackTraceFormatterTest.java new file mode 100644 index 000000000..4cc9e5161 --- /dev/null +++ b/opentelemetry-android-instrumentation/src/test/java/io/opentelemetry/rum/internal/instrumentation/anr/StackTraceFormatterTest.java @@ -0,0 +1,52 @@ +/* + * Copyright Splunk Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.opentelemetry.rum.internal.instrumentation.anr; + +import static io.opentelemetry.sdk.testing.assertj.OpenTelemetryAssertions.assertThat; + +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.api.common.AttributesBuilder; +import io.opentelemetry.context.Context; +import io.opentelemetry.semconv.trace.attributes.SemanticAttributes; +import org.junit.jupiter.api.Test; + +class StackTraceFormatterTest { + + @Test + void shouldSerializeStackTrace() { + StackTraceElement[] stackTrace = + new StackTraceElement[] { + new StackTraceElement("a.b.Class", "foo", "/src/a/b/Class.java", 42), + new StackTraceElement( + "a.b.AnotherClass", "bar", "/src/a/b/AnotherClass.java", 123) + }; + StackTraceFormatter underTest = new StackTraceFormatter(); + + AttributesBuilder startAttributes = Attributes.builder(); + underTest.onStart(startAttributes, Context.current(), stackTrace); + assertThat(startAttributes.build()) + .hasSize(1) + .containsEntry( + SemanticAttributes.EXCEPTION_STACKTRACE, + "a.b.Class.foo(/src/a/b/Class.java:42)\n" + + "a.b.AnotherClass.bar(/src/a/b/AnotherClass.java:123)\n"); + + AttributesBuilder endAttributes = Attributes.builder(); + underTest.onEnd(endAttributes, Context.current(), stackTrace, null, null); + assertThat(endAttributes.build()).isEmpty(); + } +} diff --git a/opentelemetry-android-instrumentation/src/test/java/io/opentelemetry/rum/internal/instrumentation/crash/CrashReporterTest.java b/opentelemetry-android-instrumentation/src/test/java/io/opentelemetry/rum/internal/instrumentation/crash/CrashReporterTest.java new file mode 100644 index 000000000..130048f7d --- /dev/null +++ b/opentelemetry-android-instrumentation/src/test/java/io/opentelemetry/rum/internal/instrumentation/crash/CrashReporterTest.java @@ -0,0 +1,102 @@ +/* + * Copyright Splunk Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.opentelemetry.rum.internal.instrumentation.crash; + +import static io.opentelemetry.api.common.AttributeKey.stringKey; +import static io.opentelemetry.instrumentation.api.instrumenter.AttributesExtractor.constant; +import static org.awaitility.Awaitility.await; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.api.trace.SpanKind; +import io.opentelemetry.rum.internal.instrumentation.InstrumentedApplication; +import io.opentelemetry.sdk.OpenTelemetrySdk; +import io.opentelemetry.sdk.testing.assertj.TraceAssert; +import io.opentelemetry.sdk.testing.junit5.OpenTelemetryExtension; +import io.opentelemetry.sdk.trace.data.StatusData; +import io.opentelemetry.semconv.trace.attributes.SemanticAttributes; +import java.time.Duration; +import java.util.function.Consumer; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +class CrashReporterTest { + + @RegisterExtension + static final OpenTelemetryExtension testing = OpenTelemetryExtension.create(); + + static Thread.UncaughtExceptionHandler existingHandler; + + @BeforeAll + static void setUp() { + existingHandler = Thread.getDefaultUncaughtExceptionHandler(); + // disable the handler installed by junit + Thread.setDefaultUncaughtExceptionHandler(null); + } + + @AfterAll + static void tearDown() { + Thread.setDefaultUncaughtExceptionHandler(existingHandler); + } + + @Test + void integrationTest() throws InterruptedException { + InstrumentedApplication instrumentedApplication = mock(InstrumentedApplication.class); + when(instrumentedApplication.getOpenTelemetrySdk()) + .thenReturn((OpenTelemetrySdk) testing.getOpenTelemetry()); + + CrashReporter.builder() + .addAttributesExtractor(constant(stringKey("test.key"), "abc")) + .build() + .installOn(instrumentedApplication); + + RuntimeException crash = new RuntimeException("boooom!"); + Thread crashingThread = + new Thread( + () -> { + throw crash; + }); + crashingThread.setDaemon(true); + crashingThread.start(); + crashingThread.join(); + + Attributes expectedAttributes = + Attributes.builder() + .put(SemanticAttributes.EXCEPTION_ESCAPED, true) + .put(SemanticAttributes.THREAD_ID, crashingThread.getId()) + .put(SemanticAttributes.THREAD_NAME, crashingThread.getName()) + .put(stringKey("test.key"), "abc") + .build(); + assertTrace( + trace -> + trace.hasSpansSatisfyingExactly( + span -> + span.hasName("RuntimeException") + .hasKind(SpanKind.INTERNAL) + .hasStatus(StatusData.error()) + .hasException(crash) + .hasAttributes(expectedAttributes))); + } + + private static void assertTrace(Consumer assertion) { + await().atMost(Duration.ofSeconds(30)) + .untilAsserted(() -> testing.assertTraces().hasTracesSatisfyingExactly(assertion)); + } +} diff --git a/opentelemetry-android-instrumentation/src/test/java/io/opentelemetry/rum/internal/instrumentation/crash/CrashReportingExceptionHandlerTest.java b/opentelemetry-android-instrumentation/src/test/java/io/opentelemetry/rum/internal/instrumentation/crash/CrashReportingExceptionHandlerTest.java new file mode 100644 index 000000000..46466db52 --- /dev/null +++ b/opentelemetry-android-instrumentation/src/test/java/io/opentelemetry/rum/internal/instrumentation/crash/CrashReportingExceptionHandlerTest.java @@ -0,0 +1,66 @@ +/* + * Copyright Splunk Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.opentelemetry.rum.internal.instrumentation.crash; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.when; + +import io.opentelemetry.context.Context; +import io.opentelemetry.instrumentation.api.instrumenter.Instrumenter; +import io.opentelemetry.sdk.common.CompletableResultCode; +import io.opentelemetry.sdk.trace.SdkTracerProvider; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InOrder; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class CrashReportingExceptionHandlerTest { + + @Mock Instrumenter instrumenter; + @Mock SdkTracerProvider sdkTracerProvider; + @Mock Thread.UncaughtExceptionHandler existingHandler; + @Mock CompletableResultCode flushResult; + + @Test + void shouldReportCrash() { + when(sdkTracerProvider.forceFlush()).thenReturn(flushResult); + + CrashReportingExceptionHandler handler = + new CrashReportingExceptionHandler( + instrumenter, sdkTracerProvider, existingHandler); + + NullPointerException oopsie = new NullPointerException("oopsie"); + Thread crashThread = new Thread("badThread"); + + handler.uncaughtException(crashThread, oopsie); + + CrashDetails crashDetails = CrashDetails.create(crashThread, oopsie); + InOrder io = inOrder(instrumenter, sdkTracerProvider, flushResult, existingHandler); + io.verify(instrumenter).start(Context.current(), crashDetails); + io.verify(instrumenter).end(any(), eq(crashDetails), isNull(), eq(oopsie)); + io.verify(sdkTracerProvider).forceFlush(); + io.verify(flushResult).join(10, TimeUnit.SECONDS); + io.verify(existingHandler).uncaughtException(crashThread, oopsie); + io.verifyNoMoreInteractions(); + } +} diff --git a/opentelemetry-android-instrumentation/src/test/java/io/opentelemetry/rum/internal/instrumentation/fragment/FragmentCallbackTestHarness.java b/opentelemetry-android-instrumentation/src/test/java/io/opentelemetry/rum/internal/instrumentation/fragment/FragmentCallbackTestHarness.java new file mode 100644 index 000000000..9101d2d31 --- /dev/null +++ b/opentelemetry-android-instrumentation/src/test/java/io/opentelemetry/rum/internal/instrumentation/fragment/FragmentCallbackTestHarness.java @@ -0,0 +1,95 @@ +/* + * Copyright Splunk Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.opentelemetry.rum.internal.instrumentation.fragment; + +import static org.mockito.Mockito.mock; + +import android.content.Context; +import android.os.Bundle; +import android.view.View; +import androidx.fragment.app.Fragment; +import androidx.fragment.app.FragmentManager; + +class FragmentCallbackTestHarness { + + private final RumFragmentLifecycleCallbacks callbacks; + + FragmentCallbackTestHarness(RumFragmentLifecycleCallbacks callbacks) { + this.callbacks = callbacks; + } + + void runFragmentCreationLifecycle(Fragment fragment) { + Context context = mock(Context.class); + FragmentManager fragmentManager = mock(FragmentManager.class); + Bundle bundle = mock(Bundle.class); + + callbacks.onFragmentPreAttached(fragmentManager, fragment, context); + callbacks.onFragmentAttached(fragmentManager, fragment, context); + callbacks.onFragmentPreCreated(fragmentManager, fragment, bundle); + callbacks.onFragmentCreated(fragmentManager, fragment, bundle); + runFragmentRestoredLifecycle(fragment); + } + + void runFragmentRestoredLifecycle(Fragment fragment) { + FragmentManager fragmentManager = mock(FragmentManager.class); + Bundle bundle = mock(Bundle.class); + View view = mock(View.class); + callbacks.onFragmentViewCreated(fragmentManager, fragment, view, bundle); + callbacks.onFragmentStarted(fragmentManager, fragment); + callbacks.onFragmentResumed(fragmentManager, fragment); + } + + void runFragmentResumedLifecycle(Fragment fragment) { + FragmentManager fragmentManager = mock(FragmentManager.class); + callbacks.onFragmentResumed(fragmentManager, fragment); + } + + void runFragmentPausedLifecycle(Fragment fragment) { + FragmentManager fragmentManager = mock(FragmentManager.class); + callbacks.onFragmentPaused(fragmentManager, fragment); + callbacks.onFragmentStopped(fragmentManager, fragment); + } + + void runFragmentDetachedFromActiveLifecycle(Fragment fragment) { + FragmentManager fragmentManager = mock(FragmentManager.class); + + runFragmentPausedLifecycle(fragment); + callbacks.onFragmentViewDestroyed(fragmentManager, fragment); + callbacks.onFragmentDestroyed(fragmentManager, fragment); + runFragmentDetachedLifecycle(fragment); + } + + void runFragmentViewDestroyedFromStoppedLifecycle(Fragment fragment) { + FragmentManager fragmentManager = mock(FragmentManager.class); + + callbacks.onFragmentViewDestroyed(fragmentManager, fragment); + } + + void runFragmentDetachedFromStoppedLifecycle(Fragment fragment) { + FragmentManager fragmentManager = mock(FragmentManager.class); + + runFragmentViewDestroyedFromStoppedLifecycle(fragment); + callbacks.onFragmentDestroyed(fragmentManager, fragment); + runFragmentDetachedLifecycle(fragment); + } + + void runFragmentDetachedLifecycle(Fragment fragment) { + FragmentManager fragmentManager = mock(FragmentManager.class); + + callbacks.onFragmentDetached(fragmentManager, fragment); + } +} diff --git a/opentelemetry-android-instrumentation/src/test/java/io/opentelemetry/rum/internal/instrumentation/fragment/FragmentTracerTest.java b/opentelemetry-android-instrumentation/src/test/java/io/opentelemetry/rum/internal/instrumentation/fragment/FragmentTracerTest.java new file mode 100644 index 000000000..42dbf4231 --- /dev/null +++ b/opentelemetry-android-instrumentation/src/test/java/io/opentelemetry/rum/internal/instrumentation/fragment/FragmentTracerTest.java @@ -0,0 +1,124 @@ +/* + * Copyright Splunk Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.opentelemetry.rum.internal.instrumentation.fragment; + +import static io.opentelemetry.rum.internal.RumConstants.LAST_SCREEN_NAME_KEY; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import androidx.fragment.app.Fragment; +import io.opentelemetry.api.trace.Tracer; +import io.opentelemetry.rum.internal.instrumentation.activity.VisibleScreenTracker; +import io.opentelemetry.rum.internal.util.ActiveSpan; +import io.opentelemetry.sdk.testing.junit5.OpenTelemetryExtension; +import io.opentelemetry.sdk.trace.data.SpanData; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +class FragmentTracerTest { + @RegisterExtension final OpenTelemetryExtension otelTesting = OpenTelemetryExtension.create(); + private Tracer tracer; + private ActiveSpan activeSpan; + + @BeforeEach + void setup() { + tracer = otelTesting.getOpenTelemetry().getTracer("testTracer"); + VisibleScreenTracker visibleScreenTracker = mock(VisibleScreenTracker.class); + activeSpan = new ActiveSpan(visibleScreenTracker::getPreviouslyVisibleScreen); + } + + @Test + void create() { + FragmentTracer trackableTracer = + FragmentTracer.builder(mock(Fragment.class)) + .setTracer(tracer) + .setActiveSpan(activeSpan) + .build(); + trackableTracer.startFragmentCreation(); + trackableTracer.endActiveSpan(); + SpanData span = getSingleSpan(); + assertEquals("Created", span.getName()); + } + + @Test + void addPreviousScreen_noPrevious() { + VisibleScreenTracker visibleScreenTracker = mock(VisibleScreenTracker.class); + + FragmentTracer trackableTracer = + FragmentTracer.builder(mock(Fragment.class)) + .setTracer(tracer) + .setActiveSpan(activeSpan) + .build(); + + trackableTracer.startSpanIfNoneInProgress("starting"); + trackableTracer.addPreviousScreenAttribute(); + trackableTracer.endActiveSpan(); + + SpanData span = getSingleSpan(); + assertNull(span.getAttributes().get(LAST_SCREEN_NAME_KEY)); + } + + @Test + void addPreviousScreen_currentSameAsPrevious() { + VisibleScreenTracker visibleScreenTracker = mock(VisibleScreenTracker.class); + when(visibleScreenTracker.getPreviouslyVisibleScreen()).thenReturn("Fragment"); + + FragmentTracer trackableTracer = + FragmentTracer.builder(mock(Fragment.class)) + .setTracer(tracer) + .setActiveSpan(activeSpan) + .build(); + + trackableTracer.startSpanIfNoneInProgress("starting"); + trackableTracer.addPreviousScreenAttribute(); + trackableTracer.endActiveSpan(); + + SpanData span = getSingleSpan(); + assertNull(span.getAttributes().get(LAST_SCREEN_NAME_KEY)); + } + + @Test + void addPreviousScreen() { + + VisibleScreenTracker visibleScreenTracker = mock(VisibleScreenTracker.class); + when(visibleScreenTracker.getPreviouslyVisibleScreen()).thenReturn("previousScreen"); + activeSpan = new ActiveSpan(visibleScreenTracker::getPreviouslyVisibleScreen); + + FragmentTracer fragmentTracer = + FragmentTracer.builder(mock(Fragment.class)) + .setTracer(tracer) + .setActiveSpan(activeSpan) + .build(); + + fragmentTracer.startSpanIfNoneInProgress("starting"); + fragmentTracer.addPreviousScreenAttribute(); + fragmentTracer.endActiveSpan(); + + SpanData span = getSingleSpan(); + assertEquals("previousScreen", span.getAttributes().get(LAST_SCREEN_NAME_KEY)); + } + + private SpanData getSingleSpan() { + List generatedSpans = otelTesting.getSpans(); + assertEquals(1, generatedSpans.size()); + return generatedSpans.get(0); + } +} diff --git a/opentelemetry-android-instrumentation/src/test/java/io/opentelemetry/rum/internal/instrumentation/fragment/RumFragmentLifecycleCallbacksTest.java b/opentelemetry-android-instrumentation/src/test/java/io/opentelemetry/rum/internal/instrumentation/fragment/RumFragmentLifecycleCallbacksTest.java new file mode 100644 index 000000000..536c37f98 --- /dev/null +++ b/opentelemetry-android-instrumentation/src/test/java/io/opentelemetry/rum/internal/instrumentation/fragment/RumFragmentLifecycleCallbacksTest.java @@ -0,0 +1,338 @@ +/* + * Copyright Splunk Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.opentelemetry.rum.internal.instrumentation.fragment; + +import static io.opentelemetry.rum.internal.RumConstants.LAST_SCREEN_NAME_KEY; +import static io.opentelemetry.rum.internal.RumConstants.SCREEN_NAME_KEY; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.isA; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import androidx.fragment.app.Fragment; +import io.opentelemetry.api.trace.Tracer; +import io.opentelemetry.rum.internal.instrumentation.ScreenNameExtractor; +import io.opentelemetry.rum.internal.instrumentation.activity.VisibleScreenTracker; +import io.opentelemetry.sdk.testing.junit5.OpenTelemetryExtension; +import io.opentelemetry.sdk.trace.data.EventData; +import io.opentelemetry.sdk.trace.data.SpanData; +import java.util.List; +import java.util.Optional; +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.RegisterExtension; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class RumFragmentLifecycleCallbacksTest { + @RegisterExtension final OpenTelemetryExtension otelTesting = OpenTelemetryExtension.create(); + private final VisibleScreenTracker visibleScreenTracker = mock(VisibleScreenTracker.class); + private Tracer tracer; + @Mock private ScreenNameExtractor screenNameExtractor; + + @BeforeEach + void setup() { + tracer = otelTesting.getOpenTelemetry().getTracer("testTracer"); + when(screenNameExtractor.extract(isA(Fragment.class))).thenReturn("Fragment"); + } + + @Test + void fragmentCreation() { + FragmentCallbackTestHarness testHarness = + new FragmentCallbackTestHarness( + new RumFragmentLifecycleCallbacks( + tracer, visibleScreenTracker, screenNameExtractor)); + + Fragment fragment = mock(Fragment.class); + testHarness.runFragmentCreationLifecycle(fragment); + + List spans = otelTesting.getSpans(); + assertEquals(1, spans.size()); + + SpanData spanData = spans.get(0); + + assertEquals("Created", spanData.getName()); + assertEquals( + fragment.getClass().getSimpleName(), + spanData.getAttributes().get(FragmentTracer.FRAGMENT_NAME_KEY)); + assertEquals( + fragment.getClass().getSimpleName(), spanData.getAttributes().get(SCREEN_NAME_KEY)); + assertNull(spanData.getAttributes().get(LAST_SCREEN_NAME_KEY)); + + List events = spanData.getEvents(); + assertEquals(7, events.size()); + checkEventExists(events, "fragmentPreAttached"); + checkEventExists(events, "fragmentAttached"); + checkEventExists(events, "fragmentPreCreated"); + checkEventExists(events, "fragmentCreated"); + checkEventExists(events, "fragmentViewCreated"); + checkEventExists(events, "fragmentStarted"); + checkEventExists(events, "fragmentResumed"); + } + + @Test + void fragmentRestored() { + when(visibleScreenTracker.getPreviouslyVisibleScreen()).thenReturn("previousScreen"); + FragmentCallbackTestHarness testHarness = + new FragmentCallbackTestHarness( + new RumFragmentLifecycleCallbacks( + tracer, visibleScreenTracker, screenNameExtractor)); + + Fragment fragment = mock(Fragment.class); + testHarness.runFragmentRestoredLifecycle(fragment); + + List spans = otelTesting.getSpans(); + assertEquals(1, spans.size()); + + SpanData spanData = spans.get(0); + + assertEquals("Restored", spanData.getName()); + assertEquals( + fragment.getClass().getSimpleName(), + spanData.getAttributes().get(FragmentTracer.FRAGMENT_NAME_KEY)); + assertEquals( + fragment.getClass().getSimpleName(), spanData.getAttributes().get(SCREEN_NAME_KEY)); + assertEquals("previousScreen", spanData.getAttributes().get(LAST_SCREEN_NAME_KEY)); + + List events = spanData.getEvents(); + assertEquals(3, events.size()); + checkEventExists(events, "fragmentViewCreated"); + checkEventExists(events, "fragmentStarted"); + checkEventExists(events, "fragmentResumed"); + } + + @Test + void fragmentResumed() { + FragmentCallbackTestHarness testHarness = + new FragmentCallbackTestHarness( + new RumFragmentLifecycleCallbacks( + tracer, visibleScreenTracker, screenNameExtractor)); + + Fragment fragment = mock(Fragment.class); + testHarness.runFragmentResumedLifecycle(fragment); + + List spans = otelTesting.getSpans(); + assertEquals(1, spans.size()); + + SpanData spanData = spans.get(0); + + assertEquals("Resumed", spanData.getName()); + assertEquals( + fragment.getClass().getSimpleName(), + spanData.getAttributes().get(FragmentTracer.FRAGMENT_NAME_KEY)); + assertNull(spanData.getAttributes().get(LAST_SCREEN_NAME_KEY)); + + List events = spanData.getEvents(); + assertEquals(1, events.size()); + checkEventExists(events, "fragmentResumed"); + } + + @Test + void fragmentPaused() { + FragmentCallbackTestHarness testHarness = + new FragmentCallbackTestHarness( + new RumFragmentLifecycleCallbacks( + tracer, visibleScreenTracker, screenNameExtractor)); + + Fragment fragment = mock(Fragment.class); + testHarness.runFragmentPausedLifecycle(fragment); + + List spans = otelTesting.getSpans(); + assertEquals(1, spans.size()); + + SpanData spanData = spans.get(0); + + assertEquals("Paused", spanData.getName()); + assertEquals( + fragment.getClass().getSimpleName(), + spanData.getAttributes().get(FragmentTracer.FRAGMENT_NAME_KEY)); + assertEquals( + fragment.getClass().getSimpleName(), spanData.getAttributes().get(SCREEN_NAME_KEY)); + assertNull(spanData.getAttributes().get(LAST_SCREEN_NAME_KEY)); + + List events = spanData.getEvents(); + assertEquals(2, events.size()); + checkEventExists(events, "fragmentPaused"); + checkEventExists(events, "fragmentStopped"); + } + + @Test + void fragmentDetachedFromActive() { + FragmentCallbackTestHarness testHarness = + new FragmentCallbackTestHarness( + new RumFragmentLifecycleCallbacks( + tracer, visibleScreenTracker, screenNameExtractor)); + + Fragment fragment = mock(Fragment.class); + testHarness.runFragmentDetachedFromActiveLifecycle(fragment); + + List spans = otelTesting.getSpans(); + assertEquals(3, spans.size()); + + SpanData pauseSpan = spans.get(0); + + assertEquals("Paused", pauseSpan.getName()); + assertEquals( + fragment.getClass().getSimpleName(), + pauseSpan.getAttributes().get(FragmentTracer.FRAGMENT_NAME_KEY)); + assertEquals( + fragment.getClass().getSimpleName(), + pauseSpan.getAttributes().get(SCREEN_NAME_KEY)); + assertNull(pauseSpan.getAttributes().get(LAST_SCREEN_NAME_KEY)); + + List events = pauseSpan.getEvents(); + assertEquals(2, events.size()); + checkEventExists(events, "fragmentPaused"); + checkEventExists(events, "fragmentStopped"); + + SpanData destroyViewSpan = spans.get(1); + + assertEquals("ViewDestroyed", destroyViewSpan.getName()); + assertEquals( + fragment.getClass().getSimpleName(), + destroyViewSpan.getAttributes().get(FragmentTracer.FRAGMENT_NAME_KEY)); + assertEquals( + fragment.getClass().getSimpleName(), + destroyViewSpan.getAttributes().get(SCREEN_NAME_KEY)); + assertNull(destroyViewSpan.getAttributes().get(LAST_SCREEN_NAME_KEY)); + + events = destroyViewSpan.getEvents(); + assertEquals(1, events.size()); + checkEventExists(events, "fragmentViewDestroyed"); + + SpanData detachSpan = spans.get(2); + + assertEquals("Destroyed", detachSpan.getName()); + assertNotNull(detachSpan.getAttributes().get(FragmentTracer.FRAGMENT_NAME_KEY)); + assertNull(detachSpan.getAttributes().get(LAST_SCREEN_NAME_KEY)); + + events = detachSpan.getEvents(); + assertEquals(2, events.size()); + checkEventExists(events, "fragmentDestroyed"); + checkEventExists(events, "fragmentDetached"); + } + + @Test + void fragmentDestroyedFromStopped() { + FragmentCallbackTestHarness testHarness = + new FragmentCallbackTestHarness( + new RumFragmentLifecycleCallbacks( + tracer, visibleScreenTracker, screenNameExtractor)); + + Fragment fragment = mock(Fragment.class); + testHarness.runFragmentViewDestroyedFromStoppedLifecycle(fragment); + + List spans = otelTesting.getSpans(); + assertEquals(1, spans.size()); + + SpanData span = spans.get(0); + + assertEquals("ViewDestroyed", span.getName()); + assertEquals( + fragment.getClass().getSimpleName(), span.getAttributes().get(SCREEN_NAME_KEY)); + assertEquals( + fragment.getClass().getSimpleName(), + span.getAttributes().get(FragmentTracer.FRAGMENT_NAME_KEY)); + assertNull(span.getAttributes().get(LAST_SCREEN_NAME_KEY)); + + List events = span.getEvents(); + assertEquals(1, events.size()); + checkEventExists(events, "fragmentViewDestroyed"); + } + + @Test + void fragmentDetachedFromStopped() { + FragmentCallbackTestHarness testHarness = + new FragmentCallbackTestHarness( + new RumFragmentLifecycleCallbacks( + tracer, visibleScreenTracker, screenNameExtractor)); + + Fragment fragment = mock(Fragment.class); + testHarness.runFragmentDetachedFromStoppedLifecycle(fragment); + + List spans = otelTesting.getSpans(); + assertEquals(2, spans.size()); + + SpanData destroyViewSpan = spans.get(0); + + assertEquals("ViewDestroyed", destroyViewSpan.getName()); + assertEquals( + fragment.getClass().getSimpleName(), + destroyViewSpan.getAttributes().get(SCREEN_NAME_KEY)); + assertEquals( + fragment.getClass().getSimpleName(), + destroyViewSpan.getAttributes().get(FragmentTracer.FRAGMENT_NAME_KEY)); + assertNull(destroyViewSpan.getAttributes().get(LAST_SCREEN_NAME_KEY)); + + List events = destroyViewSpan.getEvents(); + assertEquals(1, events.size()); + checkEventExists(events, "fragmentViewDestroyed"); + + SpanData detachSpan = spans.get(1); + + assertEquals("Destroyed", detachSpan.getName()); + assertEquals( + fragment.getClass().getSimpleName(), + detachSpan.getAttributes().get(FragmentTracer.FRAGMENT_NAME_KEY)); + assertNull(detachSpan.getAttributes().get(LAST_SCREEN_NAME_KEY)); + + events = detachSpan.getEvents(); + assertEquals(2, events.size()); + checkEventExists(events, "fragmentDestroyed"); + checkEventExists(events, "fragmentDetached"); + } + + @Test + void fragmentDetached() { + FragmentCallbackTestHarness testHarness = + new FragmentCallbackTestHarness( + new RumFragmentLifecycleCallbacks( + tracer, visibleScreenTracker, screenNameExtractor)); + + Fragment fragment = mock(Fragment.class); + testHarness.runFragmentDetachedLifecycle(fragment); + + List spans = otelTesting.getSpans(); + assertEquals(1, spans.size()); + + SpanData detachSpan = spans.get(0); + + assertEquals("Detached", detachSpan.getName()); + assertEquals( + fragment.getClass().getSimpleName(), + detachSpan.getAttributes().get(SCREEN_NAME_KEY)); + assertEquals( + fragment.getClass().getSimpleName(), + detachSpan.getAttributes().get(FragmentTracer.FRAGMENT_NAME_KEY)); + assertNull(detachSpan.getAttributes().get(LAST_SCREEN_NAME_KEY)); + + List events = detachSpan.getEvents(); + assertEquals(1, events.size()); + checkEventExists(events, "fragmentDetached"); + } + + private void checkEventExists(List events, String eventName) { + Optional event = + events.stream().filter(e -> e.getName().equals(eventName)).findAny(); + assertTrue(event.isPresent(), "Event with name " + eventName + " not found"); + } +} diff --git a/opentelemetry-android-instrumentation/src/test/java/io/opentelemetry/rum/internal/instrumentation/fragment/ScreenNameExtractorTest.java b/opentelemetry-android-instrumentation/src/test/java/io/opentelemetry/rum/internal/instrumentation/fragment/ScreenNameExtractorTest.java new file mode 100644 index 000000000..e17b865a5 --- /dev/null +++ b/opentelemetry-android-instrumentation/src/test/java/io/opentelemetry/rum/internal/instrumentation/fragment/ScreenNameExtractorTest.java @@ -0,0 +1,62 @@ +/* + * Copyright Splunk Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.opentelemetry.rum.internal.instrumentation.fragment; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import android.app.Activity; +import androidx.fragment.app.Fragment; +import io.opentelemetry.rum.internal.instrumentation.RumScreenName; +import io.opentelemetry.rum.internal.instrumentation.ScreenNameExtractor; +import org.junit.jupiter.api.Test; + +class ScreenNameExtractorTest { + + @Test + void testActivity() { + Activity activity = new Activity(); + String name = ScreenNameExtractor.DEFAULT.extract(activity); + assertEquals("Activity", name); + } + + @Test + void testFragment() { + Fragment fragment = new Fragment(); + String name = ScreenNameExtractor.DEFAULT.extract(fragment); + assertEquals("Fragment", name); + } + + @Test + void testAnnotatedActivity() { + Activity activity = new AnnotatedActivity(); + String name = ScreenNameExtractor.DEFAULT.extract(activity); + assertEquals("squarely", name); + } + + @Test + void testAnnotatedFragment() { + Fragment fragment = new AnnotatedFragment(); + String name = ScreenNameExtractor.DEFAULT.extract(fragment); + assertEquals("bumpity", name); + } + + @RumScreenName("bumpity") + static class AnnotatedFragment extends Fragment {} + + @RumScreenName("squarely") + static class AnnotatedActivity extends Activity {} +} diff --git a/opentelemetry-android-instrumentation/src/test/java/io/opentelemetry/rum/internal/instrumentation/network/CarrierFinderTest.java b/opentelemetry-android-instrumentation/src/test/java/io/opentelemetry/rum/internal/instrumentation/network/CarrierFinderTest.java new file mode 100644 index 000000000..805b98a06 --- /dev/null +++ b/opentelemetry-android-instrumentation/src/test/java/io/opentelemetry/rum/internal/instrumentation/network/CarrierFinderTest.java @@ -0,0 +1,65 @@ +/* + * Copyright Splunk Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.opentelemetry.rum.internal.instrumentation.network; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import android.telephony.TelephonyManager; +import org.junit.jupiter.api.Test; + +class CarrierFinderTest { + + @Test + void testSimpleGet() { + Carrier expected = + Carrier.builder() + .id(206) + .name("ShadyTel") + .isoCountryCode("US") + .mobileCountryCode("usa") + .mobileNetworkCode("omg") + .build(); + + TelephonyManager manager = mock(TelephonyManager.class); + when(manager.getSimCarrierId()).thenReturn(expected.getId()); + when(manager.getSimCarrierIdName()).thenReturn(expected.getName()); + when(manager.getSimCountryIso()).thenReturn(expected.getIsoCountryCode()); + when(manager.getSimOperator()) + .thenReturn(expected.getMobileCountryCode() + expected.getMobileNetworkCode()); + + CarrierFinder finder = new CarrierFinder(manager); + Carrier carrier = finder.get(); + assertThat(carrier).isEqualTo(expected); + } + + @Test + void testMostlyInvalid() { + Carrier expected = Carrier.builder().build(); + + TelephonyManager manager = mock(TelephonyManager.class); + when(manager.getSimCarrierId()).thenReturn(-1); + when(manager.getSimCarrierIdName()).thenReturn(null); + when(manager.getSimCountryIso()).thenReturn(null); + when(manager.getSimOperator()).thenReturn(null); + + CarrierFinder finder = new CarrierFinder(manager); + Carrier carrier = finder.get(); + assertThat(carrier).isEqualTo(expected); + } +} diff --git a/opentelemetry-android-instrumentation/src/test/java/io/opentelemetry/rum/internal/instrumentation/network/CurrentNetworkAttributesExtractorTest.java b/opentelemetry-android-instrumentation/src/test/java/io/opentelemetry/rum/internal/instrumentation/network/CurrentNetworkAttributesExtractorTest.java new file mode 100644 index 000000000..9df8d8819 --- /dev/null +++ b/opentelemetry-android-instrumentation/src/test/java/io/opentelemetry/rum/internal/instrumentation/network/CurrentNetworkAttributesExtractorTest.java @@ -0,0 +1,74 @@ +/* + * Copyright Splunk Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.opentelemetry.rum.internal.instrumentation.network; + +import static io.opentelemetry.sdk.testing.assertj.OpenTelemetryAssertions.assertThat; +import static org.assertj.core.api.Assertions.entry; + +import android.os.Build; +import io.opentelemetry.semconv.trace.attributes.SemanticAttributes; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +@RunWith(RobolectricTestRunner.class) +public class CurrentNetworkAttributesExtractorTest { + + final CurrentNetworkAttributesExtractor underTest = new CurrentNetworkAttributesExtractor(); + + @Config(sdk = Build.VERSION_CODES.P) + @Test + public void getNetworkAttributes_withCarrier() { + CurrentNetwork currentNetwork = + CurrentNetwork.builder(NetworkState.TRANSPORT_CELLULAR) + .subType("aaa") + .carrier( + Carrier.builder() + .id(206) + .name("ShadyTel") + .isoCountryCode("US") + .mobileCountryCode("usa") + .mobileNetworkCode("omg") + .build()) + .build(); + + assertThat(underTest.extract(currentNetwork)) + .containsOnly( + entry(SemanticAttributes.NET_HOST_CONNECTION_TYPE, "cell"), + entry(SemanticAttributes.NET_HOST_CONNECTION_SUBTYPE, "aaa"), + entry(SemanticAttributes.NET_HOST_CARRIER_NAME, "ShadyTel"), + entry(SemanticAttributes.NET_HOST_CARRIER_ICC, "US"), + entry(SemanticAttributes.NET_HOST_CARRIER_MCC, "usa"), + entry(SemanticAttributes.NET_HOST_CARRIER_MNC, "omg")); + } + + @Config(sdk = Build.VERSION_CODES.O) + @Test + public void getNetworkAttributes_withoutCarrier() { + CurrentNetwork currentNetwork = + CurrentNetwork.builder(NetworkState.TRANSPORT_CELLULAR) + .subType("aaa") + .carrier(Carrier.builder().id(42).name("ShadyTel").build()) + .build(); + + assertThat(underTest.extract(currentNetwork)) + .containsOnly( + entry(SemanticAttributes.NET_HOST_CONNECTION_TYPE, "cell"), + entry(SemanticAttributes.NET_HOST_CONNECTION_SUBTYPE, "aaa")); + } +} diff --git a/opentelemetry-android-instrumentation/src/test/java/io/opentelemetry/rum/internal/instrumentation/network/CurrentNetworkProviderTest.java b/opentelemetry-android-instrumentation/src/test/java/io/opentelemetry/rum/internal/instrumentation/network/CurrentNetworkProviderTest.java new file mode 100644 index 000000000..435b0c1ec --- /dev/null +++ b/opentelemetry-android-instrumentation/src/test/java/io/opentelemetry/rum/internal/instrumentation/network/CurrentNetworkProviderTest.java @@ -0,0 +1,239 @@ +/* + * Copyright Splunk Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.opentelemetry.rum.internal.instrumentation.network; + +import static org.junit.Assert.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isA; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import android.net.ConnectivityManager; +import android.net.ConnectivityManager.NetworkCallback; +import android.net.Network; +import android.net.NetworkRequest; +import android.os.Build; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +@RunWith(RobolectricTestRunner.class) +@Config(maxSdk = Build.VERSION_CODES.S) +public class CurrentNetworkProviderTest { + + @Test + @Config(maxSdk = Build.VERSION_CODES.LOLLIPOP) + public void lollipop() { + NetworkRequest networkRequest = mock(NetworkRequest.class); + NetworkDetector networkDetector = mock(NetworkDetector.class); + ConnectivityManager connectivityManager = mock(ConnectivityManager.class); + + when(networkDetector.detectCurrentNetwork()) + .thenReturn( + CurrentNetwork.builder(NetworkState.TRANSPORT_WIFI) + .build()) // called on init + .thenReturn( + CurrentNetwork.builder(NetworkState.TRANSPORT_CELLULAR) + .subType("LTE") + .build()); + + CurrentNetworkProvider currentNetworkProvider = new CurrentNetworkProvider(networkDetector); + currentNetworkProvider.startMonitoring(() -> networkRequest, connectivityManager); + + assertEquals( + CurrentNetwork.builder(NetworkState.TRANSPORT_WIFI).build(), + currentNetworkProvider.getCurrentNetwork()); + + ArgumentCaptor monitorCaptor = + ArgumentCaptor.forClass(NetworkCallback.class); + verify(connectivityManager) + .registerNetworkCallback(eq(networkRequest), monitorCaptor.capture()); + + AtomicInteger notified = new AtomicInteger(0); + currentNetworkProvider.addNetworkChangeListener( + (currentNetwork) -> { + int timesCalled = notified.incrementAndGet(); + if (timesCalled == 1) { + assertEquals( + CurrentNetwork.builder(NetworkState.TRANSPORT_CELLULAR) + .subType("LTE") + .build(), + currentNetwork); + } else { + assertEquals( + CurrentNetwork.builder(NetworkState.NO_NETWORK_AVAILABLE).build(), + currentNetwork); + } + }); + // note: we ignore the network passed in and just rely on refreshing the network info when + // this is happens + monitorCaptor.getValue().onAvailable(null); + assertEquals(1, notified.get()); + monitorCaptor.getValue().onLost(null); + assertEquals(2, notified.get()); + } + + @Test + @Config(maxSdk = Build.VERSION_CODES.S, minSdk = Build.VERSION_CODES.O) + public void modernSdks() { + NetworkRequest networkRequest = mock(NetworkRequest.class); + NetworkDetector networkDetector = mock(NetworkDetector.class); + ConnectivityManager connectivityManager = mock(ConnectivityManager.class); + + when(networkDetector.detectCurrentNetwork()) + .thenReturn(CurrentNetwork.builder(NetworkState.TRANSPORT_WIFI).build()) + .thenReturn( + CurrentNetwork.builder(NetworkState.TRANSPORT_CELLULAR) + .subType("LTE") + .build()); + + CurrentNetworkProvider currentNetworkProvider = new CurrentNetworkProvider(networkDetector); + currentNetworkProvider.startMonitoring(() -> networkRequest, connectivityManager); + + assertEquals( + CurrentNetwork.builder(NetworkState.TRANSPORT_WIFI).build(), + currentNetworkProvider.getCurrentNetwork()); + verify(connectivityManager, never()) + .registerNetworkCallback(eq(networkRequest), isA(NetworkCallback.class)); + + ArgumentCaptor monitorCaptor = + ArgumentCaptor.forClass(NetworkCallback.class); + verify(connectivityManager).registerDefaultNetworkCallback(monitorCaptor.capture()); + + AtomicInteger notified = new AtomicInteger(0); + currentNetworkProvider.addNetworkChangeListener( + (currentNetwork) -> { + int timesCalled = notified.incrementAndGet(); + if (timesCalled == 1) { + assertEquals( + CurrentNetwork.builder(NetworkState.TRANSPORT_CELLULAR) + .subType("LTE") + .build(), + currentNetwork); + } else { + assertEquals( + CurrentNetwork.builder(NetworkState.NO_NETWORK_AVAILABLE).build(), + currentNetwork); + } + }); + // note: we ignore the network passed in and just rely on refreshing the network info when + // this is happens + monitorCaptor.getValue().onAvailable(null); + assertEquals(1, notified.get()); + monitorCaptor.getValue().onLost(null); + assertEquals(2, notified.get()); + } + + @Test + public void networkDetectorException() { + NetworkDetector networkDetector = mock(NetworkDetector.class); + when(networkDetector.detectCurrentNetwork()).thenThrow(new SecurityException("bug")); + + CurrentNetworkProvider currentNetworkProvider = new CurrentNetworkProvider(networkDetector); + assertEquals( + CurrentNetworkProvider.UNKNOWN_NETWORK, + currentNetworkProvider.refreshNetworkStatus()); + } + + @Test + @Config(maxSdk = Build.VERSION_CODES.S, minSdk = Build.VERSION_CODES.O) + public void networkDetectorExceptionOnCallbackRegistration() { + NetworkDetector networkDetector = mock(NetworkDetector.class); + ConnectivityManager connectivityManager = mock(ConnectivityManager.class); + + when(networkDetector.detectCurrentNetwork()) + .thenReturn(CurrentNetwork.builder(NetworkState.TRANSPORT_WIFI).build()); + doThrow(new SecurityException("bug")) + .when(connectivityManager) + .registerDefaultNetworkCallback(isA(NetworkCallback.class)); + + CurrentNetworkProvider currentNetworkProvider = new CurrentNetworkProvider(networkDetector); + currentNetworkProvider.startMonitoring( + () -> mock(NetworkRequest.class), connectivityManager); + assertEquals( + CurrentNetwork.builder(NetworkState.TRANSPORT_WIFI).build(), + currentNetworkProvider.refreshNetworkStatus()); + } + + @Test + @Config(maxSdk = Build.VERSION_CODES.LOLLIPOP) + public void networkDetectorExceptionOnCallbackRegistration_lollipop() { + NetworkDetector networkDetector = mock(NetworkDetector.class); + ConnectivityManager connectivityManager = mock(ConnectivityManager.class); + NetworkRequest networkRequest = mock(NetworkRequest.class); + + when(networkDetector.detectCurrentNetwork()) + .thenReturn(CurrentNetwork.builder(NetworkState.TRANSPORT_WIFI).build()); + doThrow(new SecurityException("bug")) + .when(connectivityManager) + .registerNetworkCallback(eq(networkRequest), isA(NetworkCallback.class)); + + CurrentNetworkProvider currentNetworkProvider = new CurrentNetworkProvider(networkDetector); + currentNetworkProvider.startMonitoring(() -> networkRequest, connectivityManager); + assertEquals( + CurrentNetwork.builder(NetworkState.TRANSPORT_WIFI).build(), + currentNetworkProvider.refreshNetworkStatus()); + } + + @Test + @Config(maxSdk = Build.VERSION_CODES.LOLLIPOP) + public void shouldNotFailOnImmediateConnectionManagerCall_lollipop() { + NetworkRequest networkRequest = mock(NetworkRequest.class); + NetworkDetector networkDetector = mock(NetworkDetector.class); + ConnectivityManager connectivityManager = mock(ConnectivityManager.class); + + doAnswer( + invocation -> { + NetworkCallback callback = invocation.getArgument(1); + callback.onAvailable(mock(Network.class)); + return null; + }) + .when(connectivityManager) + .registerNetworkCallback(eq(networkRequest), any(NetworkCallback.class)); + + CurrentNetworkProvider currentNetworkProvider = new CurrentNetworkProvider(networkDetector); + currentNetworkProvider.startMonitoring(() -> networkRequest, connectivityManager); + } + + @Test + @Config(maxSdk = Build.VERSION_CODES.S, minSdk = Build.VERSION_CODES.O) + public void shouldNotFailOnImmediateConnectionManagerCall() { + NetworkRequest networkRequest = mock(NetworkRequest.class); + NetworkDetector networkDetector = mock(NetworkDetector.class); + ConnectivityManager connectivityManager = mock(ConnectivityManager.class); + + doAnswer( + invocation -> { + NetworkCallback callback = invocation.getArgument(0); + callback.onAvailable(mock(Network.class)); + return null; + }) + .when(connectivityManager) + .registerDefaultNetworkCallback(any(NetworkCallback.class)); + + CurrentNetworkProvider currentNetworkProvider = new CurrentNetworkProvider(networkDetector); + currentNetworkProvider.startMonitoring(() -> networkRequest, connectivityManager); + } +} diff --git a/opentelemetry-android-instrumentation/src/test/java/io/opentelemetry/rum/internal/instrumentation/network/NetworkAttributesSpanAppenderTest.java b/opentelemetry-android-instrumentation/src/test/java/io/opentelemetry/rum/internal/instrumentation/network/NetworkAttributesSpanAppenderTest.java new file mode 100644 index 000000000..4cb37f121 --- /dev/null +++ b/opentelemetry-android-instrumentation/src/test/java/io/opentelemetry/rum/internal/instrumentation/network/NetworkAttributesSpanAppenderTest.java @@ -0,0 +1,61 @@ +/* + * Copyright Splunk Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.opentelemetry.rum.internal.instrumentation.network; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.context.Context; +import io.opentelemetry.sdk.trace.ReadWriteSpan; +import io.opentelemetry.semconv.trace.attributes.SemanticAttributes; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class NetworkAttributesSpanAppenderTest { + + @Mock CurrentNetworkProvider currentNetworkProvider; + @Mock ReadWriteSpan span; + + @InjectMocks NetworkAttributesSpanAppender underTest; + + @Test + void shouldAppendNetworkAttributes() { + when(currentNetworkProvider.getCurrentNetwork()) + .thenReturn( + CurrentNetwork.builder(NetworkState.TRANSPORT_CELLULAR) + .subType("LTE") + .build()); + + assertTrue(underTest.isStartRequired()); + underTest.onStart(Context.current(), span); + + verify(span) + .setAllAttributes( + Attributes.of( + SemanticAttributes.NET_HOST_CONNECTION_TYPE, "cell", + SemanticAttributes.NET_HOST_CONNECTION_SUBTYPE, "LTE")); + + assertFalse(underTest.isEndRequired()); + } +} diff --git a/opentelemetry-android-instrumentation/src/test/java/io/opentelemetry/rum/internal/instrumentation/network/NetworkChangeMonitorTest.java b/opentelemetry-android-instrumentation/src/test/java/io/opentelemetry/rum/internal/instrumentation/network/NetworkChangeMonitorTest.java new file mode 100644 index 000000000..037176342 --- /dev/null +++ b/opentelemetry-android-instrumentation/src/test/java/io/opentelemetry/rum/internal/instrumentation/network/NetworkChangeMonitorTest.java @@ -0,0 +1,174 @@ +/* + * Copyright Splunk Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.opentelemetry.rum.internal.instrumentation.network; + +import static io.opentelemetry.sdk.testing.assertj.OpenTelemetryAssertions.assertThat; +import static io.opentelemetry.sdk.testing.assertj.OpenTelemetryAssertions.equalTo; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import android.os.Build; +import io.opentelemetry.rum.internal.instrumentation.ApplicationStateListener; +import io.opentelemetry.rum.internal.instrumentation.InstrumentedApplication; +import io.opentelemetry.sdk.OpenTelemetrySdk; +import io.opentelemetry.sdk.testing.junit4.OpenTelemetryRule; +import io.opentelemetry.sdk.trace.data.SpanData; +import io.opentelemetry.semconv.trace.attributes.SemanticAttributes; +import java.util.List; +import org.junit.After; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +@Config(sdk = Build.VERSION_CODES.P) +@RunWith(RobolectricTestRunner.class) +public class NetworkChangeMonitorTest { + + @Rule public OpenTelemetryRule otelTesting = OpenTelemetryRule.create(); + + @Captor ArgumentCaptor applicationStateListenerCaptor; + @Captor ArgumentCaptor networkChangeListenerCaptor; + + @Mock CurrentNetworkProvider currentNetworkProvider; + @Mock InstrumentedApplication instrumentedApplication; + + AutoCloseable mocks; + + @Before + public void setUp() { + mocks = MockitoAnnotations.openMocks(this); + when(instrumentedApplication.getOpenTelemetrySdk()) + .thenReturn((OpenTelemetrySdk) otelTesting.getOpenTelemetry()); + } + + @After + public void tearDown() throws Exception { + mocks.close(); + } + + @Test + public void networkAvailable_wifi() { + NetworkChangeMonitor.create(currentNetworkProvider).installOn(instrumentedApplication); + + verify(currentNetworkProvider) + .addNetworkChangeListener(networkChangeListenerCaptor.capture()); + NetworkChangeListener listener = networkChangeListenerCaptor.getValue(); + + listener.onNetworkChange(CurrentNetwork.builder(NetworkState.TRANSPORT_WIFI).build()); + + List spans = otelTesting.getSpans(); + assertEquals(1, spans.size()); + assertThat(spans.get(0)) + .hasName("network.change") + .hasAttributesSatisfyingExactly( + equalTo(NetworkApplicationListener.NETWORK_STATUS_KEY, "available"), + equalTo(SemanticAttributes.NET_HOST_CONNECTION_TYPE, "wifi")); + } + + @Test + public void networkAvailable_cellular() { + NetworkChangeMonitor.create(currentNetworkProvider).installOn(instrumentedApplication); + + verify(currentNetworkProvider) + .addNetworkChangeListener(networkChangeListenerCaptor.capture()); + NetworkChangeListener listener = networkChangeListenerCaptor.getValue(); + + CurrentNetwork network = + CurrentNetwork.builder(NetworkState.TRANSPORT_CELLULAR) + .subType("LTE") + .carrier( + Carrier.builder() + .id(206) + .name("ShadyTel") + .isoCountryCode("US") + .mobileCountryCode("usa") + .mobileNetworkCode("omg") + .build()) + .build(); + + listener.onNetworkChange(network); + + List spans = otelTesting.getSpans(); + assertEquals(1, spans.size()); + assertThat(spans.get(0)) + .hasName("network.change") + .hasAttributesSatisfyingExactly( + equalTo(NetworkApplicationListener.NETWORK_STATUS_KEY, "available"), + equalTo(SemanticAttributes.NET_HOST_CONNECTION_TYPE, "cell"), + equalTo(SemanticAttributes.NET_HOST_CONNECTION_SUBTYPE, "LTE"), + equalTo(SemanticAttributes.NET_HOST_CARRIER_NAME, "ShadyTel"), + equalTo(SemanticAttributes.NET_HOST_CARRIER_ICC, "US"), + equalTo(SemanticAttributes.NET_HOST_CARRIER_MCC, "usa"), + equalTo(SemanticAttributes.NET_HOST_CARRIER_MNC, "omg")); + } + + @Test + public void networkLost() { + NetworkChangeMonitor.create(currentNetworkProvider).installOn(instrumentedApplication); + + verify(currentNetworkProvider) + .addNetworkChangeListener(networkChangeListenerCaptor.capture()); + NetworkChangeListener listener = networkChangeListenerCaptor.getValue(); + + listener.onNetworkChange(CurrentNetwork.builder(NetworkState.NO_NETWORK_AVAILABLE).build()); + + List spans = otelTesting.getSpans(); + assertEquals(1, spans.size()); + assertThat(spans.get(0)) + .hasName("network.change") + .hasAttributesSatisfyingExactly( + equalTo(NetworkApplicationListener.NETWORK_STATUS_KEY, "lost"), + equalTo(SemanticAttributes.NET_HOST_CONNECTION_TYPE, "unavailable")); + } + + @Test + public void noEventsPlease() { + NetworkChangeMonitor.create(currentNetworkProvider).installOn(instrumentedApplication); + + verify(currentNetworkProvider) + .addNetworkChangeListener(networkChangeListenerCaptor.capture()); + NetworkChangeListener networkListener = networkChangeListenerCaptor.getValue(); + + verify(instrumentedApplication) + .registerApplicationStateListener(applicationStateListenerCaptor.capture()); + ApplicationStateListener applicationListener = applicationStateListenerCaptor.getValue(); + + applicationListener.onApplicationBackgrounded(); + + networkListener.onNetworkChange( + CurrentNetwork.builder(NetworkState.NO_NETWORK_AVAILABLE).build()); + assertTrue(otelTesting.getSpans().isEmpty()); + networkListener.onNetworkChange( + CurrentNetwork.builder(NetworkState.TRANSPORT_CELLULAR).subType("LTE").build()); + assertTrue(otelTesting.getSpans().isEmpty()); + + applicationListener.onApplicationForegrounded(); + + networkListener.onNetworkChange( + CurrentNetwork.builder(NetworkState.NO_NETWORK_AVAILABLE).build()); + assertEquals(1, otelTesting.getSpans().size()); + } +} diff --git a/opentelemetry-android-instrumentation/src/test/java/io/opentelemetry/rum/internal/instrumentation/network/NetworkDetectorTest.java b/opentelemetry-android-instrumentation/src/test/java/io/opentelemetry/rum/internal/instrumentation/network/NetworkDetectorTest.java new file mode 100644 index 000000000..28cb8b5f6 --- /dev/null +++ b/opentelemetry-android-instrumentation/src/test/java/io/opentelemetry/rum/internal/instrumentation/network/NetworkDetectorTest.java @@ -0,0 +1,49 @@ +/* + * Copyright Splunk Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.opentelemetry.rum.internal.instrumentation.network; + +import static org.junit.Assert.assertTrue; + +import android.content.Context; +import android.os.Build; +import androidx.test.core.app.ApplicationProvider; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +@RunWith(RobolectricTestRunner.class) +public class NetworkDetectorTest { + + @Test + @Config(sdk = Build.VERSION_CODES.Q) + public void quiznos() { + Context context = ApplicationProvider.getApplicationContext(); + + NetworkDetector networkDetector = NetworkDetector.create(context); + assertTrue(networkDetector instanceof PostApi28NetworkDetector); + } + + @Test + @Config(sdk = Build.VERSION_CODES.LOLLIPOP) + public void lollipop() { + Context context = ApplicationProvider.getApplicationContext(); + + NetworkDetector networkDetector = NetworkDetector.create(context); + assertTrue(networkDetector instanceof SimpleNetworkDetector); + } +} diff --git a/opentelemetry-android-instrumentation/src/test/java/io/opentelemetry/rum/internal/instrumentation/network/PostApi28NetworkDetectorTest.java b/opentelemetry-android-instrumentation/src/test/java/io/opentelemetry/rum/internal/instrumentation/network/PostApi28NetworkDetectorTest.java new file mode 100644 index 000000000..c925f2fc7 --- /dev/null +++ b/opentelemetry-android-instrumentation/src/test/java/io/opentelemetry/rum/internal/instrumentation/network/PostApi28NetworkDetectorTest.java @@ -0,0 +1,151 @@ +/* + * Copyright Splunk Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.opentelemetry.rum.internal.instrumentation.network; + +import static io.opentelemetry.sdk.testing.assertj.OpenTelemetryAssertions.assertThat; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.Assert.assertEquals; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import android.content.Context; +import android.net.ConnectivityManager; +import android.net.Network; +import android.net.NetworkCapabilities; +import android.os.Build; +import android.telephony.TelephonyManager; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +@RunWith(RobolectricTestRunner.class) +@Config(sdk = Build.VERSION_CODES.P) +public class PostApi28NetworkDetectorTest { + + ConnectivityManager connectivityManager; + TelephonyManager telephonyManager; + Context context; + Network network; + NetworkCapabilities networkCapabilities; + CarrierFinder carrierFinder; + + @Before + public void setup() { + connectivityManager = mock(ConnectivityManager.class); + telephonyManager = mock(TelephonyManager.class); + context = mock(Context.class); + network = mock(Network.class); + carrierFinder = mock(CarrierFinder.class); + networkCapabilities = mock(NetworkCapabilities.class); + + when(connectivityManager.getActiveNetwork()).thenReturn(network); + when(connectivityManager.getNetworkCapabilities(network)).thenReturn(networkCapabilities); + } + + @Test + public void none() { + when(connectivityManager.getNetworkCapabilities(network)).thenReturn(null); + + PostApi28NetworkDetector networkDetector = + new PostApi28NetworkDetector( + connectivityManager, telephonyManager, carrierFinder, context); + CurrentNetwork currentNetwork = networkDetector.detectCurrentNetwork(); + assertEquals( + CurrentNetwork.builder(NetworkState.NO_NETWORK_AVAILABLE).build(), currentNetwork); + } + + @Test + public void wifi() { + when(networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)).thenReturn(true); + + PostApi28NetworkDetector networkDetector = + new PostApi28NetworkDetector( + connectivityManager, telephonyManager, carrierFinder, context); + CurrentNetwork currentNetwork = networkDetector.detectCurrentNetwork(); + assertEquals(CurrentNetwork.builder(NetworkState.TRANSPORT_WIFI).build(), currentNetwork); + } + + @Test + public void cellular() { + when(telephonyManager.getDataNetworkType()).thenReturn(TelephonyManager.NETWORK_TYPE_LTE); + when(networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)) + .thenReturn(true); + + PostApi28NetworkDetector networkDetector = + new PostApi28NetworkDetector( + connectivityManager, telephonyManager, carrierFinder, context); + CurrentNetwork currentNetwork = networkDetector.detectCurrentNetwork(); + assertEquals( + CurrentNetwork.builder(NetworkState.TRANSPORT_CELLULAR).subType("LTE").build(), + currentNetwork); + } + + @Test + public void cellular_noTelephonyPermissions() { + when(telephonyManager.getDataNetworkType()).thenReturn(TelephonyManager.NETWORK_TYPE_LTE); + when(networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)) + .thenReturn(true); + + PostApi28NetworkDetector networkDetector = + new PostApi28NetworkDetector( + connectivityManager, telephonyManager, carrierFinder, context) { + @Override + boolean hasPermission(String permission) { + return false; + } + }; + CurrentNetwork currentNetwork = networkDetector.detectCurrentNetwork(); + assertEquals( + CurrentNetwork.builder(NetworkState.TRANSPORT_CELLULAR).build(), currentNetwork); + } + + @Test + public void other() { + PostApi28NetworkDetector networkDetector = + new PostApi28NetworkDetector( + connectivityManager, telephonyManager, carrierFinder, context); + CurrentNetwork currentNetwork = networkDetector.detectCurrentNetwork(); + assertEquals( + CurrentNetwork.builder(NetworkState.TRANSPORT_UNKNOWN).build(), currentNetwork); + } + + @Test + public void vpn() { + when(networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN)).thenReturn(true); + + PostApi28NetworkDetector networkDetector = + new PostApi28NetworkDetector( + connectivityManager, telephonyManager, carrierFinder, context); + CurrentNetwork currentNetwork = networkDetector.detectCurrentNetwork(); + assertEquals(CurrentNetwork.builder(NetworkState.TRANSPORT_VPN).build(), currentNetwork); + } + + @Test + public void carrierIsSet() { + Carrier carrier = Carrier.builder().name("flib").build(); + when(networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)) + .thenReturn(true); + when(carrierFinder.get()).thenReturn(carrier); + PostApi28NetworkDetector networkDetector = + new PostApi28NetworkDetector( + connectivityManager, telephonyManager, carrierFinder, context); + CurrentNetwork currentNetwork = networkDetector.detectCurrentNetwork(); + assertThat(currentNetwork.getCarrierName()).isEqualTo("flib"); + } +} diff --git a/opentelemetry-android-instrumentation/src/test/java/io/opentelemetry/rum/internal/instrumentation/network/SimpleNetworkDetectorTest.java b/opentelemetry-android-instrumentation/src/test/java/io/opentelemetry/rum/internal/instrumentation/network/SimpleNetworkDetectorTest.java new file mode 100644 index 000000000..04a9c5419 --- /dev/null +++ b/opentelemetry-android-instrumentation/src/test/java/io/opentelemetry/rum/internal/instrumentation/network/SimpleNetworkDetectorTest.java @@ -0,0 +1,146 @@ +/* + * Copyright Splunk Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.opentelemetry.rum.internal.instrumentation.network; + +import static org.junit.Assert.assertEquals; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import android.content.Context; +import android.net.ConnectivityManager; +import android.net.NetworkInfo; +import android.os.Build; +import androidx.test.core.app.ApplicationProvider; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.Shadows; +import org.robolectric.annotation.Config; +import org.robolectric.shadows.ShadowNetworkInfo; + +@RunWith(RobolectricTestRunner.class) +@Config(sdk = Build.VERSION_CODES.P) +public class SimpleNetworkDetectorTest { + @Test + public void none() { + ConnectivityManager connectivityManager = + (ConnectivityManager) + ApplicationProvider.getApplicationContext() + .getSystemService(Context.CONNECTIVITY_SERVICE); + + Shadows.shadowOf(connectivityManager).setActiveNetworkInfo(null); + SimpleNetworkDetector networkDetector = new SimpleNetworkDetector(connectivityManager); + + CurrentNetwork currentNetwork = networkDetector.detectCurrentNetwork(); + + assertEquals( + CurrentNetwork.builder(NetworkState.NO_NETWORK_AVAILABLE).build(), currentNetwork); + } + + @Test + public void other() { + ConnectivityManager connectivityManager = + (ConnectivityManager) + ApplicationProvider.getApplicationContext() + .getSystemService(Context.CONNECTIVITY_SERVICE); + + NetworkInfo networkInfo = + ShadowNetworkInfo.newInstance( + NetworkInfo.DetailedState.CONNECTED, + ConnectivityManager.TYPE_DUMMY, + 0, + true, + NetworkInfo.State.CONNECTED); + Shadows.shadowOf(connectivityManager).setActiveNetworkInfo(networkInfo); + + SimpleNetworkDetector networkDetector = new SimpleNetworkDetector(connectivityManager); + + CurrentNetwork currentNetwork = networkDetector.detectCurrentNetwork(); + + assertEquals( + CurrentNetwork.builder(NetworkState.TRANSPORT_UNKNOWN).build(), currentNetwork); + } + + @Test + public void wifi() { + ConnectivityManager connectivityManager = + (ConnectivityManager) + ApplicationProvider.getApplicationContext() + .getSystemService(Context.CONNECTIVITY_SERVICE); + + NetworkInfo networkInfo = + ShadowNetworkInfo.newInstance( + NetworkInfo.DetailedState.CONNECTED, + ConnectivityManager.TYPE_WIFI, + 0, + true, + NetworkInfo.State.CONNECTED); + Shadows.shadowOf(connectivityManager).setActiveNetworkInfo(networkInfo); + + SimpleNetworkDetector networkDetector = new SimpleNetworkDetector(connectivityManager); + + CurrentNetwork currentNetwork = networkDetector.detectCurrentNetwork(); + + assertEquals(CurrentNetwork.builder(NetworkState.TRANSPORT_WIFI).build(), currentNetwork); + } + + @Test + public void vpn() { + ConnectivityManager connectivityManager = + (ConnectivityManager) + ApplicationProvider.getApplicationContext() + .getSystemService(Context.CONNECTIVITY_SERVICE); + + NetworkInfo networkInfo = + ShadowNetworkInfo.newInstance( + NetworkInfo.DetailedState.CONNECTED, + ConnectivityManager.TYPE_VPN, + 0, + true, + NetworkInfo.State.CONNECTED); + Shadows.shadowOf(connectivityManager).setActiveNetworkInfo(networkInfo); + + SimpleNetworkDetector networkDetector = new SimpleNetworkDetector(connectivityManager); + + CurrentNetwork currentNetwork = networkDetector.detectCurrentNetwork(); + + assertEquals(CurrentNetwork.builder(NetworkState.TRANSPORT_VPN).build(), currentNetwork); + } + + @Test + public void cellularWithSubtype() { + ConnectivityManager connectivityManager = + (ConnectivityManager) + ApplicationProvider.getApplicationContext() + .getSystemService(Context.CONNECTIVITY_SERVICE); + + // note: looks like the ShadowNetworkInfo doesn't support setting subtype name. + NetworkInfo networkInfo = mock(NetworkInfo.class); + when(networkInfo.getType()).thenReturn(ConnectivityManager.TYPE_MOBILE); + when(networkInfo.getSubtypeName()).thenReturn("LTE"); + + Shadows.shadowOf(connectivityManager).setActiveNetworkInfo(networkInfo); + + SimpleNetworkDetector networkDetector = new SimpleNetworkDetector(connectivityManager); + + CurrentNetwork currentNetwork = networkDetector.detectCurrentNetwork(); + + assertEquals( + CurrentNetwork.builder(NetworkState.TRANSPORT_CELLULAR).subType("LTE").build(), + currentNetwork); + } +} diff --git a/opentelemetry-android-instrumentation/src/test/java/io/opentelemetry/rum/internal/instrumentation/slowrendering/SlowRenderListenerTest.java b/opentelemetry-android-instrumentation/src/test/java/io/opentelemetry/rum/internal/instrumentation/slowrendering/SlowRenderListenerTest.java new file mode 100644 index 000000000..f229f6fa9 --- /dev/null +++ b/opentelemetry-android-instrumentation/src/test/java/io/opentelemetry/rum/internal/instrumentation/slowrendering/SlowRenderListenerTest.java @@ -0,0 +1,223 @@ +/* + * Copyright Splunk Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.opentelemetry.rum.internal.instrumentation.slowrendering; + +import static android.view.FrameMetrics.DRAW_DURATION; +import static android.view.FrameMetrics.FIRST_DRAW_FRAME; +import static io.opentelemetry.sdk.testing.assertj.OpenTelemetryAssertions.assertThat; +import static org.junit.Assert.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import android.app.Activity; +import android.app.Application; +import android.content.ComponentName; +import android.os.Build; +import android.os.Handler; +import android.view.FrameMetrics; +import io.opentelemetry.api.common.AttributeKey; +import io.opentelemetry.api.trace.Tracer; +import io.opentelemetry.sdk.testing.junit4.OpenTelemetryRule; +import io.opentelemetry.sdk.trace.data.SpanData; +import java.time.Duration; +import java.util.List; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Answers; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnit; +import org.mockito.junit.MockitoRule; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +@RunWith(RobolectricTestRunner.class) +@Config(sdk = Build.VERSION_CODES.N) +public class SlowRenderListenerTest { + + private static final AttributeKey COUNT_KEY = AttributeKey.longKey("count"); + + @Rule public OpenTelemetryRule otelTesting = OpenTelemetryRule.create(); + @Rule public MockitoRule mocks = MockitoJUnit.rule(); + + @Mock Handler frameMetricsHandler; + @Mock Application application; + + @Mock(answer = Answers.RETURNS_DEEP_STUBS) + Activity activity; + + @Mock FrameMetrics frameMetrics; + Tracer tracer; + + @Captor ArgumentCaptor activityListenerCaptor; + + @Before + public void setup() { + tracer = otelTesting.getOpenTelemetry().getTracer("testTracer"); + ComponentName componentName = new ComponentName("io.otel", "Komponent"); + when(activity.getComponentName()).thenReturn(componentName); + } + + @Test + public void add() { + SlowRenderListener testInstance = + new SlowRenderListener(tracer, null, frameMetricsHandler, Duration.ZERO); + + testInstance.onActivityResumed(activity); + + verify(activity.getWindow()) + .addOnFrameMetricsAvailableListener( + activityListenerCaptor.capture(), eq(frameMetricsHandler)); + assertEquals("io.otel/Komponent", activityListenerCaptor.getValue().getActivityName()); + } + + @Test + public void removeBeforeAddOk() { + SlowRenderListener testInstance = + new SlowRenderListener(tracer, null, frameMetricsHandler, Duration.ZERO); + + testInstance.onActivityPaused(activity); + + verifyNoInteractions(activity); + assertThat(otelTesting.getSpans()).hasSize(0); + } + + @Test + public void addAndRemove() { + SlowRenderListener testInstance = + new SlowRenderListener(tracer, null, frameMetricsHandler, Duration.ZERO); + + testInstance.onActivityResumed(activity); + testInstance.onActivityPaused(activity); + + verify(activity.getWindow()) + .addOnFrameMetricsAvailableListener( + activityListenerCaptor.capture(), eq(frameMetricsHandler)); + verify(activity.getWindow()) + .removeOnFrameMetricsAvailableListener(activityListenerCaptor.getValue()); + + assertThat(otelTesting.getSpans()).hasSize(0); + } + + @Test + public void removeWithMetrics() { + SlowRenderListener testInstance = + new SlowRenderListener(tracer, null, frameMetricsHandler, Duration.ZERO); + + testInstance.onActivityResumed(activity); + + verify(activity.getWindow()) + .addOnFrameMetricsAvailableListener(activityListenerCaptor.capture(), any()); + SlowRenderListener.PerActivityListener listener = activityListenerCaptor.getValue(); + for (long duration : makeSomeDurations()) { + when(frameMetrics.getMetric(DRAW_DURATION)).thenReturn(duration); + listener.onFrameMetricsAvailable(null, frameMetrics, 0); + } + + testInstance.onActivityPaused(activity); + + List spans = otelTesting.getSpans(); + assertSpanContent(spans); + } + + @Test + public void start() { + ScheduledExecutorService exec = mock(ScheduledExecutorService.class); + + doAnswer( + invocation -> { + Runnable runnable = invocation.getArgument(0); + runnable.run(); // just call it immediately + return null; + }) + .when(exec) + .scheduleAtFixedRate(any(), eq(1001L), eq(1001L), eq(TimeUnit.MILLISECONDS)); + + SlowRenderListener testInstance = + new SlowRenderListener(tracer, exec, frameMetricsHandler, Duration.ofMillis(1001)); + + testInstance.onActivityResumed(activity); + + verify(activity.getWindow()) + .addOnFrameMetricsAvailableListener(activityListenerCaptor.capture(), any()); + SlowRenderListener.PerActivityListener listener = activityListenerCaptor.getValue(); + for (long duration : makeSomeDurations()) { + when(frameMetrics.getMetric(DRAW_DURATION)).thenReturn(duration); + listener.onFrameMetricsAvailable(null, frameMetrics, 0); + } + + testInstance.start(); + + List spans = otelTesting.getSpans(); + assertSpanContent(spans); + } + + @Test + public void activityListenerSkipsFirstFrame() { + SlowRenderListener.PerActivityListener listener = + new SlowRenderListener.PerActivityListener(null); + when(frameMetrics.getMetric(FIRST_DRAW_FRAME)).thenReturn(1L); + listener.onFrameMetricsAvailable(null, frameMetrics, 99); + verify(frameMetrics, never()).getMetric(DRAW_DURATION); + } + + private static void assertSpanContent(List spans) { + assertThat(spans) + .hasSize(2) + .satisfiesExactly( + span -> + assertThat(span) + .hasName("slowRenders") + .endsAt(span.getStartEpochNanos()) + .hasAttribute(COUNT_KEY, 3L) + .hasAttribute( + AttributeKey.stringKey("activity.name"), + "io.otel/Komponent"), + span -> + assertThat(span) + .hasName("frozenRenders") + .endsAt(span.getStartEpochNanos()) + .hasAttribute(COUNT_KEY, 1L) + .hasAttribute( + AttributeKey.stringKey("activity.name"), + "io.otel/Komponent")); + } + + private List makeSomeDurations() { + return Stream.of( + 5L, 11L, 101L, // slow + 701L, // frozen + 17L, // slow + 17L, // slow + 16L, 11L) + .map(TimeUnit.MILLISECONDS::toNanos) + .collect(Collectors.toList()); + } +} diff --git a/opentelemetry-android-instrumentation/src/test/java/io/opentelemetry/rum/internal/instrumentation/startup/AppStartupTimerTest.java b/opentelemetry-android-instrumentation/src/test/java/io/opentelemetry/rum/internal/instrumentation/startup/AppStartupTimerTest.java new file mode 100644 index 000000000..a6b8108d2 --- /dev/null +++ b/opentelemetry-android-instrumentation/src/test/java/io/opentelemetry/rum/internal/instrumentation/startup/AppStartupTimerTest.java @@ -0,0 +1,76 @@ +/* + * Copyright Splunk Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.opentelemetry.rum.internal.instrumentation.startup; + +import static io.opentelemetry.rum.internal.RumConstants.START_TYPE_KEY; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertSame; + +import io.opentelemetry.api.trace.Span; +import io.opentelemetry.api.trace.Tracer; +import io.opentelemetry.sdk.testing.junit5.OpenTelemetryExtension; +import io.opentelemetry.sdk.trace.data.SpanData; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +class AppStartupTimerTest { + @RegisterExtension final OpenTelemetryExtension otelTesting = OpenTelemetryExtension.create(); + private Tracer tracer; + + @BeforeEach + void setup() { + tracer = otelTesting.getOpenTelemetry().getTracer("testTracer"); + } + + @Test + void start_end() { + AppStartupTimer appStartupTimer = new AppStartupTimer(); + Span startSpan = appStartupTimer.start(tracer); + assertNotNull(startSpan); + appStartupTimer.end(); + + List spans = otelTesting.getSpans(); + assertEquals(1, spans.size()); + SpanData spanData = spans.get(0); + + assertEquals("AppStart", spanData.getName()); + assertEquals("cold", spanData.getAttributes().get(START_TYPE_KEY)); + } + + @Test + void multi_end() { + AppStartupTimer appStartupTimer = new AppStartupTimer(); + appStartupTimer.start(tracer); + appStartupTimer.end(); + appStartupTimer.end(); + + assertEquals(1, otelTesting.getSpans().size()); + } + + @Test + void multi_start() { + AppStartupTimer appStartupTimer = new AppStartupTimer(); + appStartupTimer.start(tracer); + assertSame(appStartupTimer.start(tracer), appStartupTimer.start(tracer)); + + appStartupTimer.end(); + assertEquals(1, otelTesting.getSpans().size()); + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 000000000..04b34f001 --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,6 @@ +rootProject.name = "Splunk Android RUM" + +include(":opentelemetry-android-instrumentation") +include(":splunk-otel-android") +include(":splunk-otel-android-volley") +include(":sample-app")