diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 0f5b3b4af9..e94d62677a 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -223,19 +223,6 @@ publish:release-timber: - git fetch --depth=1 origin master - ./gradlew :dd-sdk-android-timber:bintrayUpload --stacktrace --no-daemon - -publish:gradle-plugin: - tags: [ "runner:main", "size:large" ] - only: - - tags - image: $CI_IMAGE_DOCKER - stage: publish - timeout: 30m - script: - - aws ssm get-parameter --region us-east-1 --name ci.dd-sdk-android.gradle-properties --with-decryption --query "Parameter.Value" --out text >> ./gradle.properties - - git fetch --depth=1 origin master - - ./gradlew :dd-android-gradle-plugin:bintrayUpload --stacktrace --no-daemon - # SLACK NOTIFICATIONS notify:release: @@ -258,4 +245,14 @@ notify:failure: script: - BUILD_URL="$CI_PROJECT_URL/pipelines/$CI_PIPELINE_ID" - 'MESSAGE_TEXT=":status_alert: $CI_PROJECT_NAME $CI_COMMIT_TAG publish pipeline <$BUILD_URL|$COMMIT_MESSAGE> failed."' - - postmessage "#mobile-rum" "$MESSAGE_TEXT" \ No newline at end of file + - postmessage "#mobile-rum" "$MESSAGE_TEXT" + +notify:dogfooding: + stage: notify + when: on_success + only: + - tags + script: + - pip3 install GitPython requests + - aws ssm get-parameter --region us-east-1 --name ci.dd-sdk-android.gh_token --with-decryption --query "Parameter.Value" --out text >> ./gh_token + - python3 dogfood.py -v $CI_COMMIT_TAG \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1084816a66..4fb1e3a0a7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -64,7 +64,7 @@ The whole project is covered by a set of static analysis tools, linters and test Many great ideas for new features come from the community, and we'd be happy to consider yours! -To share your request, you can open an [issue](https://github.com/DataDog/dd-sdk-android/issues/new) +To share your request, you can open an [issue](https://github.com/DataDog/dd-sdk-android/issues/new?labels=enhancement&template=feature_request.md) with the details about what you'd like to see. At a minimum, please provide: - The goal of the new feature; @@ -79,7 +79,7 @@ or UI, contact our support team via https://docs.datadoghq.com/help/ for direct, faster assistance. You may submit bug reports concerning the Datadog SDK for Android by -[opening a Github issue](https://github.com/DataDog/dd-sdk-android/issues/new). +[opening a Github issue](https://github.com/DataDog/dd-sdk-android/issues/new?labels=bug&template=bug_report.md). At a minimum, please provide: - A description of the problem; diff --git a/build.gradle.kts b/build.gradle.kts index 1d18fede44..edd45c4b44 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -52,7 +52,6 @@ tasks.register("checkAll") { tasks.register("assembleAll") { dependsOn( - ":dd-android-gradle-plugin:assemble", ":dd-sdk-android:assemble", ":dd-sdk-android-coil:assemble", ":dd-sdk-android-fresco:assemble", @@ -104,7 +103,6 @@ tasks.register("unitTestDebug") { tasks.register("unitTestTools") { dependsOn( - ":dd-android-gradle-plugin:test", ":sample:java:assembleRelease", ":sample:kotlin:assembleRelease", ":tools:detekt:test", @@ -122,7 +120,6 @@ tasks.register("unitTestAll") { tasks.register("ktlintCheckAll") { dependsOn( - ":dd-android-gradle-plugin:ktlintCheck", ":dd-sdk-android:ktlintCheck", ":dd-sdk-android-coil:ktlintCheck", ":dd-sdk-android-fresco:ktlintCheck", @@ -155,7 +152,6 @@ tasks.register("lintCheckAll") { tasks.register("detektAll") { dependsOn( - ":dd-android-gradle-plugin:detekt", ":dd-sdk-android:detekt", ":dd-sdk-android-coil:detekt", ":dd-sdk-android-fresco:detekt", @@ -173,7 +169,6 @@ tasks.register("detektAll") { tasks.register("jacocoReportAll") { dependsOn( - ":dd-android-gradle-plugin:jacocoTestReport", ":dd-sdk-android:jacocoTestDebugUnitTestReport", ":dd-sdk-android:jacocoTestReleaseUnitTestReport", ":dd-sdk-android-coil:jacocoTestDebugUnitTestReport", diff --git a/buildSrc/src/main/kotlin/com/datadog/gradle/config/KtLintConfig.kt b/buildSrc/src/main/kotlin/com/datadog/gradle/config/KtLintConfig.kt index cca6af129d..3408132c6f 100644 --- a/buildSrc/src/main/kotlin/com/datadog/gradle/config/KtLintConfig.kt +++ b/buildSrc/src/main/kotlin/com/datadog/gradle/config/KtLintConfig.kt @@ -20,7 +20,7 @@ fun Project.ktLintConfig() { additionalEditorconfigFile.set(file("${project.rootDir}/script/config/.editorconfig")) filter { exclude("**/generated/**") - exclude("**/com/datadog/android/rum/internal/domain/model/**") + exclude("**/com/datadog/android/rum/model/**") include("**/kotlin/**") } } diff --git a/buildSrc/src/main/kotlin/com/datadog/gradle/plugin/jsonschema/JsonDefinition.kt b/buildSrc/src/main/kotlin/com/datadog/gradle/plugin/jsonschema/JsonDefinition.kt index ccb36cf8e4..0da30d7e2c 100644 --- a/buildSrc/src/main/kotlin/com/datadog/gradle/plugin/jsonschema/JsonDefinition.kt +++ b/buildSrc/src/main/kotlin/com/datadog/gradle/plugin/jsonschema/JsonDefinition.kt @@ -22,5 +22,6 @@ data class JsonDefinition( @SerializedName("allOf") val allOf: List?, @SerializedName("properties") val properties: Map?, @SerializedName("definitions") val definitions: Map?, - @SerializedName("readOnly") val readOnly: Boolean? + @SerializedName("readOnly") val readOnly: Boolean?, + @SerializedName("additionalProperties") val additionalProperties: JsonDefinition? ) diff --git a/buildSrc/src/main/kotlin/com/datadog/gradle/plugin/jsonschema/JsonPrimitiveType.kt b/buildSrc/src/main/kotlin/com/datadog/gradle/plugin/jsonschema/JsonPrimitiveType.kt new file mode 100644 index 0000000000..eea93570c1 --- /dev/null +++ b/buildSrc/src/main/kotlin/com/datadog/gradle/plugin/jsonschema/JsonPrimitiveType.kt @@ -0,0 +1,11 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.datadog.gradle.plugin.jsonschema + +enum class JsonPrimitiveType { + STRING, BOOLEAN, INTEGER, DOUBLE +} diff --git a/buildSrc/src/main/kotlin/com/datadog/gradle/plugin/jsonschema/JsonSchemaReader.kt b/buildSrc/src/main/kotlin/com/datadog/gradle/plugin/jsonschema/JsonSchemaReader.kt index 19a2bc7b11..6d56f33ec8 100644 --- a/buildSrc/src/main/kotlin/com/datadog/gradle/plugin/jsonschema/JsonSchemaReader.kt +++ b/buildSrc/src/main/kotlin/com/datadog/gradle/plugin/jsonschema/JsonSchemaReader.kt @@ -137,10 +137,10 @@ class JsonSchemaReader( val type = definition.type return when (type) { JsonType.NULL -> TypeDefinition.Null(definition.description.orEmpty()) - JsonType.BOOLEAN, - JsonType.NUMBER, - JsonType.INTEGER, - JsonType.STRING -> transformPrimitive(definition, typeName) + JsonType.BOOLEAN -> transformPrimitive(definition, JsonPrimitiveType.BOOLEAN, typeName) + JsonType.NUMBER -> transformPrimitive(definition, JsonPrimitiveType.DOUBLE, typeName) + JsonType.INTEGER -> transformPrimitive(definition, JsonPrimitiveType.INTEGER, typeName) + JsonType.STRING -> transformPrimitive(definition, JsonPrimitiveType.STRING, typeName) JsonType.ARRAY -> transformArray(definition, typeName) JsonType.OBJECT, null -> transformType(definition, typeName) } @@ -148,6 +148,7 @@ class JsonSchemaReader( private fun transformPrimitive( definition: JsonDefinition, + primitiveType: JsonPrimitiveType, typeName: String ): TypeDefinition { return if (!definition.enum.isNullOrEmpty()) { @@ -156,7 +157,7 @@ class JsonSchemaReader( transformConstant(definition.type, definition.constant, definition.description) } else { TypeDefinition.Primitive( - type = definition.type ?: JsonType.NULL, + type = primitiveType, description = definition.description.orEmpty() ) } @@ -209,7 +210,7 @@ class JsonSchemaReader( transformEnum(typeName, definition.type, definition.enum, definition.description) } else if (definition.constant != null) { transformConstant(definition.type, definition.constant, definition.description) - } else if (!definition.properties.isNullOrEmpty()) { + } else if (!definition.properties.isNullOrEmpty() || definition.additionalProperties != null) { generateDataClass(typeName, definition) } else if (!definition.allOf.isNullOrEmpty()) { generateTypeAllOf(typeName, definition.allOf) @@ -255,11 +256,13 @@ class JsonSchemaReader( ) properties.add(TypeProperty(name, propertyType, !required, readOnly)) } + val additional = definition.additionalProperties?.let { transform(it, "?") } return TypeDefinition.Class( name = typeName, description = definition.description.orEmpty(), - properties = properties + properties = properties, + additionalProperties = additional ) } diff --git a/buildSrc/src/main/kotlin/com/datadog/gradle/plugin/jsonschema/PokoDeserializerGenerator.kt b/buildSrc/src/main/kotlin/com/datadog/gradle/plugin/jsonschema/PokoDeserializerGenerator.kt new file mode 100644 index 0000000000..3421cc029d --- /dev/null +++ b/buildSrc/src/main/kotlin/com/datadog/gradle/plugin/jsonschema/PokoDeserializerGenerator.kt @@ -0,0 +1,494 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.datadog.gradle.plugin.jsonschema + +import com.squareup.kotlinpoet.ARRAY +import com.squareup.kotlinpoet.AnnotationSpec +import com.squareup.kotlinpoet.ClassName +import com.squareup.kotlinpoet.FunSpec +import com.squareup.kotlinpoet.KModifier +import com.squareup.kotlinpoet.ParameterizedTypeName.Companion.parameterizedBy +import com.squareup.kotlinpoet.PropertySpec +import com.squareup.kotlinpoet.STRING +import com.squareup.kotlinpoet.TypeSpec +import com.squareup.kotlinpoet.jvm.throws +import java.lang.IllegalStateException + +class PokoDeserializerGenerator( + private val packageName: String, + private val knownTypes: MutableList, + private val nestedClasses: MutableSet, + private val nestedEnums: MutableSet +) { + + private lateinit var rootTypeName: String + + /** + * Generate the companion class for a Class Type. + * @param definition the type definition + * @param rootTypeName the type name of the root parent class + */ + fun generateCompanionForClass( + definition: TypeDefinition.Class, + rootTypeName: String + ): TypeSpec { + val companionSpecBuilder = TypeSpec.companionObjectBuilder() + + if (definition.additionalProperties != null) { + if (!definition.properties.isNullOrEmpty()) { + companionSpecBuilder.addProperty(generateReservedPropertyNames(definition)) + } + } + + companionSpecBuilder.addFunction(generateClassDeserializer(definition, rootTypeName)) + + return companionSpecBuilder.build() + } + + /** + * Generate the companion class for a Class Type. + * @param definition the type definition + * @param rootTypeName the type name of the root parent class + */ + fun generateCompanionForEnum(definition: TypeDefinition.Enum, rootTypeName: String): TypeSpec { + val companionSpecBuilder = TypeSpec.companionObjectBuilder() + + companionSpecBuilder.addFunction(generateEnumDeserializer(definition, rootTypeName)) + + return companionSpecBuilder.build() + } + + // region Internal / Class + + /** + * Generates a function that deserializes a Json into an instance of class [definition]. + * @param definition the class definition + * @param rootTypeName the root Type name + */ + private fun generateClassDeserializer( + definition: TypeDefinition.Class, + rootTypeName: String + ): FunSpec { + this.rootTypeName = rootTypeName + val funBuilder = FunSpec.builder(FROM_JSON) + .addAnnotation(AnnotationSpec.builder(JvmStatic::class).build()) + .returns(ClassName.bestGuess(definition.name)) + funBuilder.throws(JSON_PARSE_EXCEPTION) + funBuilder.addParameter(FROM_JSON_PARAM_NAME, STRING) + funBuilder.beginControlFlow("try") + + appendDeserializerFunctionBlock(funBuilder, definition) + + funBuilder.nextControlFlow( + "catch (%L: %T)", EXEPTION_VAR_NAME, ILLEGAL_STATE_EXCEPTION + ) + funBuilder.addStatement( + "throw %T(%L.message)", + JSON_PARSE_EXCEPTION, EXEPTION_VAR_NAME + ) + funBuilder.nextControlFlow( + "catch (%L: %T)", EXEPTION_VAR_NAME, + NUMBER_FORMAT_EXCEPTION + ) + funBuilder.addStatement( + "throw %T(%L.message)", + JSON_PARSE_EXCEPTION, EXEPTION_VAR_NAME + ) + funBuilder.endControlFlow() + return funBuilder.build() + } + + private fun appendDeserializerFunctionBlock( + funBuilder: FunSpec.Builder, + definition: TypeDefinition.Class + ) { + funBuilder.addStatement( + "val %L = %T.parseString(%L).asJsonObject", + ROOT_JSON_OBJECT_PARAM_NAME, + JSON_PARSER, + FROM_JSON_PARAM_NAME + ) + + definition.properties.forEach { p -> + assignDeserializedProperty( + propertyType = p.type, + assignee = "val ${p.name.variableName()}", + getter = "$ROOT_JSON_OBJECT_PARAM_NAME.get(\"${p.name}\")", + nullable = p.optional, + funBuilder = funBuilder + ) + } + if (definition.additionalProperties != null) { + appendAdditionalPropertiesDeserialization( + definition.additionalProperties, + funBuilder, + !definition.properties.isNullOrEmpty() + ) + } + + val filteredProperties = + definition.properties.filter { it.type !is TypeDefinition.Constant } + val properties = filteredProperties.joinToString(", ") { it.name.variableName() } + + val additionalSuffix = if (definition.additionalProperties != null) { + if (filteredProperties.isEmpty()) { + PokoGenerator.ADDITIONAL_PROPERTIES_NAME + } else { + ", ${PokoGenerator.ADDITIONAL_PROPERTIES_NAME}" + } + } else { + "" + } + + funBuilder.addStatement("return %L($properties$additionalSuffix)", definition.name) + } + + /** + * Appends an additionalProperties deserialization to a [FunSpec.Builder]. + * @param additionalProperties the additional properties type definition + * @param funBuilder the `fromJson()` [FunSpec] builder. + */ + private fun appendAdditionalPropertiesDeserialization( + additionalProperties: TypeDefinition, + funBuilder: FunSpec.Builder, + hasKnownProperties: Boolean + ) { + + funBuilder.addStatement( + "val %L = mutableMapOf<%T, %T>()", + PokoGenerator.ADDITIONAL_PROPERTIES_NAME, + STRING, + additionalProperties.asKotlinTypeName( + nestedEnums, + nestedClasses, + knownTypes, + packageName, + rootTypeName + ) + ) + funBuilder.beginControlFlow("for (entry in %L.entrySet())", ROOT_JSON_OBJECT_PARAM_NAME) + + if (hasKnownProperties) { + funBuilder.beginControlFlow("if (entry.key !in %L)", RESERVED_PROPERTIES_NAME) + } + + assignDeserializedProperty( + propertyType = additionalProperties, + assignee = "${PokoGenerator.ADDITIONAL_PROPERTIES_NAME}[entry.key]", + getter = "entry.value", + nullable = false, + funBuilder = funBuilder + ) + + if (hasKnownProperties) { + funBuilder.endControlFlow() + } + funBuilder.endControlFlow() + } + + /** + * Appends a property deserialization to a [FunSpec.Builder]. + * @param propertyType the property's type definition + * @param assignee the assignee prefix + * @param getter the code snippet to get the json value + * @param nullable whether the value is nullable + * @param funBuilder the `toJson()` [FunSpec] builder. + */ + private fun assignDeserializedProperty( + propertyType: TypeDefinition, + assignee: String, + getter: String, + nullable: Boolean, + funBuilder: FunSpec.Builder + ) { + when (propertyType) { + is TypeDefinition.Null -> funBuilder.addStatement("$assignee = null") + is TypeDefinition.Primitive -> appendPrimitiveDeserialization( + propertyType, + assignee, + getter, + nullable, + funBuilder + ) + is TypeDefinition.Array -> appendArrayDeserialization( + propertyType, + assignee, + getter, + nullable, + funBuilder + ) + is TypeDefinition.Class -> appendObjectDeserialization( + propertyType, + assignee, + getter, + nullable, + funBuilder + ) + is TypeDefinition.Enum -> appendEnumDeserialization( + propertyType, + assignee, + getter, + nullable, + funBuilder + ) + is TypeDefinition.Constant -> { + // No Op + } + } + } + + /** + * Appends a primitive property deserialization to a [FunSpec.Builder]. + * @param type the primitive type + * @param assignee the assignee prefix + * @param getter the code snippet to get the json value + * @param nullable whether the value is nullable + * @param funBuilder the `toJson()` [FunSpec] builder. + */ + private fun appendPrimitiveDeserialization( + type: TypeDefinition.Primitive, + assignee: String, + getter: String, + nullable: Boolean, + funBuilder: FunSpec.Builder + ) { + val opt = if (nullable) "?" else "" + funBuilder.addStatement("$assignee = $getter$opt.${type.asPrimitiveType()}") + } + + /** + * Appends an Array property deserialization to a [FunSpec.Builder]. + * @param arrayType the array's type + * @param assignee the assignee prefix + * @param getter the code snippet to get the json value + * @param nullable whether the value is nullable + * @param funBuilder the `toJson()` [FunSpec] builder. + */ + private fun appendArrayDeserialization( + arrayType: TypeDefinition.Array, + assignee: String, + getter: String, + nullable: Boolean, + funBuilder: FunSpec.Builder + ) { + val opt = if (nullable) "?" else "" + funBuilder.beginControlFlow( + "$assignee = $getter$opt.asJsonArray$opt.let { %L ->", + JSON_ARRAY_VAR_NAME + ) + val collectionClassName: ClassName = if (arrayType.uniqueItems) { + MUTABLE_SET + } else { + MUTABLE_LIST + } + funBuilder.addStatement( + "val %L = %T(%L.size())", + ARRAY_COLLECTION_VAR_NAME, + collectionClassName.parameterizedBy( + arrayType.items.asKotlinTypeName( + nestedEnums, + nestedClasses, + knownTypes, + packageName, + rootTypeName + ) + ), + JSON_ARRAY_VAR_NAME + ) + funBuilder.beginControlFlow("%L.forEach", JSON_ARRAY_VAR_NAME) + appendArrayItemDeserialization(arrayType, funBuilder) + + funBuilder.endControlFlow() + funBuilder.addStatement("%L", ARRAY_COLLECTION_VAR_NAME) + funBuilder.endControlFlow() + } + + /** + * Appends an array item deserialization to a [FunSpec.Builder]. + * @param arrayType the array's type + * @param funBuilder the `toJson()` [FunSpec] builder. + */ + private fun appendArrayItemDeserialization( + arrayType: TypeDefinition.Array, + funBuilder: FunSpec.Builder + ) { + when (arrayType.items) { + is TypeDefinition.Primitive -> funBuilder.addStatement( + "%L.add(it.${arrayType.items.asPrimitiveType()})", + ARRAY_COLLECTION_VAR_NAME + ) + is TypeDefinition.Class -> + funBuilder.addStatement( + "%L.add(%T.fromJson(it.toString()))", + ARRAY_COLLECTION_VAR_NAME, + arrayType.items.asKotlinTypeName( + nestedEnums, + nestedClasses, + knownTypes, + packageName, + rootTypeName + ) + ) + is TypeDefinition.Enum -> funBuilder.addStatement( + "%L.add(%T.fromJson(it.asString))", + ARRAY_COLLECTION_VAR_NAME, + arrayType.items.asKotlinTypeName( + nestedEnums, + nestedClasses, + knownTypes, + packageName, + rootTypeName + ) + ) + is TypeDefinition.Constant, + is TypeDefinition.Array, + is TypeDefinition.Null -> throw IllegalStateException( + "Unable to deserialize an array of ${arrayType.items}" + ) + } + } + + /** + * Appends an Object property deserialization to a [FunSpec.Builder]. + * @param propertyType the property's type definition + * @param assignee the assignee prefix + * @param getter the code snippet to get the json value + * @param nullable whether the value is nullable + * @param funBuilder the `toJson()` [FunSpec] builder. + */ + private fun appendObjectDeserialization( + propertyType: TypeDefinition.Class, + assignee: String, + getter: String, + nullable: Boolean, + funBuilder: FunSpec.Builder + ) { + val opt = if (nullable) "?" else "" + funBuilder.beginControlFlow("$assignee = $getter$opt.toString()$opt.let") + funBuilder.addStatement( + "%T.fromJson(it)", + propertyType.asKotlinTypeName( + nestedEnums, + nestedClasses, + knownTypes, + packageName, + rootTypeName + ) + ) + funBuilder.endControlFlow() + } + + /** + * Appends an Enum property deserialization to a [FunSpec.Builder]. + * @param propertyType the property's type definition + * @param assignee the assignee prefix + * @param getter the code snippet to get the json value + * @param nullable whether the value is nullable + * @param funBuilder the `toJson()` [FunSpec] builder. + */ + private fun appendEnumDeserialization( + propertyType: TypeDefinition.Enum, + assignee: String, + getter: String, + nullable: Boolean, + funBuilder: FunSpec.Builder + ) { + val opt = if (nullable) "?" else "" + funBuilder.beginControlFlow("$assignee = $getter$opt.asString$opt.let") + + funBuilder.addStatement( + "%T.fromJson(it)", + propertyType.asKotlinTypeName( + nestedEnums, + nestedClasses, + knownTypes, + packageName, + rootTypeName + ) + ) + funBuilder.endControlFlow() + } + + private fun generateReservedPropertyNames(definition: TypeDefinition.Class): PropertySpec { + + val propertyNames = definition.properties + .joinToString(", ") { "\"${it.name}\"" } + + val propertyBuilder = PropertySpec.builder( + RESERVED_PROPERTIES_NAME, + ARRAY.parameterizedBy(STRING), + KModifier.PRIVATE + ) + .initializer("arrayOf($propertyNames)") + return propertyBuilder.build() + } + + private fun TypeDefinition.Primitive.asPrimitiveType(): String { + return when (type) { + JsonPrimitiveType.BOOLEAN -> "asBoolean" + JsonPrimitiveType.DOUBLE -> "asDouble" + JsonPrimitiveType.STRING -> "asString" + JsonPrimitiveType.INTEGER -> "asLong" + } + } + + // endregion + + // region Internal / Enum + + /** + * Generates a function that deserializes a Json into an instance of class [definition]. + * @param definition the class definition + * @param rootTypeName the root Type name + */ + private fun generateEnumDeserializer( + definition: TypeDefinition.Enum, + rootTypeName: String + ): FunSpec { + this.rootTypeName = rootTypeName + val funBuilder = FunSpec.builder(FROM_JSON) + .addAnnotation(AnnotationSpec.builder(JvmStatic::class).build()) + .addParameter(FROM_JSON_PARAM_NAME, String::class) + .returns( + definition.asKotlinTypeName( + nestedEnums, + nestedClasses, + knownTypes, + packageName, + rootTypeName + ) + ) + funBuilder.addStatement( + "return values().first { it.%L == %L }", + PokoGenerator.ENUM_CONSTRUCTOR_JSON_VALUE_NAME, + FROM_JSON_PARAM_NAME + ) + + return funBuilder.build() + } + + // endregion + + companion object { + + private const val FROM_JSON = "fromJson" + private const val FROM_JSON_PARAM_NAME = "serializedObject" + private const val ROOT_JSON_OBJECT_PARAM_NAME = "jsonObject" + private const val EXEPTION_VAR_NAME = "e" + private const val ARRAY_COLLECTION_VAR_NAME = "collection" + private const val JSON_ARRAY_VAR_NAME = "jsonArray" + private val JSON_PARSE_EXCEPTION = + ClassName.bestGuess("com.google.gson.JsonParseException") + private val JSON_PARSER = ClassName.bestGuess("com.google.gson.JsonParser") + private val ILLEGAL_STATE_EXCEPTION = ClassName.bestGuess("java.lang.IllegalStateException") + private val NUMBER_FORMAT_EXCEPTION = ClassName.bestGuess("java.lang.NumberFormatException") + private val MUTABLE_LIST = ClassName.bestGuess("kotlin.collections.ArrayList") + private val MUTABLE_SET = ClassName.bestGuess("kotlin.collections.HashSet") + + private const val RESERVED_PROPERTIES_NAME = "RESERVED_PROPERTIES" + } +} diff --git a/buildSrc/src/main/kotlin/com/datadog/gradle/plugin/jsonschema/PokoExtensions.kt b/buildSrc/src/main/kotlin/com/datadog/gradle/plugin/jsonschema/PokoExtensions.kt new file mode 100644 index 0000000000..81781a85ab --- /dev/null +++ b/buildSrc/src/main/kotlin/com/datadog/gradle/plugin/jsonschema/PokoExtensions.kt @@ -0,0 +1,132 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.datadog.gradle.plugin.jsonschema + +import android.databinding.tool.ext.joinToCamelCaseAsVar +import com.squareup.kotlinpoet.BOOLEAN +import com.squareup.kotlinpoet.ClassName +import com.squareup.kotlinpoet.DOUBLE +import com.squareup.kotlinpoet.LIST +import com.squareup.kotlinpoet.LONG +import com.squareup.kotlinpoet.NOTHING +import com.squareup.kotlinpoet.ParameterizedTypeName.Companion.parameterizedBy +import com.squareup.kotlinpoet.SET +import com.squareup.kotlinpoet.STRING +import com.squareup.kotlinpoet.TypeName +import java.util.Locale + +internal val NOTHING_NULLABLE = NOTHING.copy(nullable = true) + +internal fun String.variableName(): String { + val split = this.split("_").filter { it.isNotBlank() } + if (split.isEmpty()) return "" + if (split.size == 1) return split[0] + return split.joinToCamelCaseAsVar() +} + +internal fun String.enumConstantName(): String { + return toUpperCase(Locale.US).replace(Regex("[^A-Z0-9]+"), "_") +} + +internal fun JsonType?.asKotlinTypeName(): TypeName { + return when (this) { + null, + JsonType.NULL -> NOTHING_NULLABLE + JsonType.BOOLEAN -> BOOLEAN + JsonType.NUMBER -> DOUBLE + JsonType.STRING -> STRING + JsonType.INTEGER -> LONG + JsonType.OBJECT, + JsonType.ARRAY -> throw IllegalArgumentException( + "Cannot convert $this to a KotlinTypeName" + ) + } +} + +internal fun JsonPrimitiveType?.asKotlinTypeName(): TypeName { + return when (this) { + JsonPrimitiveType.BOOLEAN -> BOOLEAN + JsonPrimitiveType.DOUBLE -> DOUBLE + JsonPrimitiveType.STRING -> STRING + JsonPrimitiveType.INTEGER -> LONG + null -> NOTHING_NULLABLE + } +} + +internal fun String.uniqueClassName(knownTypes: MutableList): String { + var uniqueName = this + var tries = 0 + while (uniqueName in knownTypes) { + tries++ + uniqueName = "${this}$tries" + } + knownTypes.add(uniqueName) + return uniqueName +} + +internal fun TypeDefinition.Enum.withUniqueTypeName( + nestedEnums: MutableSet, + knownTypes: MutableList +): TypeDefinition.Enum { + val matchingEnum = nestedEnums.firstOrNull { it.values == values } + return matchingEnum ?: copy(name = name.uniqueClassName(knownTypes)) +} + +internal fun TypeDefinition.Class.withUniqueTypeName( + nestedClasses: MutableSet, + knownTypes: MutableList +): TypeDefinition.Class { + val matchingClass = nestedClasses.firstOrNull { it.properties == properties } + return matchingClass ?: copy(name = name.uniqueClassName(knownTypes)) +} + +internal fun TypeDefinition.asKotlinTypeName( + nestedEnums: MutableSet, + nestedClasses: MutableSet, + knownTypes: MutableList, + packageName: String, + rootTypeName: String +): TypeName { + return when (this) { + is TypeDefinition.Null -> NOTHING + is TypeDefinition.Primitive -> type.asKotlinTypeName() + is TypeDefinition.Constant -> type.asKotlinTypeName() + is TypeDefinition.Class -> { + val def = withUniqueTypeName(nestedClasses, knownTypes) + nestedClasses.add(def) + ClassName(packageName, rootTypeName, def.name) + } + is TypeDefinition.Array -> { + if (uniqueItems) { + SET.parameterizedBy( + items.asKotlinTypeName( + nestedEnums, + nestedClasses, + knownTypes, + packageName, + rootTypeName + ) + ) + } else { + LIST.parameterizedBy( + items.asKotlinTypeName( + nestedEnums, + nestedClasses, + knownTypes, + packageName, + rootTypeName + ) + ) + } + } + is TypeDefinition.Enum -> { + val def = withUniqueTypeName(nestedEnums, knownTypes) + nestedEnums.add(def) + ClassName(packageName, rootTypeName, def.name) + } + } +} diff --git a/buildSrc/src/main/kotlin/com/datadog/gradle/plugin/jsonschema/PokoGenerator.kt b/buildSrc/src/main/kotlin/com/datadog/gradle/plugin/jsonschema/PokoGenerator.kt index fa4d89d7d9..8369302cb5 100644 --- a/buildSrc/src/main/kotlin/com/datadog/gradle/plugin/jsonschema/PokoGenerator.kt +++ b/buildSrc/src/main/kotlin/com/datadog/gradle/plugin/jsonschema/PokoGenerator.kt @@ -6,26 +6,20 @@ package com.datadog.gradle.plugin.jsonschema -import android.databinding.tool.ext.joinToCamelCaseAsVar import com.squareup.kotlinpoet.BOOLEAN -import com.squareup.kotlinpoet.ClassName import com.squareup.kotlinpoet.CodeBlock import com.squareup.kotlinpoet.DOUBLE import com.squareup.kotlinpoet.FileSpec import com.squareup.kotlinpoet.FunSpec import com.squareup.kotlinpoet.KModifier -import com.squareup.kotlinpoet.LIST import com.squareup.kotlinpoet.LONG -import com.squareup.kotlinpoet.NOTHING +import com.squareup.kotlinpoet.MAP import com.squareup.kotlinpoet.ParameterSpec import com.squareup.kotlinpoet.ParameterizedTypeName.Companion.parameterizedBy import com.squareup.kotlinpoet.PropertySpec -import com.squareup.kotlinpoet.SET import com.squareup.kotlinpoet.STRING -import com.squareup.kotlinpoet.TypeName import com.squareup.kotlinpoet.TypeSpec import java.io.File -import java.util.Locale class PokoGenerator( internal val outputDir: File, @@ -34,9 +28,12 @@ class PokoGenerator( private lateinit var rootTypeName: String private val knownTypes: MutableList = mutableListOf() - private val nestedClasses: MutableSet = mutableSetOf() + private val nestedEnums: MutableSet = mutableSetOf() + private val deserializerGenerator = + PokoDeserializerGenerator(packageName, knownTypes, nestedClasses, nestedEnums) + private val serializerGenerator = PokoSerializerGenerator() // region PokoGenerator @@ -101,9 +98,11 @@ class PokoGenerator( constructorBuilder, docBuilder ) + val companion = deserializerGenerator.generateCompanionForClass(definition, rootTypeName) typeBuilder.primaryConstructor(constructorBuilder.build()) .addKdoc(docBuilder.build()) + .addType(companion) return typeBuilder } @@ -116,25 +115,39 @@ class PokoGenerator( definition: TypeDefinition.Enum ): TypeSpec { val enumBuilder = TypeSpec.enumBuilder(definition.name) + enumBuilder.primaryConstructor( + FunSpec.constructorBuilder() + .addParameter(ENUM_CONSTRUCTOR_JSON_VALUE_NAME, String::class) + .build() + ) val docBuilder = CodeBlock.builder() if (definition.description.isNotBlank()) { docBuilder.add(definition.description) docBuilder.add("\n") } + enumBuilder.addProperty( + PropertySpec.builder(ENUM_CONSTRUCTOR_JSON_VALUE_NAME, String::class, KModifier.PRIVATE) + .initializer(ENUM_CONSTRUCTOR_JSON_VALUE_NAME) + .build() + ) definition.values.forEach { value -> enumBuilder.addEnumConstant( value.enumConstantName(), TypeSpec.anonymousClassBuilder() + .addSuperclassConstructorParameter("%S", value) .build() ) } - enumBuilder.addFunction(generateEnumSerializer(definition)) + enumBuilder.addFunction(serializerGenerator.generateEnumSerializer()) + + val companion = deserializerGenerator.generateCompanionForEnum(definition, rootTypeName) return enumBuilder .addKdoc(docBuilder.build()) + .addType(companion) .build() } @@ -153,8 +166,13 @@ class PokoGenerator( ) { val varName = property.name.variableName() val nullable = property.optional || property.type is TypeDefinition.Null - val type = property.type.asKotlinTypeName() - .copy(nullable = nullable) + val type = property.type.asKotlinTypeName( + nestedEnums, + nestedClasses, + knownTypes, + packageName, + rootTypeName + ).copy(nullable = nullable) val constructorParamBuilder = ParameterSpec.builder(varName, type) if (nullable) { @@ -174,6 +192,48 @@ class PokoGenerator( } } + /** + * Appends a property to a [TypeSpec.Builder]. + * @param additionalPropertyType the additional properties type definition + * @param typeBuilder the `data class` [TypeSpec] builder. + * @param constructorBuilder the `data class` constructor builder. + * @param docBuilder the `data class` KDoc builder. + */ + private fun appendAdditionalProperties( + additionalPropertyType: TypeDefinition, + typeBuilder: TypeSpec.Builder, + constructorBuilder: FunSpec.Builder, + docBuilder: CodeBlock.Builder + ) { + val type = additionalPropertyType.asKotlinTypeName( + nestedEnums, + nestedClasses, + knownTypes, + packageName, + rootTypeName + ) + + val constructorParamBuilder = ParameterSpec.builder( + ADDITIONAL_PROPERTIES_NAME, + MAP.parameterizedBy(STRING, type) + ) + constructorParamBuilder.defaultValue("emptyMap()") + constructorBuilder.addParameter(constructorParamBuilder.build()) + + typeBuilder.addProperty( + PropertySpec.builder(ADDITIONAL_PROPERTIES_NAME, MAP.parameterizedBy(STRING, type)) + .mutable(false) + .initializer(ADDITIONAL_PROPERTIES_NAME) + .build() + ) + + if (additionalPropertyType.description.isNotBlank()) { + docBuilder.add( + "@param $ADDITIONAL_PROPERTIES_NAME ${additionalPropertyType.description}\n" + ) + } + } + /** * Appends a property to a [TypeSpec.Builder], with a constant default value. * @param name the property json name @@ -243,289 +303,27 @@ class PokoGenerator( ) } } - - if (nonConstants > 0) { - typeBuilder.addModifiers(KModifier.DATA) - } - - typeBuilder.addFunction(generateClassSerializer(definition)) - } - - // endregion - - // region Serialization - - /** - * Generates a function serializing the type to Json - * @param definition the class definition - */ - private fun generateClassSerializer(definition: TypeDefinition.Class): FunSpec { - val funBuilder = FunSpec.builder(TO_JSON) - .returns(JSON_ELEMENT) - - funBuilder.addStatement("val json = %T()", JSON_OBJECT) - - definition.properties.forEach { p -> - appendPropertySerialization(p, funBuilder) - } - - funBuilder.addStatement("return json") - - return funBuilder.build() - } - - /** - * Generates a function serializing the type to Json - * @param definition the enum class definition - */ - private fun generateEnumSerializer(definition: TypeDefinition.Enum): FunSpec { - val funBuilder = FunSpec.builder(TO_JSON) - .returns(JSON_ELEMENT) - - funBuilder.beginControlFlow("return when (this)") - definition.values.forEach { value -> - funBuilder.addStatement( - "%L -> %T(%S)", - value.enumConstantName(), - JSON_PRIMITIVE, - value + if (definition.additionalProperties != null) { + nonConstants++ + appendAdditionalProperties( + definition.additionalProperties, + typeBuilder, + constructorBuilder, + docBuilder ) } - funBuilder.endControlFlow() - return funBuilder.build() - } - - /** - * Appends a property serialization to a [FunSpec.Builder]. - * @param property the property definition - * @param funBuilder the `toJson()` [FunSpec] builder. - */ - private fun appendPropertySerialization( - property: TypeProperty, - funBuilder: FunSpec.Builder - ) { - - val varName = property.name.variableName() - when (property.type) { - is TypeDefinition.Constant -> appendConstantSerialization( - property.name, - property.type, - funBuilder - ) - is TypeDefinition.Primitive -> appendPrimitiveSerialization( - property, - property.type, - varName, - funBuilder - ) - is TypeDefinition.Null -> if (!property.optional) { - funBuilder.addStatement("json.add(%S, null)", property.name) - } - is TypeDefinition.Array -> appendArraySerialization( - property, - property.type, - varName, - funBuilder - ) - is TypeDefinition.Class, - is TypeDefinition.Enum -> appendObjectSerialization(property, varName, funBuilder) - } - } - - private fun appendObjectSerialization( - property: TypeProperty, - varName: String, - funBuilder: FunSpec.Builder - ) { - if (property.optional) { - funBuilder.addStatement( - "%L?.let { json.add(%S, it.%L()) }", - varName, - property.name, - TO_JSON - ) - } else { - funBuilder.addStatement( - "json.add(%S, %L.%L())", - property.name, - varName, TO_JSON - ) - } - } - - private fun appendArraySerialization( - property: TypeProperty, - type: TypeDefinition.Array, - varName: String, - funBuilder: FunSpec.Builder - ) { - val arrayVar = if (property.optional) { - funBuilder.beginControlFlow("%L?.let { temp ->", varName) - "temp" - } else { - varName - } - - funBuilder.addStatement("val %LArray = %T(%L.size)", varName, JSON_ARRAY, arrayVar) - when (type.items) { - is TypeDefinition.Primitive -> funBuilder.addStatement( - "%L.forEach { %LArray.add(it) }", - arrayVar, - varName - ) - is TypeDefinition.Class, - is TypeDefinition.Enum -> funBuilder.addStatement( - "%L.forEach { %LArray.add(it.%L()) }", - arrayVar, - varName, - TO_JSON - ) - } - - funBuilder.addStatement("json.add(%S, %LArray)", property.name, varName) - - if (property.optional) { - funBuilder.endControlFlow() - } - } - - /** - * Appends a primitive property serialization to a [FunSpec.Builder]. - * @param property the property definition - * @param type the primitive type - * @param funBuilder the `toJson()` [FunSpec] builder. - */ - @Suppress("NON_EXHAUSTIVE_WHEN") - private fun appendPrimitiveSerialization( - property: TypeProperty, - type: TypeDefinition.Primitive, - varName: String, - funBuilder: FunSpec.Builder - ) { - when (type.type) { - JsonType.BOOLEAN, - JsonType.NUMBER, - JsonType.STRING, - JsonType.INTEGER -> - if (property.optional) { - funBuilder.addStatement( - "%L?.let { json.addProperty(%S, it) }", - varName, - property.name - ) - } else { - funBuilder.addStatement( - "json.addProperty(%S, %L)", - property.name, - varName - ) - } - } - } - - /** - * Appends a property serialization to a [FunSpec.Builder], with a constant default value. - * @param name the property json name - * @param definition the property definition - * @param funBuilder the `toJson()` [FunSpec] builder. - */ - private fun appendConstantSerialization( - name: String, - definition: TypeDefinition.Constant, - funBuilder: FunSpec.Builder - ) { - val constantValue = definition.value - if (constantValue is String || constantValue is Number) { - funBuilder.addStatement("json.addProperty(%S, %L)", name, name.variableName()) - } else { - throw IllegalStateException("Unable to generate serialization for constant type $definition") - } - } - - // endregion - - // region Extensions - - private fun String.variableName(): String { - val split = this.split("_").filter { it.isNotBlank() } - if (split.isEmpty()) return "" - if (split.size == 1) return split[0] - return split.joinToCamelCaseAsVar() - } - - private fun String.uniqueClassName(): String { - var uniqueName = this - var tries = 0 - while (uniqueName in knownTypes) { - tries++ - uniqueName = "${this}$tries" - } - knownTypes.add(uniqueName) - return uniqueName - } - - private fun String.enumConstantName(): String { - return toUpperCase(Locale.US).replace(Regex("[^A-Z0-9]+"), "_") - } - - private fun TypeDefinition.Enum.withUniqueTypeName(): TypeDefinition.Enum { - val matchingEnum = nestedEnums.firstOrNull { it.values == values } - return matchingEnum ?: copy(name = name.uniqueClassName()) - } - - private fun TypeDefinition.Class.withUniqueTypeName(): TypeDefinition.Class { - val matchingClass = nestedClasses.firstOrNull { it.properties == properties } - return matchingClass ?: copy(name = name.uniqueClassName()) - } - - private fun TypeDefinition.asKotlinTypeName(): TypeName { - return when (this) { - is TypeDefinition.Null -> NOTHING - is TypeDefinition.Primitive -> type.asKotlinTypeName() - is TypeDefinition.Constant -> type.asKotlinTypeName() - is TypeDefinition.Class -> { - val def = withUniqueTypeName() - nestedClasses.add(def) - ClassName(packageName, rootTypeName, def.name) - } - is TypeDefinition.Array -> { - if (uniqueItems) { - SET.parameterizedBy(items.asKotlinTypeName()) - } else { - LIST.parameterizedBy(items.asKotlinTypeName()) - } - } - is TypeDefinition.Enum -> { - val def = withUniqueTypeName() - nestedEnums.add(def) - ClassName(packageName, rootTypeName, def.name) - } + if (nonConstants > 0) { + typeBuilder.addModifiers(KModifier.DATA) } - } - private fun JsonType?.asKotlinTypeName(): TypeName { - return when (this) { - JsonType.NULL -> NOTHING_NULLABLE - JsonType.BOOLEAN -> BOOLEAN - JsonType.NUMBER -> DOUBLE - JsonType.STRING -> STRING - JsonType.INTEGER -> LONG - else -> TODO() - } + typeBuilder.addFunction(serializerGenerator.generateClassSerializer(definition)) } // endregion companion object { - - private val NOTHING_NULLABLE = NOTHING.copy(nullable = true) - - private val TO_JSON = "toJson" - - private val JSON_ELEMENT = ClassName.bestGuess("com.google.gson.JsonElement") - private val JSON_OBJECT = ClassName.bestGuess("com.google.gson.JsonObject") - private val JSON_ARRAY = ClassName.bestGuess("com.google.gson.JsonArray") - private val JSON_PRIMITIVE = ClassName.bestGuess("com.google.gson.JsonPrimitive") + const val ENUM_CONSTRUCTOR_JSON_VALUE_NAME = "jsonValue" + const val ADDITIONAL_PROPERTIES_NAME = "additionalProperties" } } diff --git a/buildSrc/src/main/kotlin/com/datadog/gradle/plugin/jsonschema/PokoSerializerGenerator.kt b/buildSrc/src/main/kotlin/com/datadog/gradle/plugin/jsonschema/PokoSerializerGenerator.kt new file mode 100644 index 0000000000..ec6191658b --- /dev/null +++ b/buildSrc/src/main/kotlin/com/datadog/gradle/plugin/jsonschema/PokoSerializerGenerator.kt @@ -0,0 +1,232 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.datadog.gradle.plugin.jsonschema + +import com.squareup.kotlinpoet.ClassName +import com.squareup.kotlinpoet.FunSpec + +class PokoSerializerGenerator { + + /** + * Generates a function serializing the type to Json + * @param definition the class definition + */ + fun generateClassSerializer(definition: TypeDefinition.Class): FunSpec { + val funBuilder = FunSpec.builder(TO_JSON) + .returns(JSON_ELEMENT) + + funBuilder.addStatement("val json = %T()", JSON_OBJECT) + + definition.properties.forEach { p -> + appendPropertySerialization(p, funBuilder) + } + + if (definition.additionalProperties != null) { + appendAdditionalPropertiesSerialization(definition.additionalProperties, funBuilder) + } + + funBuilder.addStatement("return json") + + return funBuilder.build() + } + + private fun appendAdditionalPropertiesSerialization( + additionalProperties: TypeDefinition, + funBuilder: FunSpec.Builder + ) { + funBuilder.beginControlFlow( + "%L.forEach { (k, v) ->", + PokoGenerator.ADDITIONAL_PROPERTIES_NAME + ) + + when (additionalProperties) { + is TypeDefinition.Primitive -> funBuilder.addStatement("json.addProperty(k, v)") + is TypeDefinition.Class, + is TypeDefinition.Enum -> funBuilder.addStatement("json.add(k, v.%L()) }", TO_JSON) + is TypeDefinition.Null -> funBuilder.addStatement("json.add(k, null) }") + is TypeDefinition.Array -> throw IllegalStateException( + "Unable to generate serialization for Array type $additionalProperties" + ) + is TypeDefinition.Constant -> throw IllegalStateException( + "Unable to generate serialization for constant type $additionalProperties" + ) + } + + funBuilder.endControlFlow() + } + + /** + * Generates a function serializing the type to Json + */ + fun generateEnumSerializer(): FunSpec { + val funBuilder = FunSpec.builder(TO_JSON) + .returns(JSON_ELEMENT) + funBuilder.addStatement( + "return %T(%L)", + JSON_PRIMITIVE, + PokoGenerator.ENUM_CONSTRUCTOR_JSON_VALUE_NAME + ) + + return funBuilder.build() + } + + /** + * Appends a property serialization to a [FunSpec.Builder]. + * @param property the property definition + * @param funBuilder the `toJson()` [FunSpec] builder. + */ + private fun appendPropertySerialization( + property: TypeProperty, + funBuilder: FunSpec.Builder + ) { + + val varName = property.name.variableName() + when (property.type) { + is TypeDefinition.Constant -> appendConstantSerialization( + property.name, + property.type, + funBuilder + ) + is TypeDefinition.Primitive -> appendPrimitiveSerialization( + property, + varName, + funBuilder + ) + is TypeDefinition.Null -> if (!property.optional) { + funBuilder.addStatement("json.add(%S, null)", property.name) + } + is TypeDefinition.Array -> appendArraySerialization( + property, + property.type, + varName, + funBuilder + ) + is TypeDefinition.Class, + is TypeDefinition.Enum -> appendObjectSerialization(property, varName, funBuilder) + } + } + + private fun appendObjectSerialization( + property: TypeProperty, + varName: String, + funBuilder: FunSpec.Builder + ) { + if (property.optional) { + funBuilder.addStatement( + "%L?.let { json.add(%S, it.%L()) }", + varName, + property.name, + TO_JSON + ) + } else { + funBuilder.addStatement( + "json.add(%S, %L.%L())", + property.name, + varName, TO_JSON + ) + } + } + + private fun appendArraySerialization( + property: TypeProperty, + type: TypeDefinition.Array, + varName: String, + funBuilder: FunSpec.Builder + ) { + val arrayVar = if (property.optional) { + funBuilder.beginControlFlow("%L?.let { temp ->", varName) + "temp" + } else { + varName + } + + funBuilder.addStatement( + "val %LArray = %T(%L.size)", varName, + JSON_ARRAY, arrayVar + ) + when (type.items) { + is TypeDefinition.Null, + is TypeDefinition.Primitive, + is TypeDefinition.Constant -> funBuilder.addStatement( + "%L.forEach { %LArray.add(it) }", + arrayVar, + varName + ) + is TypeDefinition.Class, + is TypeDefinition.Enum -> funBuilder.addStatement( + "%L.forEach { %LArray.add(it.%L()) }", + arrayVar, + varName, + TO_JSON + ) + is TypeDefinition.Array -> throw UnsupportedOperationException( + "Unable to serialize an array of arrays: $type" + ) + } + + funBuilder.addStatement("json.add(%S, %LArray)", property.name, varName) + + if (property.optional) { + funBuilder.endControlFlow() + } + } + + /** + * Appends a primitive property serialization to a [FunSpec.Builder]. + * @param property the property definition + * @param funBuilder the `toJson()` [FunSpec] builder. + */ + private fun appendPrimitiveSerialization( + property: TypeProperty, + varName: String, + funBuilder: FunSpec.Builder + ) { + if (property.optional) { + funBuilder.addStatement( + "%L?.let { json.addProperty(%S, it) }", + varName, + property.name + ) + } else { + funBuilder.addStatement( + "json.addProperty(%S, %L)", + property.name, + varName + ) + } + } + + /** + * Appends a property serialization to a [FunSpec.Builder], with a constant default value. + * @param name the property json name + * @param definition the property definition + * @param funBuilder the `toJson()` [FunSpec] builder. + */ + private fun appendConstantSerialization( + name: String, + definition: TypeDefinition.Constant, + funBuilder: FunSpec.Builder + ) { + val constantValue = definition.value + if (constantValue is String || constantValue is Number) { + funBuilder.addStatement("json.addProperty(%S, %L)", name, name.variableName()) + } else { + throw IllegalStateException( + "Unable to generate serialization for constant type $definition" + ) + } + } + + companion object { + + private const val TO_JSON = "toJson" + private val JSON_ELEMENT = ClassName.bestGuess("com.google.gson.JsonElement") + private val JSON_OBJECT = ClassName.bestGuess("com.google.gson.JsonObject") + private val JSON_ARRAY = ClassName.bestGuess("com.google.gson.JsonArray") + private val JSON_PRIMITIVE = ClassName.bestGuess("com.google.gson.JsonPrimitive") + } +} diff --git a/buildSrc/src/main/kotlin/com/datadog/gradle/plugin/jsonschema/TypeDefinition.kt b/buildSrc/src/main/kotlin/com/datadog/gradle/plugin/jsonschema/TypeDefinition.kt index 4b7b383027..e611656f40 100644 --- a/buildSrc/src/main/kotlin/com/datadog/gradle/plugin/jsonschema/TypeDefinition.kt +++ b/buildSrc/src/main/kotlin/com/datadog/gradle/plugin/jsonschema/TypeDefinition.kt @@ -31,7 +31,7 @@ sealed class TypeDefinition { } data class Primitive( - val type: JsonType, + val type: JsonPrimitiveType, override val description: String = "" ) : TypeDefinition() { override fun mergedWith(other: TypeDefinition): TypeDefinition { @@ -56,7 +56,8 @@ sealed class TypeDefinition { data class Class( val name: String, val properties: List, - override val description: String = "" + override val description: String = "", + val additionalProperties: TypeDefinition? = null ) : TypeDefinition() { override fun mergedWith(other: TypeDefinition): TypeDefinition { check(other is Class) { "Cannot merge Class with ${other.javaClass}" } diff --git a/buildSrc/src/test/kotlin/com/datadog/gradle/plugin/jsonschema/JsonSchemaReaderTest.kt b/buildSrc/src/test/kotlin/com/datadog/gradle/plugin/jsonschema/JsonSchemaReaderTest.kt index ae5a02bc03..55f8a726af 100644 --- a/buildSrc/src/test/kotlin/com/datadog/gradle/plugin/jsonschema/JsonSchemaReaderTest.kt +++ b/buildSrc/src/test/kotlin/com/datadog/gradle/plugin/jsonschema/JsonSchemaReaderTest.kt @@ -46,26 +46,28 @@ class JsonSchemaReaderTest( @Parameterized.Parameters(name = "{index}: {0}") fun data(): Collection> { return listOf( - arrayOf("minimal", Person), - arrayOf("required", Product), - arrayOf("nested", Book), arrayOf("arrays", Article), - arrayOf("sets", Video), + arrayOf("nested", Book), + arrayOf("additional_props", Comment), + arrayOf("definition_name_conflict", Conflict), arrayOf("definition", Customer), arrayOf("definition_with_id", Customer), - arrayOf("enum", Style), - arrayOf("constant", Location), - arrayOf("constant_number", Version), arrayOf("nested_enum", DateTime), - arrayOf("description", Opus), - arrayOf("top_level_definition", Foo), + arrayOf("external_description", Delivery), arrayOf("types", Demo), + arrayOf("top_level_definition", Foo), + arrayOf("constant", Location), + arrayOf("read_only", Message), + arrayOf("enum_array", Order), + arrayOf("description", Opus), + arrayOf("minimal", Person), + arrayOf("required", Product), + arrayOf("external_nested_description", Shipping), + arrayOf("enum", Style), arrayOf("all_of", User), arrayOf("all_of_merged", UserMerged), - arrayOf("external_description", Delivery), - arrayOf("external_nested_description", Shipping), - arrayOf("definition_name_conflict", Conflict), - arrayOf("read_only", Message) + arrayOf("constant_number", Version), + arrayOf("sets", Video) ) } } diff --git a/buildSrc/src/test/kotlin/com/datadog/gradle/plugin/jsonschema/ModelValidationTest.kt b/buildSrc/src/test/kotlin/com/datadog/gradle/plugin/jsonschema/ModelValidationTest.kt index ebffb61a26..755efea3fc 100644 --- a/buildSrc/src/test/kotlin/com/datadog/gradle/plugin/jsonschema/ModelValidationTest.kt +++ b/buildSrc/src/test/kotlin/com/datadog/gradle/plugin/jsonschema/ModelValidationTest.kt @@ -8,6 +8,7 @@ package com.datadog.gradle.plugin.jsonschema import com.example.forgery.ForgeryConfiguration import fr.xgouchet.elmyr.junit4.ForgeRule +import org.assertj.core.api.Assertions.assertThat import org.everit.json.schema.loader.SchemaLoader import org.json.JSONObject import org.json.JSONTokener @@ -57,6 +58,24 @@ class ModelValidationTest( } } + @Test + fun `validate model serialization and deserialization`() { + val type = Class.forName("com.example.model.$className") + val toJson = type.getMethod("toJson") + val fromJson = type.getMethod("fromJson", String::class.java) + repeat(10) { + val entity = forge.getForgery(type) + val json = toJson.invoke(entity).toString() + val generatedModel = fromJson.invoke(null, json) + assertThat(generatedModel) + .overridingErrorMessage( + "Deserialized model was not the same " + + "with the serialized for type: [$type] and test iteration: [$it]" + ) + .isEqualToComparingFieldByField(entity) + } + } + private fun loadSchema(schemaResName: String): JSONObject { return javaClass.getResourceAsStream("/input/$schemaResName.json").use { JSONObject(JSONTokener(it)) @@ -69,25 +88,28 @@ class ModelValidationTest( @Parameterized.Parameters(name = "{index}: {1}") fun data(): Collection> { return listOf( - arrayOf("minimal", "Person"), - arrayOf("required", "Product"), - arrayOf("nested", "Book"), arrayOf("arrays", "Article"), - arrayOf("sets", "Video"), + arrayOf("nested", "Book"), + arrayOf("additional_props", "Comment"), + arrayOf("definition_name_conflict", "Conflict"), arrayOf("definition", "Customer"), arrayOf("definition_with_id", "Customer"), - arrayOf("enum", "Style"), - arrayOf("constant", "Location"), - arrayOf("constant_number", "Version"), arrayOf("nested_enum", "DateTime"), - arrayOf("description", "Opus"), - arrayOf("top_level_definition", "Foo"), - arrayOf("types", "Demo"), - arrayOf("all_of", "User"), arrayOf("external_description", "Delivery"), + arrayOf("types", "Demo"), + arrayOf("top_level_definition", "Foo"), + arrayOf("constant", "Location"), + arrayOf("read_only", "Message"), + arrayOf("enum_array", "Order"), + arrayOf("description", "Opus"), + arrayOf("minimal", "Person"), + arrayOf("required", "Product"), arrayOf("external_nested_description", "Shipping"), - arrayOf("definition_name_conflict", "Conflict"), - arrayOf("read_only", "Message") + arrayOf("enum", "Style"), + arrayOf("all_of", "User"), + arrayOf("all_of_merged", "UserMerged"), + arrayOf("constant_number", "Version"), + arrayOf("sets", "Video") ) } } diff --git a/buildSrc/src/test/kotlin/com/datadog/gradle/plugin/jsonschema/PokoGeneratorTest.kt b/buildSrc/src/test/kotlin/com/datadog/gradle/plugin/jsonschema/PokoGeneratorTest.kt index 8269941529..a7a9c3fe83 100644 --- a/buildSrc/src/test/kotlin/com/datadog/gradle/plugin/jsonschema/PokoGeneratorTest.kt +++ b/buildSrc/src/test/kotlin/com/datadog/gradle/plugin/jsonschema/PokoGeneratorTest.kt @@ -9,7 +9,6 @@ package com.datadog.gradle.plugin.jsonschema import java.io.File import java.nio.file.Files import java.nio.file.Paths -import org.assertj.core.api.Assertions.assertThat import org.junit.Rule import org.junit.Test import org.junit.rules.TemporaryFolder @@ -43,17 +42,26 @@ class PokoGeneratorTest( ).findFirst().get().toFile() val generatedContent = generatedFile.readText(Charsets.UTF_8) - val outputContent = File(outputPath).readText(Charsets.UTF_8) - assertThat(generatedContent) - .overridingErrorMessage( - "File $outputFile generated from type \n$inputType \ndidn't match expectation:\n" + - "<<<<<<< EXPECTED\n" + - outputContent + - "=======\n" + - generatedContent + - "\n>>>>>>> GENERATED\n" - ) - .isEqualTo(outputContent) + val expectedContent = File(outputPath).readText(Charsets.UTF_8) + + if (generatedContent != expectedContent) { + val genLines = generatedContent.lines() + val expLines = expectedContent.lines() + for (i in 0 until minOf(genLines.size, expLines.size)) { + if (genLines[i] != expLines[i]) { + System.err.println(generatedContent) + throw AssertionError( + "File $outputFile generated from \n$inputType didn't match expectation:\n" + + "First error on line ${i + 1}:\n" + + "<<<<<<< EXPECTED\n" + + expLines[i] + + "\n=======\n" + + genLines[i] + + "\n>>>>>>> GENERATED\n" + ) + } + } + } } companion object { @@ -63,6 +71,7 @@ class PokoGeneratorTest( return listOf( arrayOf(Article, "Article"), arrayOf(Book, "Book"), + arrayOf(Comment, "Comment"), arrayOf(Conflict, "Conflict"), arrayOf(Customer, "Customer"), arrayOf(DateTime, "DateTime"), @@ -72,10 +81,12 @@ class PokoGeneratorTest( arrayOf(Person, "Person"), arrayOf(Location, "Location"), arrayOf(Message, "Message"), + arrayOf(Order, "Order"), arrayOf(Opus, "Opus"), arrayOf(Product, "Product"), arrayOf(Shipping, "Shipping"), arrayOf(Style, "Style"), + arrayOf(Order, "Order"), arrayOf(Version, "Version"), arrayOf(Video, "Video"), arrayOf(User, "User"), diff --git a/buildSrc/src/test/kotlin/com/datadog/gradle/plugin/jsonschema/TestDefinitions.kt b/buildSrc/src/test/kotlin/com/datadog/gradle/plugin/jsonschema/TestDefinitions.kt index c437d22777..5503479eec 100644 --- a/buildSrc/src/test/kotlin/com/datadog/gradle/plugin/jsonschema/TestDefinitions.kt +++ b/buildSrc/src/test/kotlin/com/datadog/gradle/plugin/jsonschema/TestDefinitions.kt @@ -9,24 +9,24 @@ package com.datadog.gradle.plugin.jsonschema val Address = TypeDefinition.Class( name = "Address", properties = listOf( - TypeProperty("street_address", TypeDefinition.Primitive(JsonType.STRING), false), - TypeProperty("city", TypeDefinition.Primitive(JsonType.STRING), false), - TypeProperty("state", TypeDefinition.Primitive(JsonType.STRING), false) + TypeProperty("street_address", TypeDefinition.Primitive(JsonPrimitiveType.STRING), false), + TypeProperty("city", TypeDefinition.Primitive(JsonPrimitiveType.STRING), false), + TypeProperty("state", TypeDefinition.Primitive(JsonPrimitiveType.STRING), false) ) ) val Article = TypeDefinition.Class( name = "Article", properties = listOf( - TypeProperty("title", TypeDefinition.Primitive(JsonType.STRING), false), + TypeProperty("title", TypeDefinition.Primitive(JsonPrimitiveType.STRING), false), TypeProperty( "tags", - TypeDefinition.Array(TypeDefinition.Primitive(JsonType.STRING)), + TypeDefinition.Array(TypeDefinition.Primitive(JsonPrimitiveType.STRING)), true ), TypeProperty( "authors", - TypeDefinition.Array(TypeDefinition.Primitive(JsonType.STRING)), + TypeDefinition.Array(TypeDefinition.Primitive(JsonPrimitiveType.STRING)), false ) ) @@ -35,15 +35,15 @@ val Article = TypeDefinition.Class( val Book = TypeDefinition.Class( name = "Book", properties = listOf( - TypeProperty("bookId", TypeDefinition.Primitive(JsonType.INTEGER), false), - TypeProperty("title", TypeDefinition.Primitive(JsonType.STRING), false), - TypeProperty("price", TypeDefinition.Primitive(JsonType.NUMBER), false), + TypeProperty("bookId", TypeDefinition.Primitive(JsonPrimitiveType.INTEGER), false), + TypeProperty("title", TypeDefinition.Primitive(JsonPrimitiveType.STRING), false), + TypeProperty("price", TypeDefinition.Primitive(JsonPrimitiveType.DOUBLE), false), TypeProperty( "author", TypeDefinition.Class( name = "Author", properties = listOf( - TypeProperty("firstName", TypeDefinition.Primitive(JsonType.STRING), false), - TypeProperty("lastName", TypeDefinition.Primitive(JsonType.STRING), false), + TypeProperty("firstName", TypeDefinition.Primitive(JsonPrimitiveType.STRING), false), + TypeProperty("lastName", TypeDefinition.Primitive(JsonPrimitiveType.STRING), false), TypeProperty( "contact", TypeDefinition.Class( @@ -51,12 +51,12 @@ val Book = TypeDefinition.Class( properties = listOf( TypeProperty( "phone", - TypeDefinition.Primitive(JsonType.STRING), + TypeDefinition.Primitive(JsonPrimitiveType.STRING), true ), TypeProperty( "email", - TypeDefinition.Primitive(JsonType.STRING), + TypeDefinition.Primitive(JsonPrimitiveType.STRING), true ) ) @@ -73,12 +73,39 @@ val Book = TypeDefinition.Class( val Customer = TypeDefinition.Class( name = "Customer", properties = listOf( - TypeProperty("name", TypeDefinition.Primitive(JsonType.STRING), true), + TypeProperty("name", TypeDefinition.Primitive(JsonPrimitiveType.STRING), true), TypeProperty("billing_address", Address, true), TypeProperty("shipping_address", Address, true) ) ) +val Comment = TypeDefinition.Class( + name = "Comment", + properties = listOf( + TypeProperty("message", TypeDefinition.Primitive(JsonPrimitiveType.STRING), true), + TypeProperty( + "ratings", + TypeDefinition.Class( + name = "Ratings", + properties = listOf( + TypeProperty("global", TypeDefinition.Primitive(JsonPrimitiveType.INTEGER), false) + ), + additionalProperties = TypeDefinition.Primitive(JsonPrimitiveType.INTEGER) + ), + true + ), + TypeProperty( + "flags", + TypeDefinition.Class( + name = "Flags", + properties = listOf(), + additionalProperties = TypeDefinition.Primitive(JsonPrimitiveType.STRING) + ), + true + ) + ) +) + val Conflict = TypeDefinition.Class( name = "Conflict", properties = listOf( @@ -87,7 +114,7 @@ val Conflict = TypeDefinition.Class( TypeDefinition.Class( name = "ConflictType", properties = listOf( - TypeProperty("id", TypeDefinition.Primitive(JsonType.STRING), true) + TypeProperty("id", TypeDefinition.Primitive(JsonPrimitiveType.STRING), true) ) ), true @@ -97,7 +124,7 @@ val Conflict = TypeDefinition.Class( TypeDefinition.Class( name = "User", properties = listOf( - TypeProperty("name", TypeDefinition.Primitive(JsonType.STRING), true), + TypeProperty("name", TypeDefinition.Primitive(JsonPrimitiveType.STRING), true), TypeProperty( "type", TypeDefinition.Enum( @@ -122,7 +149,7 @@ val DateTime = TypeDefinition.Class( TypeDefinition.Class( name = "Date", properties = listOf( - TypeProperty("year", TypeDefinition.Primitive(JsonType.INTEGER), true), + TypeProperty("year", TypeDefinition.Primitive(JsonPrimitiveType.INTEGER), true), TypeProperty( "month", TypeDefinition.Enum( "Month", @@ -133,7 +160,7 @@ val DateTime = TypeDefinition.Class( ) ), true ), - TypeProperty("day", TypeDefinition.Primitive(JsonType.INTEGER), true) + TypeProperty("day", TypeDefinition.Primitive(JsonPrimitiveType.INTEGER), true) ) ), true @@ -143,9 +170,9 @@ val DateTime = TypeDefinition.Class( TypeDefinition.Class( name = "Time", properties = listOf( - TypeProperty("hour", TypeDefinition.Primitive(JsonType.INTEGER), true), - TypeProperty("minute", TypeDefinition.Primitive(JsonType.INTEGER), true), - TypeProperty("seconds", TypeDefinition.Primitive(JsonType.INTEGER), true) + TypeProperty("hour", TypeDefinition.Primitive(JsonPrimitiveType.INTEGER), true), + TypeProperty("minute", TypeDefinition.Primitive(JsonPrimitiveType.INTEGER), true), + TypeProperty("seconds", TypeDefinition.Primitive(JsonPrimitiveType.INTEGER), true) ) ), true @@ -155,15 +182,15 @@ val DateTime = TypeDefinition.Class( val Demo = TypeDefinition.Class( name = "Demo", properties = listOf( - TypeProperty("s", TypeDefinition.Primitive(JsonType.STRING), false), - TypeProperty("i", TypeDefinition.Primitive(JsonType.INTEGER), false), - TypeProperty("n", TypeDefinition.Primitive(JsonType.NUMBER), false), - TypeProperty("b", TypeDefinition.Primitive(JsonType.BOOLEAN), false), + TypeProperty("s", TypeDefinition.Primitive(JsonPrimitiveType.STRING), false), + TypeProperty("i", TypeDefinition.Primitive(JsonPrimitiveType.INTEGER), false), + TypeProperty("n", TypeDefinition.Primitive(JsonPrimitiveType.DOUBLE), false), + TypeProperty("b", TypeDefinition.Primitive(JsonPrimitiveType.BOOLEAN), false), TypeProperty("l", TypeDefinition.Null(), false), - TypeProperty("ns", TypeDefinition.Primitive(JsonType.STRING), true), - TypeProperty("ni", TypeDefinition.Primitive(JsonType.INTEGER), true), - TypeProperty("nn", TypeDefinition.Primitive(JsonType.NUMBER), true), - TypeProperty("nb", TypeDefinition.Primitive(JsonType.BOOLEAN), true), + TypeProperty("ns", TypeDefinition.Primitive(JsonPrimitiveType.STRING), true), + TypeProperty("ni", TypeDefinition.Primitive(JsonPrimitiveType.INTEGER), true), + TypeProperty("nn", TypeDefinition.Primitive(JsonPrimitiveType.DOUBLE), true), + TypeProperty("nb", TypeDefinition.Primitive(JsonPrimitiveType.BOOLEAN), true), TypeProperty("nl", TypeDefinition.Null(), true) ) @@ -172,13 +199,13 @@ val Demo = TypeDefinition.Class( val Delivery = TypeDefinition.Class( name = "Delivery", properties = listOf( - TypeProperty("item", TypeDefinition.Primitive(JsonType.STRING), false), + TypeProperty("item", TypeDefinition.Primitive(JsonPrimitiveType.STRING), false), TypeProperty( "customer", TypeDefinition.Class( name = "Customer", properties = listOf( - TypeProperty("name", TypeDefinition.Primitive(JsonType.STRING), true), + TypeProperty("name", TypeDefinition.Primitive(JsonPrimitiveType.STRING), true), TypeProperty("billing_address", Address, true), TypeProperty("shipping_address", Address, true) ) @@ -191,8 +218,8 @@ val Delivery = TypeDefinition.Class( val Foo = TypeDefinition.Class( name = "Foo", properties = listOf( - TypeProperty("bar", TypeDefinition.Primitive(JsonType.STRING), true), - TypeProperty("baz", TypeDefinition.Primitive(JsonType.INTEGER), true) + TypeProperty("bar", TypeDefinition.Primitive(JsonPrimitiveType.STRING), true), + TypeProperty("baz", TypeDefinition.Primitive(JsonPrimitiveType.INTEGER), true) ) ) @@ -208,43 +235,43 @@ val Message = TypeDefinition.Class( properties = listOf( TypeProperty( "destination", - TypeDefinition.Array(TypeDefinition.Primitive(JsonType.STRING)), + TypeDefinition.Array(TypeDefinition.Primitive(JsonPrimitiveType.STRING)), optional = false, readOnly = true ), TypeProperty( "origin", - TypeDefinition.Primitive(JsonType.STRING), + TypeDefinition.Primitive(JsonPrimitiveType.STRING), optional = false, readOnly = true ), TypeProperty( "subject", - TypeDefinition.Primitive(JsonType.STRING), + TypeDefinition.Primitive(JsonPrimitiveType.STRING), optional = true, readOnly = true ), TypeProperty( "message", - TypeDefinition.Primitive(JsonType.STRING), + TypeDefinition.Primitive(JsonPrimitiveType.STRING), optional = true, readOnly = true ), TypeProperty( "labels", - TypeDefinition.Array(TypeDefinition.Primitive(JsonType.STRING)), + TypeDefinition.Array(TypeDefinition.Primitive(JsonPrimitiveType.STRING)), optional = true, readOnly = false ), TypeProperty( "read", - TypeDefinition.Primitive(JsonType.BOOLEAN), + TypeDefinition.Primitive(JsonPrimitiveType.BOOLEAN), optional = true, readOnly = false ), TypeProperty( "important", - TypeDefinition.Primitive(JsonType.BOOLEAN), + TypeDefinition.Primitive(JsonPrimitiveType.BOOLEAN), optional = true, readOnly = false ) @@ -257,12 +284,12 @@ val Opus = TypeDefinition.Class( properties = listOf( TypeProperty( "title", - TypeDefinition.Primitive(JsonType.STRING, "The opus's title."), + TypeDefinition.Primitive(JsonPrimitiveType.STRING, "The opus's title."), true ), TypeProperty( "composer", - TypeDefinition.Primitive(JsonType.STRING, "The opus's composer."), + TypeDefinition.Primitive(JsonPrimitiveType.STRING, "The opus's composer."), true ), TypeProperty( @@ -274,7 +301,7 @@ val Opus = TypeDefinition.Class( properties = listOf( TypeProperty( "name", - TypeDefinition.Primitive(JsonType.STRING, "The artist's name."), + TypeDefinition.Primitive(JsonPrimitiveType.STRING, "The artist's name."), true ), TypeProperty( @@ -298,7 +325,7 @@ val Opus = TypeDefinition.Class( ), TypeProperty( "duration", - TypeDefinition.Primitive(JsonType.INTEGER, "The opus's duration in seconds"), + TypeDefinition.Primitive(JsonPrimitiveType.INTEGER, "The opus's duration in seconds"), true ) ) @@ -307,25 +334,25 @@ val Opus = TypeDefinition.Class( val Person = TypeDefinition.Class( name = "Person", properties = listOf( - TypeProperty("firstName", TypeDefinition.Primitive(JsonType.STRING), true), - TypeProperty("lastName", TypeDefinition.Primitive(JsonType.STRING), true), - TypeProperty("age", TypeDefinition.Primitive(JsonType.INTEGER), true) + TypeProperty("firstName", TypeDefinition.Primitive(JsonPrimitiveType.STRING), true), + TypeProperty("lastName", TypeDefinition.Primitive(JsonPrimitiveType.STRING), true), + TypeProperty("age", TypeDefinition.Primitive(JsonPrimitiveType.INTEGER), true) ) ) val Product = TypeDefinition.Class( name = "Product", properties = listOf( - TypeProperty("productId", TypeDefinition.Primitive(JsonType.INTEGER), false), - TypeProperty("productName", TypeDefinition.Primitive(JsonType.STRING), false), - TypeProperty("price", TypeDefinition.Primitive(JsonType.NUMBER), false) + TypeProperty("productId", TypeDefinition.Primitive(JsonPrimitiveType.INTEGER), false), + TypeProperty("productName", TypeDefinition.Primitive(JsonPrimitiveType.STRING), false), + TypeProperty("price", TypeDefinition.Primitive(JsonPrimitiveType.DOUBLE), false) ) ) val Shipping = TypeDefinition.Class( name = "Shipping", properties = listOf( - TypeProperty("item", TypeDefinition.Primitive(JsonType.STRING), false), + TypeProperty("item", TypeDefinition.Primitive(JsonPrimitiveType.STRING), false), TypeProperty("destination", Address, false) ) ) @@ -345,13 +372,31 @@ val Style = TypeDefinition.Class( ) ) +val Order = TypeDefinition.Class( + name = "Order", + properties = listOf( + TypeProperty( + "sizes", + TypeDefinition.Array( + TypeDefinition.Enum( + "Size", + JsonType.STRING, + listOf("x small", "small", "medium", "large", "x large") + ), + uniqueItems = true + ), + false + ) + ) +) + val User = TypeDefinition.Class( name = "User", properties = listOf( - TypeProperty("username", TypeDefinition.Primitive(JsonType.STRING), false), - TypeProperty("host", TypeDefinition.Primitive(JsonType.STRING), false), - TypeProperty("firstname", TypeDefinition.Primitive(JsonType.STRING), true), - TypeProperty("lastname", TypeDefinition.Primitive(JsonType.STRING), false), + TypeProperty("username", TypeDefinition.Primitive(JsonPrimitiveType.STRING), false), + TypeProperty("host", TypeDefinition.Primitive(JsonPrimitiveType.STRING), false), + TypeProperty("firstname", TypeDefinition.Primitive(JsonPrimitiveType.STRING), true), + TypeProperty("lastname", TypeDefinition.Primitive(JsonPrimitiveType.STRING), false), TypeProperty( "contact_type", TypeDefinition.Enum( @@ -367,21 +412,21 @@ val User = TypeDefinition.Class( val UserMerged = TypeDefinition.Class( name = "UserMerged", properties = listOf( - TypeProperty("email", TypeDefinition.Primitive(JsonType.STRING), true), - TypeProperty("phone", TypeDefinition.Primitive(JsonType.STRING), true), + TypeProperty("email", TypeDefinition.Primitive(JsonPrimitiveType.STRING), true), + TypeProperty("phone", TypeDefinition.Primitive(JsonPrimitiveType.STRING), true), TypeProperty( "info", TypeDefinition.Class( name = "Info", properties = listOf( - TypeProperty("notes", TypeDefinition.Primitive(JsonType.STRING), true), - TypeProperty("source", TypeDefinition.Primitive(JsonType.STRING), true) + TypeProperty("notes", TypeDefinition.Primitive(JsonPrimitiveType.STRING), true), + TypeProperty("source", TypeDefinition.Primitive(JsonPrimitiveType.STRING), true) ) ), true ), - TypeProperty("firstname", TypeDefinition.Primitive(JsonType.STRING), true), - TypeProperty("lastname", TypeDefinition.Primitive(JsonType.STRING), false) + TypeProperty("firstname", TypeDefinition.Primitive(JsonPrimitiveType.STRING), true), + TypeProperty("lastname", TypeDefinition.Primitive(JsonPrimitiveType.STRING), false) ) ) @@ -396,15 +441,15 @@ val Version = TypeDefinition.Class( val Video = TypeDefinition.Class( name = "Video", properties = listOf( - TypeProperty("title", TypeDefinition.Primitive(JsonType.STRING), false), + TypeProperty("title", TypeDefinition.Primitive(JsonPrimitiveType.STRING), false), TypeProperty( "tags", - TypeDefinition.Array(TypeDefinition.Primitive(JsonType.STRING), uniqueItems = true), + TypeDefinition.Array(TypeDefinition.Primitive(JsonPrimitiveType.STRING), uniqueItems = true), true ), TypeProperty( "links", - TypeDefinition.Array(TypeDefinition.Primitive(JsonType.STRING), uniqueItems = true), + TypeDefinition.Array(TypeDefinition.Primitive(JsonPrimitiveType.STRING), uniqueItems = true), true ) ) diff --git a/buildSrc/src/test/kotlin/com/example/forgery/ArticleForgeryFactory.kt b/buildSrc/src/test/kotlin/com/example/forgery/ArticleForgeryFactory.kt index ce8ff6b772..292e55c1bd 100644 --- a/buildSrc/src/test/kotlin/com/example/forgery/ArticleForgeryFactory.kt +++ b/buildSrc/src/test/kotlin/com/example/forgery/ArticleForgeryFactory.kt @@ -5,7 +5,6 @@ */ package com.example.forgery - import com.example.model.Article import fr.xgouchet.elmyr.Forge import fr.xgouchet.elmyr.ForgeryFactory diff --git a/buildSrc/src/test/kotlin/com/example/forgery/CommentForgeryFactory.kt b/buildSrc/src/test/kotlin/com/example/forgery/CommentForgeryFactory.kt new file mode 100644 index 0000000000..120a443bb4 --- /dev/null +++ b/buildSrc/src/test/kotlin/com/example/forgery/CommentForgeryFactory.kt @@ -0,0 +1,30 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.example.forgery + +import com.example.model.Comment +import fr.xgouchet.elmyr.Forge +import fr.xgouchet.elmyr.ForgeryFactory + +internal class CommentForgeryFactory : ForgeryFactory { + override fun getForgery(forge: Forge): Comment { + return Comment( + message = forge.aNullable { forge.anAlphabeticalString() }, + ratings = forge.aNullable { + Comment.Ratings( + global = aLong(), + additionalProperties = aMap { anAlphabeticalString() to aLong() } + ) + }, + flags = forge.aNullable { + Comment.Flags( + additionalProperties = aMap { anAlphabeticalString() to anHexadecimalString() } + ) + } + ) + } +} diff --git a/buildSrc/src/test/kotlin/com/example/forgery/ForgeryConfiguration.kt b/buildSrc/src/test/kotlin/com/example/forgery/ForgeryConfiguration.kt index f80b19a0d0..90b1f00529 100644 --- a/buildSrc/src/test/kotlin/com/example/forgery/ForgeryConfiguration.kt +++ b/buildSrc/src/test/kotlin/com/example/forgery/ForgeryConfiguration.kt @@ -14,6 +14,7 @@ internal class ForgeryConfiguration : ForgeConfigurator { forge.addFactory(ArticleForgeryFactory()) forge.addFactory(BookForgeryFactory()) forge.addFactory(ConflictForgeryFactory()) + forge.addFactory(CommentForgeryFactory()) forge.addFactory(CustomerForgeryFactory()) forge.addFactory(DateTimeForgeryFactory()) forge.addFactory(DeliveryForgeryFactory()) @@ -22,9 +23,11 @@ internal class ForgeryConfiguration : ForgeConfigurator { forge.addFactory(LocationForgeryFactory()) forge.addFactory(MessageForgeryFactory()) forge.addFactory(OpusForgeryFactory()) + forge.addFactory(OrderForgeryFactory()) forge.addFactory(PersonForgeryFactory()) forge.addFactory(ProductForgeryFactory()) forge.addFactory(UserForgeryFactory()) + forge.addFactory(UserMergedForgeryFactory()) forge.addFactory(ShippingForgeryFactory()) forge.addFactory(StyleForgeryFactory()) forge.addFactory(VersionForgeryFactory()) diff --git a/buildSrc/src/test/kotlin/com/example/forgery/OrderForgeryFactory.kt b/buildSrc/src/test/kotlin/com/example/forgery/OrderForgeryFactory.kt new file mode 100644 index 0000000000..5b16ebf7b9 --- /dev/null +++ b/buildSrc/src/test/kotlin/com/example/forgery/OrderForgeryFactory.kt @@ -0,0 +1,20 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.example.forgery + +import com.example.model.Order +import fr.xgouchet.elmyr.Forge +import fr.xgouchet.elmyr.ForgeryFactory + +internal class OrderForgeryFactory : ForgeryFactory { + + override fun getForgery(forge: Forge): Order { + return Order( + sizes = forge.aList { forge.aValueFrom(Order.Size::class.java) }.toSet() + ) + } +} diff --git a/buildSrc/src/test/kotlin/com/example/forgery/UserMergedForgeryFactory.kt b/buildSrc/src/test/kotlin/com/example/forgery/UserMergedForgeryFactory.kt new file mode 100644 index 0000000000..2023ff78c4 --- /dev/null +++ b/buildSrc/src/test/kotlin/com/example/forgery/UserMergedForgeryFactory.kt @@ -0,0 +1,28 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.example.forgery + +import com.example.model.UserMerged +import fr.xgouchet.elmyr.Forge +import fr.xgouchet.elmyr.ForgeryFactory + +internal class UserMergedForgeryFactory : ForgeryFactory { + override fun getForgery(forge: Forge): UserMerged { + return UserMerged( + email = forge.aNullable { aStringMatching("\\w+@[a-z]+\\.[a-z]{3}") }, + phone = forge.aNullable { aStringMatching("\\d{3,8}") }, + info = forge.aNullable { + UserMerged.Info( + notes = forge.aNullable { anAlphabeticalString() }, + source = forge.aNullable { anAlphabeticalString() } + ) + }, + firstname = forge.aNullable { anAlphabeticalString() }, + lastname = forge.anAlphabeticalString() + ) + } +} diff --git a/buildSrc/src/test/kotlin/com/example/model/Article.kt b/buildSrc/src/test/kotlin/com/example/model/Article.kt index 187ce9072b..9c4c489b1a 100644 --- a/buildSrc/src/test/kotlin/com/example/model/Article.kt +++ b/buildSrc/src/test/kotlin/com/example/model/Article.kt @@ -3,8 +3,15 @@ package com.example.model import com.google.gson.JsonArray import com.google.gson.JsonElement import com.google.gson.JsonObject +import com.google.gson.JsonParseException +import com.google.gson.JsonParser +import java.lang.IllegalStateException +import java.lang.NumberFormatException import kotlin.String +import kotlin.collections.ArrayList import kotlin.collections.List +import kotlin.jvm.JvmStatic +import kotlin.jvm.Throws data class Article( val title: String, @@ -24,4 +31,34 @@ data class Article( json.add("authors", authorsArray) return json } + + companion object { + @JvmStatic + @Throws(JsonParseException::class) + fun fromJson(serializedObject: String): Article { + try { + val jsonObject = JsonParser.parseString(serializedObject).asJsonObject + val title = jsonObject.get("title").asString + val tags = jsonObject.get("tags")?.asJsonArray?.let { jsonArray -> + val collection = ArrayList(jsonArray.size()) + jsonArray.forEach { + collection.add(it.asString) + } + collection + } + val authors = jsonObject.get("authors").asJsonArray.let { jsonArray -> + val collection = ArrayList(jsonArray.size()) + jsonArray.forEach { + collection.add(it.asString) + } + collection + } + return Article(title, tags, authors) + } catch (e: IllegalStateException) { + throw JsonParseException(e.message) + } catch (e: NumberFormatException) { + throw JsonParseException(e.message) + } + } + } } diff --git a/buildSrc/src/test/kotlin/com/example/model/Book.kt b/buildSrc/src/test/kotlin/com/example/model/Book.kt index dbb0f07c31..12ed518885 100644 --- a/buildSrc/src/test/kotlin/com/example/model/Book.kt +++ b/buildSrc/src/test/kotlin/com/example/model/Book.kt @@ -2,9 +2,15 @@ package com.example.model import com.google.gson.JsonElement import com.google.gson.JsonObject +import com.google.gson.JsonParseException +import com.google.gson.JsonParser +import java.lang.IllegalStateException +import java.lang.NumberFormatException import kotlin.Double import kotlin.Long import kotlin.String +import kotlin.jvm.JvmStatic +import kotlin.jvm.Throws data class Book( val bookId: Long, @@ -21,6 +27,27 @@ data class Book( return json } + companion object { + @JvmStatic + @Throws(JsonParseException::class) + fun fromJson(serializedObject: String): Book { + try { + val jsonObject = JsonParser.parseString(serializedObject).asJsonObject + val bookId = jsonObject.get("bookId").asLong + val title = jsonObject.get("title").asString + val price = jsonObject.get("price").asDouble + val author = jsonObject.get("author").toString().let { + Author.fromJson(it) + } + return Book(bookId, title, price, author) + } catch (e: IllegalStateException) { + throw JsonParseException(e.message) + } catch (e: NumberFormatException) { + throw JsonParseException(e.message) + } + } + } + data class Author( val firstName: String, val lastName: String, @@ -33,6 +60,26 @@ data class Book( json.add("contact", contact.toJson()) return json } + + companion object { + @JvmStatic + @Throws(JsonParseException::class) + fun fromJson(serializedObject: String): Author { + try { + val jsonObject = JsonParser.parseString(serializedObject).asJsonObject + val firstName = jsonObject.get("firstName").asString + val lastName = jsonObject.get("lastName").asString + val contact = jsonObject.get("contact").toString().let { + Contact.fromJson(it) + } + return Author(firstName, lastName, contact) + } catch (e: IllegalStateException) { + throw JsonParseException(e.message) + } catch (e: NumberFormatException) { + throw JsonParseException(e.message) + } + } + } } data class Contact( @@ -45,5 +92,22 @@ data class Book( email?.let { json.addProperty("email", it) } return json } + + companion object { + @JvmStatic + @Throws(JsonParseException::class) + fun fromJson(serializedObject: String): Contact { + try { + val jsonObject = JsonParser.parseString(serializedObject).asJsonObject + val phone = jsonObject.get("phone")?.asString + val email = jsonObject.get("email")?.asString + return Contact(phone, email) + } catch (e: IllegalStateException) { + throw JsonParseException(e.message) + } catch (e: NumberFormatException) { + throw JsonParseException(e.message) + } + } + } } } diff --git a/buildSrc/src/test/kotlin/com/example/model/Comment.kt b/buildSrc/src/test/kotlin/com/example/model/Comment.kt new file mode 100644 index 0000000000..80eabcb665 --- /dev/null +++ b/buildSrc/src/test/kotlin/com/example/model/Comment.kt @@ -0,0 +1,119 @@ +package com.example.model + +import com.google.gson.JsonElement +import com.google.gson.JsonObject +import com.google.gson.JsonParseException +import com.google.gson.JsonParser +import java.lang.IllegalStateException +import java.lang.NumberFormatException +import kotlin.Array +import kotlin.Long +import kotlin.String +import kotlin.collections.Map +import kotlin.jvm.JvmStatic +import kotlin.jvm.Throws + +data class Comment( + val message: String? = null, + val ratings: Ratings? = null, + val flags: Flags? = null +) { + fun toJson(): JsonElement { + val json = JsonObject() + message?.let { json.addProperty("message", it) } + ratings?.let { json.add("ratings", it.toJson()) } + flags?.let { json.add("flags", it.toJson()) } + return json + } + + companion object { + @JvmStatic + @Throws(JsonParseException::class) + fun fromJson(serializedObject: String): Comment { + try { + val jsonObject = JsonParser.parseString(serializedObject).asJsonObject + val message = jsonObject.get("message")?.asString + val ratings = jsonObject.get("ratings")?.toString()?.let { + Ratings.fromJson(it) + } + val flags = jsonObject.get("flags")?.toString()?.let { + Flags.fromJson(it) + } + return Comment(message, ratings, flags) + } catch (e: IllegalStateException) { + throw JsonParseException(e.message) + } catch (e: NumberFormatException) { + throw JsonParseException(e.message) + } + } + } + + data class Ratings( + val global: Long, + val additionalProperties: Map = emptyMap() + ) { + fun toJson(): JsonElement { + val json = JsonObject() + json.addProperty("global", global) + additionalProperties.forEach { (k, v) -> + json.addProperty(k, v) + } + return json + } + + companion object { + private val RESERVED_PROPERTIES: Array = arrayOf("global") + + @JvmStatic + @Throws(JsonParseException::class) + fun fromJson(serializedObject: String): Ratings { + try { + val jsonObject = JsonParser.parseString(serializedObject).asJsonObject + val global = jsonObject.get("global").asLong + val additionalProperties = mutableMapOf() + for (entry in jsonObject.entrySet()) { + if (entry.key !in RESERVED_PROPERTIES) { + additionalProperties[entry.key] = entry.value.asLong + } + } + return Ratings(global, additionalProperties) + } catch (e: IllegalStateException) { + throw JsonParseException(e.message) + } catch (e: NumberFormatException) { + throw JsonParseException(e.message) + } + } + } + } + + data class Flags( + val additionalProperties: Map = emptyMap() + ) { + fun toJson(): JsonElement { + val json = JsonObject() + additionalProperties.forEach { (k, v) -> + json.addProperty(k, v) + } + return json + } + + companion object { + @JvmStatic + @Throws(JsonParseException::class) + fun fromJson(serializedObject: String): Flags { + try { + val jsonObject = JsonParser.parseString(serializedObject).asJsonObject + val additionalProperties = mutableMapOf() + for (entry in jsonObject.entrySet()) { + additionalProperties[entry.key] = entry.value.asString + } + return Flags(additionalProperties) + } catch (e: IllegalStateException) { + throw JsonParseException(e.message) + } catch (e: NumberFormatException) { + throw JsonParseException(e.message) + } + } + } + } +} diff --git a/buildSrc/src/test/kotlin/com/example/model/Conflict.kt b/buildSrc/src/test/kotlin/com/example/model/Conflict.kt index 4a3548b801..29b730aef6 100644 --- a/buildSrc/src/test/kotlin/com/example/model/Conflict.kt +++ b/buildSrc/src/test/kotlin/com/example/model/Conflict.kt @@ -2,8 +2,14 @@ package com.example.model import com.google.gson.JsonElement import com.google.gson.JsonObject +import com.google.gson.JsonParseException +import com.google.gson.JsonParser import com.google.gson.JsonPrimitive +import java.lang.IllegalStateException +import java.lang.NumberFormatException import kotlin.String +import kotlin.jvm.JvmStatic +import kotlin.jvm.Throws data class Conflict( val type: ConflictType? = null, @@ -16,6 +22,27 @@ data class Conflict( return json } + companion object { + @JvmStatic + @Throws(JsonParseException::class) + fun fromJson(serializedObject: String): Conflict { + try { + val jsonObject = JsonParser.parseString(serializedObject).asJsonObject + val type = jsonObject.get("type")?.toString()?.let { + ConflictType.fromJson(it) + } + val user = jsonObject.get("user")?.toString()?.let { + User.fromJson(it) + } + return Conflict(type, user) + } catch (e: IllegalStateException) { + throw JsonParseException(e.message) + } catch (e: NumberFormatException) { + throw JsonParseException(e.message) + } + } + } + data class ConflictType( val id: String? = null ) { @@ -24,6 +51,22 @@ data class Conflict( id?.let { json.addProperty("id", it) } return json } + + companion object { + @JvmStatic + @Throws(JsonParseException::class) + fun fromJson(serializedObject: String): ConflictType { + try { + val jsonObject = JsonParser.parseString(serializedObject).asJsonObject + val id = jsonObject.get("id")?.asString + return ConflictType(id) + } catch (e: IllegalStateException) { + throw JsonParseException(e.message) + } catch (e: NumberFormatException) { + throw JsonParseException(e.message) + } + } + } } data class User( @@ -36,19 +79,42 @@ data class Conflict( type?.let { json.add("type", it.toJson()) } return json } + + companion object { + @JvmStatic + @Throws(JsonParseException::class) + fun fromJson(serializedObject: String): User { + try { + val jsonObject = JsonParser.parseString(serializedObject).asJsonObject + val name = jsonObject.get("name")?.asString + val type = jsonObject.get("type")?.asString?.let { + UserType.fromJson(it) + } + return User(name, type) + } catch (e: IllegalStateException) { + throw JsonParseException(e.message) + } catch (e: NumberFormatException) { + throw JsonParseException(e.message) + } + } + } } - enum class UserType { - UNKNOWN, + enum class UserType( + private val jsonValue: String + ) { + UNKNOWN("unknown"), + + CUSTOMER("customer"), - CUSTOMER, + PARTNER("partner"); - PARTNER; + fun toJson(): JsonElement = JsonPrimitive(jsonValue) - fun toJson(): JsonElement = when (this) { - UNKNOWN -> JsonPrimitive("unknown") - CUSTOMER -> JsonPrimitive("customer") - PARTNER -> JsonPrimitive("partner") + companion object { + @JvmStatic + fun fromJson(serializedObject: String): UserType = values().first { it.jsonValue == + serializedObject } } } } diff --git a/buildSrc/src/test/kotlin/com/example/model/Customer.kt b/buildSrc/src/test/kotlin/com/example/model/Customer.kt index 3ab0e79834..050ec6a0e2 100644 --- a/buildSrc/src/test/kotlin/com/example/model/Customer.kt +++ b/buildSrc/src/test/kotlin/com/example/model/Customer.kt @@ -2,7 +2,13 @@ package com.example.model import com.google.gson.JsonElement import com.google.gson.JsonObject +import com.google.gson.JsonParseException +import com.google.gson.JsonParser +import java.lang.IllegalStateException +import java.lang.NumberFormatException import kotlin.String +import kotlin.jvm.JvmStatic +import kotlin.jvm.Throws data class Customer( val name: String? = null, @@ -17,6 +23,28 @@ data class Customer( return json } + companion object { + @JvmStatic + @Throws(JsonParseException::class) + fun fromJson(serializedObject: String): Customer { + try { + val jsonObject = JsonParser.parseString(serializedObject).asJsonObject + val name = jsonObject.get("name")?.asString + val billingAddress = jsonObject.get("billing_address")?.toString()?.let { + Address.fromJson(it) + } + val shippingAddress = jsonObject.get("shipping_address")?.toString()?.let { + Address.fromJson(it) + } + return Customer(name, billingAddress, shippingAddress) + } catch (e: IllegalStateException) { + throw JsonParseException(e.message) + } catch (e: NumberFormatException) { + throw JsonParseException(e.message) + } + } + } + data class Address( val streetAddress: String, val city: String, @@ -29,5 +57,23 @@ data class Customer( json.addProperty("state", state) return json } + + companion object { + @JvmStatic + @Throws(JsonParseException::class) + fun fromJson(serializedObject: String): Address { + try { + val jsonObject = JsonParser.parseString(serializedObject).asJsonObject + val streetAddress = jsonObject.get("street_address").asString + val city = jsonObject.get("city").asString + val state = jsonObject.get("state").asString + return Address(streetAddress, city, state) + } catch (e: IllegalStateException) { + throw JsonParseException(e.message) + } catch (e: NumberFormatException) { + throw JsonParseException(e.message) + } + } + } } } diff --git a/buildSrc/src/test/kotlin/com/example/model/DateTime.kt b/buildSrc/src/test/kotlin/com/example/model/DateTime.kt index 5a6a64eb10..fc9b1106b8 100644 --- a/buildSrc/src/test/kotlin/com/example/model/DateTime.kt +++ b/buildSrc/src/test/kotlin/com/example/model/DateTime.kt @@ -2,8 +2,15 @@ package com.example.model import com.google.gson.JsonElement import com.google.gson.JsonObject +import com.google.gson.JsonParseException +import com.google.gson.JsonParser import com.google.gson.JsonPrimitive +import java.lang.IllegalStateException +import java.lang.NumberFormatException import kotlin.Long +import kotlin.String +import kotlin.jvm.JvmStatic +import kotlin.jvm.Throws data class DateTime( val date: Date? = null, @@ -16,6 +23,27 @@ data class DateTime( return json } + companion object { + @JvmStatic + @Throws(JsonParseException::class) + fun fromJson(serializedObject: String): DateTime { + try { + val jsonObject = JsonParser.parseString(serializedObject).asJsonObject + val date = jsonObject.get("date")?.toString()?.let { + Date.fromJson(it) + } + val time = jsonObject.get("time")?.toString()?.let { + Time.fromJson(it) + } + return DateTime(date, time) + } catch (e: IllegalStateException) { + throw JsonParseException(e.message) + } catch (e: NumberFormatException) { + throw JsonParseException(e.message) + } + } + } + data class Date( val year: Long? = null, val month: Month? = null, @@ -28,6 +56,26 @@ data class DateTime( day?.let { json.addProperty("day", it) } return json } + + companion object { + @JvmStatic + @Throws(JsonParseException::class) + fun fromJson(serializedObject: String): Date { + try { + val jsonObject = JsonParser.parseString(serializedObject).asJsonObject + val year = jsonObject.get("year")?.asLong + val month = jsonObject.get("month")?.asString?.let { + Month.fromJson(it) + } + val day = jsonObject.get("day")?.asLong + return Date(year, month, day) + } catch (e: IllegalStateException) { + throw JsonParseException(e.message) + } catch (e: NumberFormatException) { + throw JsonParseException(e.message) + } + } + } } data class Time( @@ -42,46 +90,59 @@ data class DateTime( seconds?.let { json.addProperty("seconds", it) } return json } + + companion object { + @JvmStatic + @Throws(JsonParseException::class) + fun fromJson(serializedObject: String): Time { + try { + val jsonObject = JsonParser.parseString(serializedObject).asJsonObject + val hour = jsonObject.get("hour")?.asLong + val minute = jsonObject.get("minute")?.asLong + val seconds = jsonObject.get("seconds")?.asLong + return Time(hour, minute, seconds) + } catch (e: IllegalStateException) { + throw JsonParseException(e.message) + } catch (e: NumberFormatException) { + throw JsonParseException(e.message) + } + } + } } - enum class Month { - JAN, + enum class Month( + private val jsonValue: String + ) { + JAN("jan"), + + FEB("feb"), - FEB, + MAR("mar"), - MAR, + APR("apr"), - APR, + MAY("may"), - MAY, + JUN("jun"), - JUN, + JUL("jul"), - JUL, + AUG("aug"), - AUG, + SEP("sep"), - SEP, + OCT("oct"), - OCT, + NOV("nov"), - NOV, + DEC("dec"); - DEC; + fun toJson(): JsonElement = JsonPrimitive(jsonValue) - fun toJson(): JsonElement = when (this) { - JAN -> JsonPrimitive("jan") - FEB -> JsonPrimitive("feb") - MAR -> JsonPrimitive("mar") - APR -> JsonPrimitive("apr") - MAY -> JsonPrimitive("may") - JUN -> JsonPrimitive("jun") - JUL -> JsonPrimitive("jul") - AUG -> JsonPrimitive("aug") - SEP -> JsonPrimitive("sep") - OCT -> JsonPrimitive("oct") - NOV -> JsonPrimitive("nov") - DEC -> JsonPrimitive("dec") + companion object { + @JvmStatic + fun fromJson(serializedObject: String): Month = values().first { it.jsonValue == + serializedObject } } } } diff --git a/buildSrc/src/test/kotlin/com/example/model/Delivery.kt b/buildSrc/src/test/kotlin/com/example/model/Delivery.kt index 9993154e6f..cfa152a714 100644 --- a/buildSrc/src/test/kotlin/com/example/model/Delivery.kt +++ b/buildSrc/src/test/kotlin/com/example/model/Delivery.kt @@ -2,7 +2,13 @@ package com.example.model import com.google.gson.JsonElement import com.google.gson.JsonObject +import com.google.gson.JsonParseException +import com.google.gson.JsonParser +import java.lang.IllegalStateException +import java.lang.NumberFormatException import kotlin.String +import kotlin.jvm.JvmStatic +import kotlin.jvm.Throws data class Delivery( val item: String, @@ -15,6 +21,25 @@ data class Delivery( return json } + companion object { + @JvmStatic + @Throws(JsonParseException::class) + fun fromJson(serializedObject: String): Delivery { + try { + val jsonObject = JsonParser.parseString(serializedObject).asJsonObject + val item = jsonObject.get("item").asString + val customer = jsonObject.get("customer").toString().let { + Customer.fromJson(it) + } + return Delivery(item, customer) + } catch (e: IllegalStateException) { + throw JsonParseException(e.message) + } catch (e: NumberFormatException) { + throw JsonParseException(e.message) + } + } + } + data class Customer( val name: String? = null, val billingAddress: Address? = null, @@ -27,6 +52,28 @@ data class Delivery( shippingAddress?.let { json.add("shipping_address", it.toJson()) } return json } + + companion object { + @JvmStatic + @Throws(JsonParseException::class) + fun fromJson(serializedObject: String): Customer { + try { + val jsonObject = JsonParser.parseString(serializedObject).asJsonObject + val name = jsonObject.get("name")?.asString + val billingAddress = jsonObject.get("billing_address")?.toString()?.let { + Address.fromJson(it) + } + val shippingAddress = jsonObject.get("shipping_address")?.toString()?.let { + Address.fromJson(it) + } + return Customer(name, billingAddress, shippingAddress) + } catch (e: IllegalStateException) { + throw JsonParseException(e.message) + } catch (e: NumberFormatException) { + throw JsonParseException(e.message) + } + } + } } data class Address( @@ -41,5 +88,23 @@ data class Delivery( json.addProperty("state", state) return json } + + companion object { + @JvmStatic + @Throws(JsonParseException::class) + fun fromJson(serializedObject: String): Address { + try { + val jsonObject = JsonParser.parseString(serializedObject).asJsonObject + val streetAddress = jsonObject.get("street_address").asString + val city = jsonObject.get("city").asString + val state = jsonObject.get("state").asString + return Address(streetAddress, city, state) + } catch (e: IllegalStateException) { + throw JsonParseException(e.message) + } catch (e: NumberFormatException) { + throw JsonParseException(e.message) + } + } + } } } diff --git a/buildSrc/src/test/kotlin/com/example/model/Demo.kt b/buildSrc/src/test/kotlin/com/example/model/Demo.kt index be6efa7f53..3aa72fa7e3 100644 --- a/buildSrc/src/test/kotlin/com/example/model/Demo.kt +++ b/buildSrc/src/test/kotlin/com/example/model/Demo.kt @@ -2,11 +2,17 @@ package com.example.model import com.google.gson.JsonElement import com.google.gson.JsonObject +import com.google.gson.JsonParseException +import com.google.gson.JsonParser +import java.lang.IllegalStateException +import java.lang.NumberFormatException import kotlin.Boolean import kotlin.Double import kotlin.Long import kotlin.Nothing import kotlin.String +import kotlin.jvm.JvmStatic +import kotlin.jvm.Throws data class Demo( val s: String, @@ -33,4 +39,29 @@ data class Demo( nb?.let { json.addProperty("nb", it) } return json } + + companion object { + @JvmStatic + @Throws(JsonParseException::class) + fun fromJson(serializedObject: String): Demo { + try { + val jsonObject = JsonParser.parseString(serializedObject).asJsonObject + val s = jsonObject.get("s").asString + val i = jsonObject.get("i").asLong + val n = jsonObject.get("n").asDouble + val b = jsonObject.get("b").asBoolean + val l = null + val ns = jsonObject.get("ns")?.asString + val ni = jsonObject.get("ni")?.asLong + val nn = jsonObject.get("nn")?.asDouble + val nb = jsonObject.get("nb")?.asBoolean + val nl = null + return Demo(s, i, n, b, l, ns, ni, nn, nb, nl) + } catch (e: IllegalStateException) { + throw JsonParseException(e.message) + } catch (e: NumberFormatException) { + throw JsonParseException(e.message) + } + } + } } diff --git a/buildSrc/src/test/kotlin/com/example/model/Foo.kt b/buildSrc/src/test/kotlin/com/example/model/Foo.kt index c498278498..adaef03b0d 100644 --- a/buildSrc/src/test/kotlin/com/example/model/Foo.kt +++ b/buildSrc/src/test/kotlin/com/example/model/Foo.kt @@ -2,8 +2,14 @@ package com.example.model import com.google.gson.JsonElement import com.google.gson.JsonObject +import com.google.gson.JsonParseException +import com.google.gson.JsonParser +import java.lang.IllegalStateException +import java.lang.NumberFormatException import kotlin.Long import kotlin.String +import kotlin.jvm.JvmStatic +import kotlin.jvm.Throws data class Foo( val bar: String? = null, @@ -15,4 +21,21 @@ data class Foo( baz?.let { json.addProperty("baz", it) } return json } + + companion object { + @JvmStatic + @Throws(JsonParseException::class) + fun fromJson(serializedObject: String): Foo { + try { + val jsonObject = JsonParser.parseString(serializedObject).asJsonObject + val bar = jsonObject.get("bar")?.asString + val baz = jsonObject.get("baz")?.asLong + return Foo(bar, baz) + } catch (e: IllegalStateException) { + throw JsonParseException(e.message) + } catch (e: NumberFormatException) { + throw JsonParseException(e.message) + } + } + } } diff --git a/buildSrc/src/test/kotlin/com/example/model/Location.kt b/buildSrc/src/test/kotlin/com/example/model/Location.kt index 30deadf200..f27a2a2c3a 100644 --- a/buildSrc/src/test/kotlin/com/example/model/Location.kt +++ b/buildSrc/src/test/kotlin/com/example/model/Location.kt @@ -2,7 +2,13 @@ package com.example.model import com.google.gson.JsonElement import com.google.gson.JsonObject +import com.google.gson.JsonParseException +import com.google.gson.JsonParser +import java.lang.IllegalStateException +import java.lang.NumberFormatException import kotlin.String +import kotlin.jvm.JvmStatic +import kotlin.jvm.Throws class Location { val planet: String = "earth" @@ -12,4 +18,19 @@ class Location { json.addProperty("planet", planet) return json } + + companion object { + @JvmStatic + @Throws(JsonParseException::class) + fun fromJson(serializedObject: String): Location { + try { + val jsonObject = JsonParser.parseString(serializedObject).asJsonObject + return Location() + } catch (e: IllegalStateException) { + throw JsonParseException(e.message) + } catch (e: NumberFormatException) { + throw JsonParseException(e.message) + } + } + } } diff --git a/buildSrc/src/test/kotlin/com/example/model/Message.kt b/buildSrc/src/test/kotlin/com/example/model/Message.kt index ca9120fffc..ba87321dd7 100644 --- a/buildSrc/src/test/kotlin/com/example/model/Message.kt +++ b/buildSrc/src/test/kotlin/com/example/model/Message.kt @@ -3,9 +3,16 @@ package com.example.model import com.google.gson.JsonArray import com.google.gson.JsonElement import com.google.gson.JsonObject +import com.google.gson.JsonParseException +import com.google.gson.JsonParser +import java.lang.IllegalStateException +import java.lang.NumberFormatException import kotlin.Boolean import kotlin.String +import kotlin.collections.ArrayList import kotlin.collections.List +import kotlin.jvm.JvmStatic +import kotlin.jvm.Throws data class Message( val destination: List, @@ -33,4 +40,38 @@ data class Message( important?.let { json.addProperty("important", it) } return json } + + companion object { + @JvmStatic + @Throws(JsonParseException::class) + fun fromJson(serializedObject: String): Message { + try { + val jsonObject = JsonParser.parseString(serializedObject).asJsonObject + val destination = jsonObject.get("destination").asJsonArray.let { jsonArray -> + val collection = ArrayList(jsonArray.size()) + jsonArray.forEach { + collection.add(it.asString) + } + collection + } + val origin = jsonObject.get("origin").asString + val subject = jsonObject.get("subject")?.asString + val message = jsonObject.get("message")?.asString + val labels = jsonObject.get("labels")?.asJsonArray?.let { jsonArray -> + val collection = ArrayList(jsonArray.size()) + jsonArray.forEach { + collection.add(it.asString) + } + collection + } + val read = jsonObject.get("read")?.asBoolean + val important = jsonObject.get("important")?.asBoolean + return Message(destination, origin, subject, message, labels, read, important) + } catch (e: IllegalStateException) { + throw JsonParseException(e.message) + } catch (e: NumberFormatException) { + throw JsonParseException(e.message) + } + } + } } diff --git a/buildSrc/src/test/kotlin/com/example/model/Opus.kt b/buildSrc/src/test/kotlin/com/example/model/Opus.kt index a97cdead1f..d0a54ce5c2 100644 --- a/buildSrc/src/test/kotlin/com/example/model/Opus.kt +++ b/buildSrc/src/test/kotlin/com/example/model/Opus.kt @@ -3,10 +3,17 @@ package com.example.model import com.google.gson.JsonArray import com.google.gson.JsonElement import com.google.gson.JsonObject +import com.google.gson.JsonParseException +import com.google.gson.JsonParser import com.google.gson.JsonPrimitive +import java.lang.IllegalStateException +import java.lang.NumberFormatException import kotlin.Long import kotlin.String +import kotlin.collections.ArrayList import kotlin.collections.List +import kotlin.jvm.JvmStatic +import kotlin.jvm.Throws /** * A musical opus. @@ -34,6 +41,31 @@ data class Opus( return json } + companion object { + @JvmStatic + @Throws(JsonParseException::class) + fun fromJson(serializedObject: String): Opus { + try { + val jsonObject = JsonParser.parseString(serializedObject).asJsonObject + val title = jsonObject.get("title")?.asString + val composer = jsonObject.get("composer")?.asString + val artists = jsonObject.get("artists")?.asJsonArray?.let { jsonArray -> + val collection = ArrayList(jsonArray.size()) + jsonArray.forEach { + collection.add(Artist.fromJson(it.toString())) + } + collection + } + val duration = jsonObject.get("duration")?.asLong + return Opus(title, composer, artists, duration) + } catch (e: IllegalStateException) { + throw JsonParseException(e.message) + } catch (e: NumberFormatException) { + throw JsonParseException(e.message) + } + } + } + /** * An artist and their role in an opus. * @param name The artist's name. @@ -49,40 +81,57 @@ data class Opus( role?.let { json.add("role", it.toJson()) } return json } + + companion object { + @JvmStatic + @Throws(JsonParseException::class) + fun fromJson(serializedObject: String): Artist { + try { + val jsonObject = JsonParser.parseString(serializedObject).asJsonObject + val name = jsonObject.get("name")?.asString + val role = jsonObject.get("role")?.asString?.let { + Role.fromJson(it) + } + return Artist(name, role) + } catch (e: IllegalStateException) { + throw JsonParseException(e.message) + } catch (e: NumberFormatException) { + throw JsonParseException(e.message) + } + } + } } /** * The artist's role. */ - enum class Role { - SINGER, + enum class Role( + private val jsonValue: String + ) { + SINGER("singer"), + + GUITARIST("guitarist"), - GUITARIST, + PIANIST("pianist"), - PIANIST, + DRUMMER("drummer"), - DRUMMER, + BASSIST("bassist"), - BASSIST, + VIOLINIST("violinist"), - VIOLINIST, + DJ("dj"), - DJ, + VOCALS("vocals"), - VOCALS, + OTHER("other"); - OTHER; + fun toJson(): JsonElement = JsonPrimitive(jsonValue) - fun toJson(): JsonElement = when (this) { - SINGER -> JsonPrimitive("singer") - GUITARIST -> JsonPrimitive("guitarist") - PIANIST -> JsonPrimitive("pianist") - DRUMMER -> JsonPrimitive("drummer") - BASSIST -> JsonPrimitive("bassist") - VIOLINIST -> JsonPrimitive("violinist") - DJ -> JsonPrimitive("dj") - VOCALS -> JsonPrimitive("vocals") - OTHER -> JsonPrimitive("other") + companion object { + @JvmStatic + fun fromJson(serializedObject: String): Role = values().first { it.jsonValue == + serializedObject } } } } diff --git a/buildSrc/src/test/kotlin/com/example/model/Order.kt b/buildSrc/src/test/kotlin/com/example/model/Order.kt new file mode 100644 index 0000000000..fd39e37f75 --- /dev/null +++ b/buildSrc/src/test/kotlin/com/example/model/Order.kt @@ -0,0 +1,71 @@ +package com.example.model + +import com.google.gson.JsonArray +import com.google.gson.JsonElement +import com.google.gson.JsonObject +import com.google.gson.JsonParseException +import com.google.gson.JsonParser +import com.google.gson.JsonPrimitive +import java.lang.IllegalStateException +import java.lang.NumberFormatException +import kotlin.String +import kotlin.collections.HashSet +import kotlin.collections.Set +import kotlin.jvm.JvmStatic +import kotlin.jvm.Throws + +data class Order( + val sizes: Set +) { + fun toJson(): JsonElement { + val json = JsonObject() + val sizesArray = JsonArray(sizes.size) + sizes.forEach { sizesArray.add(it.toJson()) } + json.add("sizes", sizesArray) + return json + } + + companion object { + @JvmStatic + @Throws(JsonParseException::class) + fun fromJson(serializedObject: String): Order { + try { + val jsonObject = JsonParser.parseString(serializedObject).asJsonObject + val sizes = jsonObject.get("sizes").asJsonArray.let { jsonArray -> + val collection = HashSet(jsonArray.size()) + jsonArray.forEach { + collection.add(Size.fromJson(it.asString)) + } + collection + } + return Order(sizes) + } catch (e: IllegalStateException) { + throw JsonParseException(e.message) + } catch (e: NumberFormatException) { + throw JsonParseException(e.message) + } + } + } + + enum class Size( + private val jsonValue: String + ) { + X_SMALL("x small"), + + SMALL("small"), + + MEDIUM("medium"), + + LARGE("large"), + + X_LARGE("x large"); + + fun toJson(): JsonElement = JsonPrimitive(jsonValue) + + companion object { + @JvmStatic + fun fromJson(serializedObject: String): Size = values().first { it.jsonValue == + serializedObject } + } + } +} diff --git a/buildSrc/src/test/kotlin/com/example/model/Person.kt b/buildSrc/src/test/kotlin/com/example/model/Person.kt index 16336211ba..d2afeefb21 100644 --- a/buildSrc/src/test/kotlin/com/example/model/Person.kt +++ b/buildSrc/src/test/kotlin/com/example/model/Person.kt @@ -2,8 +2,14 @@ package com.example.model import com.google.gson.JsonElement import com.google.gson.JsonObject +import com.google.gson.JsonParseException +import com.google.gson.JsonParser +import java.lang.IllegalStateException +import java.lang.NumberFormatException import kotlin.Long import kotlin.String +import kotlin.jvm.JvmStatic +import kotlin.jvm.Throws data class Person( val firstName: String? = null, @@ -17,4 +23,22 @@ data class Person( age?.let { json.addProperty("age", it) } return json } + + companion object { + @JvmStatic + @Throws(JsonParseException::class) + fun fromJson(serializedObject: String): Person { + try { + val jsonObject = JsonParser.parseString(serializedObject).asJsonObject + val firstName = jsonObject.get("firstName")?.asString + val lastName = jsonObject.get("lastName")?.asString + val age = jsonObject.get("age")?.asLong + return Person(firstName, lastName, age) + } catch (e: IllegalStateException) { + throw JsonParseException(e.message) + } catch (e: NumberFormatException) { + throw JsonParseException(e.message) + } + } + } } diff --git a/buildSrc/src/test/kotlin/com/example/model/Product.kt b/buildSrc/src/test/kotlin/com/example/model/Product.kt index 021ee2dfc9..e2e2cb5c40 100644 --- a/buildSrc/src/test/kotlin/com/example/model/Product.kt +++ b/buildSrc/src/test/kotlin/com/example/model/Product.kt @@ -2,9 +2,15 @@ package com.example.model import com.google.gson.JsonElement import com.google.gson.JsonObject +import com.google.gson.JsonParseException +import com.google.gson.JsonParser +import java.lang.IllegalStateException +import java.lang.NumberFormatException import kotlin.Double import kotlin.Long import kotlin.String +import kotlin.jvm.JvmStatic +import kotlin.jvm.Throws data class Product( val productId: Long, @@ -18,4 +24,22 @@ data class Product( json.addProperty("price", price) return json } + + companion object { + @JvmStatic + @Throws(JsonParseException::class) + fun fromJson(serializedObject: String): Product { + try { + val jsonObject = JsonParser.parseString(serializedObject).asJsonObject + val productId = jsonObject.get("productId").asLong + val productName = jsonObject.get("productName").asString + val price = jsonObject.get("price").asDouble + return Product(productId, productName, price) + } catch (e: IllegalStateException) { + throw JsonParseException(e.message) + } catch (e: NumberFormatException) { + throw JsonParseException(e.message) + } + } + } } diff --git a/buildSrc/src/test/kotlin/com/example/model/Shipping.kt b/buildSrc/src/test/kotlin/com/example/model/Shipping.kt index 2a3b57cd57..c23981ad82 100644 --- a/buildSrc/src/test/kotlin/com/example/model/Shipping.kt +++ b/buildSrc/src/test/kotlin/com/example/model/Shipping.kt @@ -2,7 +2,13 @@ package com.example.model import com.google.gson.JsonElement import com.google.gson.JsonObject +import com.google.gson.JsonParseException +import com.google.gson.JsonParser +import java.lang.IllegalStateException +import java.lang.NumberFormatException import kotlin.String +import kotlin.jvm.JvmStatic +import kotlin.jvm.Throws data class Shipping( val item: String, @@ -15,6 +21,25 @@ data class Shipping( return json } + companion object { + @JvmStatic + @Throws(JsonParseException::class) + fun fromJson(serializedObject: String): Shipping { + try { + val jsonObject = JsonParser.parseString(serializedObject).asJsonObject + val item = jsonObject.get("item").asString + val destination = jsonObject.get("destination").toString().let { + Address.fromJson(it) + } + return Shipping(item, destination) + } catch (e: IllegalStateException) { + throw JsonParseException(e.message) + } catch (e: NumberFormatException) { + throw JsonParseException(e.message) + } + } + } + data class Address( val streetAddress: String, val city: String, @@ -27,5 +52,23 @@ data class Shipping( json.addProperty("state", state) return json } + + companion object { + @JvmStatic + @Throws(JsonParseException::class) + fun fromJson(serializedObject: String): Address { + try { + val jsonObject = JsonParser.parseString(serializedObject).asJsonObject + val streetAddress = jsonObject.get("street_address").asString + val city = jsonObject.get("city").asString + val state = jsonObject.get("state").asString + return Address(streetAddress, city, state) + } catch (e: IllegalStateException) { + throw JsonParseException(e.message) + } catch (e: NumberFormatException) { + throw JsonParseException(e.message) + } + } + } } } diff --git a/buildSrc/src/test/kotlin/com/example/model/Style.kt b/buildSrc/src/test/kotlin/com/example/model/Style.kt index 8fda03aabf..4e507a6130 100644 --- a/buildSrc/src/test/kotlin/com/example/model/Style.kt +++ b/buildSrc/src/test/kotlin/com/example/model/Style.kt @@ -2,7 +2,14 @@ package com.example.model import com.google.gson.JsonElement import com.google.gson.JsonObject +import com.google.gson.JsonParseException +import com.google.gson.JsonParser import com.google.gson.JsonPrimitive +import java.lang.IllegalStateException +import java.lang.NumberFormatException +import kotlin.String +import kotlin.jvm.JvmStatic +import kotlin.jvm.Throws data class Style( val color: Color @@ -13,26 +20,45 @@ data class Style( return json } - enum class Color { - RED, + companion object { + @JvmStatic + @Throws(JsonParseException::class) + fun fromJson(serializedObject: String): Style { + try { + val jsonObject = JsonParser.parseString(serializedObject).asJsonObject + val color = jsonObject.get("color").asString.let { + Color.fromJson(it) + } + return Style(color) + } catch (e: IllegalStateException) { + throw JsonParseException(e.message) + } catch (e: NumberFormatException) { + throw JsonParseException(e.message) + } + } + } + + enum class Color( + private val jsonValue: String + ) { + RED("red"), + + AMBER("amber"), - AMBER, + GREEN("green"), - GREEN, + DARK_BLUE("dark_blue"), - DARK_BLUE, + LIME_GREEN("lime green"), - LIME_GREEN, + SUNBURST_YELLOW("sunburst-yellow"); - SUNBURST_YELLOW; + fun toJson(): JsonElement = JsonPrimitive(jsonValue) - fun toJson(): JsonElement = when (this) { - RED -> JsonPrimitive("red") - AMBER -> JsonPrimitive("amber") - GREEN -> JsonPrimitive("green") - DARK_BLUE -> JsonPrimitive("dark_blue") - LIME_GREEN -> JsonPrimitive("lime green") - SUNBURST_YELLOW -> JsonPrimitive("sunburst-yellow") + companion object { + @JvmStatic + fun fromJson(serializedObject: String): Color = values().first { it.jsonValue == + serializedObject } } } } diff --git a/buildSrc/src/test/kotlin/com/example/model/User.kt b/buildSrc/src/test/kotlin/com/example/model/User.kt index 935457381f..dd1ce379ba 100644 --- a/buildSrc/src/test/kotlin/com/example/model/User.kt +++ b/buildSrc/src/test/kotlin/com/example/model/User.kt @@ -2,8 +2,14 @@ package com.example.model import com.google.gson.JsonElement import com.google.gson.JsonObject +import com.google.gson.JsonParseException +import com.google.gson.JsonParser import com.google.gson.JsonPrimitive +import java.lang.IllegalStateException +import java.lang.NumberFormatException import kotlin.String +import kotlin.jvm.JvmStatic +import kotlin.jvm.Throws data class User( val username: String, @@ -22,14 +28,41 @@ data class User( return json } - enum class ContactType { - PERSONAL, + companion object { + @JvmStatic + @Throws(JsonParseException::class) + fun fromJson(serializedObject: String): User { + try { + val jsonObject = JsonParser.parseString(serializedObject).asJsonObject + val username = jsonObject.get("username").asString + val host = jsonObject.get("host").asString + val firstname = jsonObject.get("firstname")?.asString + val lastname = jsonObject.get("lastname").asString + val contactType = jsonObject.get("contact_type").asString.let { + ContactType.fromJson(it) + } + return User(username, host, firstname, lastname, contactType) + } catch (e: IllegalStateException) { + throw JsonParseException(e.message) + } catch (e: NumberFormatException) { + throw JsonParseException(e.message) + } + } + } + + enum class ContactType( + private val jsonValue: String + ) { + PERSONAL("personal"), + + PROFESSIONAL("professional"); - PROFESSIONAL; + fun toJson(): JsonElement = JsonPrimitive(jsonValue) - fun toJson(): JsonElement = when (this) { - PERSONAL -> JsonPrimitive("personal") - PROFESSIONAL -> JsonPrimitive("professional") + companion object { + @JvmStatic + fun fromJson(serializedObject: String): ContactType = values().first { it.jsonValue == + serializedObject } } } } diff --git a/buildSrc/src/test/kotlin/com/example/model/UserMerged.kt b/buildSrc/src/test/kotlin/com/example/model/UserMerged.kt index 03c1ea3c9e..d396c55814 100644 --- a/buildSrc/src/test/kotlin/com/example/model/UserMerged.kt +++ b/buildSrc/src/test/kotlin/com/example/model/UserMerged.kt @@ -2,7 +2,13 @@ package com.example.model import com.google.gson.JsonElement import com.google.gson.JsonObject +import com.google.gson.JsonParseException +import com.google.gson.JsonParser +import java.lang.IllegalStateException +import java.lang.NumberFormatException import kotlin.String +import kotlin.jvm.JvmStatic +import kotlin.jvm.Throws data class UserMerged( val email: String? = null, @@ -21,6 +27,28 @@ data class UserMerged( return json } + companion object { + @JvmStatic + @Throws(JsonParseException::class) + fun fromJson(serializedObject: String): UserMerged { + try { + val jsonObject = JsonParser.parseString(serializedObject).asJsonObject + val email = jsonObject.get("email")?.asString + val phone = jsonObject.get("phone")?.asString + val info = jsonObject.get("info")?.toString()?.let { + Info.fromJson(it) + } + val firstname = jsonObject.get("firstname")?.asString + val lastname = jsonObject.get("lastname").asString + return UserMerged(email, phone, info, firstname, lastname) + } catch (e: IllegalStateException) { + throw JsonParseException(e.message) + } catch (e: NumberFormatException) { + throw JsonParseException(e.message) + } + } + } + data class Info( val notes: String? = null, val source: String? = null @@ -31,5 +59,22 @@ data class UserMerged( source?.let { json.addProperty("source", it) } return json } + + companion object { + @JvmStatic + @Throws(JsonParseException::class) + fun fromJson(serializedObject: String): Info { + try { + val jsonObject = JsonParser.parseString(serializedObject).asJsonObject + val notes = jsonObject.get("notes")?.asString + val source = jsonObject.get("source")?.asString + return Info(notes, source) + } catch (e: IllegalStateException) { + throw JsonParseException(e.message) + } catch (e: NumberFormatException) { + throw JsonParseException(e.message) + } + } + } } } diff --git a/buildSrc/src/test/kotlin/com/example/model/Version.kt b/buildSrc/src/test/kotlin/com/example/model/Version.kt index 50c04c90b8..b9081ff4a6 100644 --- a/buildSrc/src/test/kotlin/com/example/model/Version.kt +++ b/buildSrc/src/test/kotlin/com/example/model/Version.kt @@ -2,8 +2,15 @@ package com.example.model import com.google.gson.JsonElement import com.google.gson.JsonObject +import com.google.gson.JsonParseException +import com.google.gson.JsonParser +import java.lang.IllegalStateException +import java.lang.NumberFormatException import kotlin.Double import kotlin.Long +import kotlin.String +import kotlin.jvm.JvmStatic +import kotlin.jvm.Throws class Version { val version: Long = 42L @@ -16,4 +23,19 @@ class Version { json.addProperty("delta", delta) return json } + + companion object { + @JvmStatic + @Throws(JsonParseException::class) + fun fromJson(serializedObject: String): Version { + try { + val jsonObject = JsonParser.parseString(serializedObject).asJsonObject + return Version() + } catch (e: IllegalStateException) { + throw JsonParseException(e.message) + } catch (e: NumberFormatException) { + throw JsonParseException(e.message) + } + } + } } diff --git a/buildSrc/src/test/kotlin/com/example/model/Video.kt b/buildSrc/src/test/kotlin/com/example/model/Video.kt index 6651a2ebd1..19ff7ad352 100644 --- a/buildSrc/src/test/kotlin/com/example/model/Video.kt +++ b/buildSrc/src/test/kotlin/com/example/model/Video.kt @@ -3,8 +3,15 @@ package com.example.model import com.google.gson.JsonArray import com.google.gson.JsonElement import com.google.gson.JsonObject +import com.google.gson.JsonParseException +import com.google.gson.JsonParser +import java.lang.IllegalStateException +import java.lang.NumberFormatException import kotlin.String +import kotlin.collections.HashSet import kotlin.collections.Set +import kotlin.jvm.JvmStatic +import kotlin.jvm.Throws data class Video( val title: String, @@ -26,4 +33,34 @@ data class Video( } return json } + + companion object { + @JvmStatic + @Throws(JsonParseException::class) + fun fromJson(serializedObject: String): Video { + try { + val jsonObject = JsonParser.parseString(serializedObject).asJsonObject + val title = jsonObject.get("title").asString + val tags = jsonObject.get("tags")?.asJsonArray?.let { jsonArray -> + val collection = HashSet(jsonArray.size()) + jsonArray.forEach { + collection.add(it.asString) + } + collection + } + val links = jsonObject.get("links")?.asJsonArray?.let { jsonArray -> + val collection = HashSet(jsonArray.size()) + jsonArray.forEach { + collection.add(it.asString) + } + collection + } + return Video(title, tags, links) + } catch (e: IllegalStateException) { + throw JsonParseException(e.message) + } catch (e: NumberFormatException) { + throw JsonParseException(e.message) + } + } + } } diff --git a/buildSrc/src/test/resources/input/additional_props.json b/buildSrc/src/test/resources/input/additional_props.json new file mode 100644 index 0000000000..1b5c594348 --- /dev/null +++ b/buildSrc/src/test/resources/input/additional_props.json @@ -0,0 +1,27 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Comment", + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "ratings": { + "type": "object", + "properties": { + "global": { + "type": "integer" + } + }, + "additionalProperties": { + "type": "integer" + }, + "required": [ "global"] + }, + "flags": { + "additionalProperties": { + "type": "string" + } + } + } +} \ No newline at end of file diff --git a/buildSrc/src/test/resources/input/enum_array.json b/buildSrc/src/test/resources/input/enum_array.json new file mode 100644 index 0000000000..d9ac3490c1 --- /dev/null +++ b/buildSrc/src/test/resources/input/enum_array.json @@ -0,0 +1,18 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Order", + "type": "object", + "properties": { + "sizes": { + "type": "array", + "items": { + "type": "string", + "enum": ["x small", "small", "medium", "large", "x large"] + }, + "uniqueItems": true + } + }, + "required": [ + "sizes" + ] +} \ No newline at end of file diff --git a/dd-android-gradle-plugin/build.gradle.kts b/dd-android-gradle-plugin/build.gradle.kts deleted file mode 100644 index 35660426bf..0000000000 --- a/dd-android-gradle-plugin/build.gradle.kts +++ /dev/null @@ -1,66 +0,0 @@ -/* - * Unless explicitly stated otherwise all pomFilesList in this repository are licensed under the Apache License Version 2.0. - * This product includes software developed at Datadog (https://www.datadoghq.com/). - * Copyright 2016-2019 Datadog, Inc. - */ - -import com.datadog.gradle.Dependencies -import com.datadog.gradle.config.bintrayConfig -import com.datadog.gradle.config.dependencyUpdateConfig -import com.datadog.gradle.config.detektConfig -import com.datadog.gradle.config.javadocConfig -import com.datadog.gradle.config.junitConfig -import com.datadog.gradle.config.kotlinConfig -import com.datadog.gradle.config.ktLintConfig -import com.datadog.gradle.config.publishingConfig -import com.datadog.gradle.implementation -import com.datadog.gradle.testImplementation - -plugins { - id("java-gradle-plugin") - kotlin("jvm") - `maven-publish` - id("com.github.ben-manes.versions") - id("io.gitlab.arturbosch.detekt") - id("org.jlleitschuh.gradle.ktlint") - id("org.jetbrains.dokka") - id("com.jfrog.bintray") - jacoco -} - -dependencies { - implementation(gradleApi()) - implementation(Dependencies.Libraries.Kotlin) - implementation(Dependencies.Libraries.KotlinReflect) - implementation(Dependencies.Libraries.OkHttp) - implementation(Dependencies.ClassPaths.AndroidTools) - - testImplementation(Dependencies.Libraries.JUnit5) - testImplementation(Dependencies.Libraries.TestTools) - testImplementation(Dependencies.Libraries.OkHttpMock) - - detekt(project(":tools:detekt")) - detekt(Dependencies.Libraries.DetektCli) -} - -kotlinConfig() -detektConfig() -ktLintConfig() -junitConfig() -javadocConfig() -dependencyUpdateConfig() -publishingConfig("${rootDir.canonicalPath}/repo", false) -bintrayConfig() - -gradlePlugin { - plugins { - register("dd-android-gradle-plugin") { - id = "dd-android-gradle-plugin" // the alias - implementationClass = "com.datadog.gradle.plugin.DdAndroidGradlePlugin" - } - } -} - -tasks.withType { - dependsOn("pluginUnderTestMetadata") -} diff --git a/dd-android-gradle-plugin/src/main/kotlin/com/datadog/gradle/plugin/DdAndroidGradlePlugin.kt b/dd-android-gradle-plugin/src/main/kotlin/com/datadog/gradle/plugin/DdAndroidGradlePlugin.kt deleted file mode 100644 index 4e5201bd60..0000000000 --- a/dd-android-gradle-plugin/src/main/kotlin/com/datadog/gradle/plugin/DdAndroidGradlePlugin.kt +++ /dev/null @@ -1,85 +0,0 @@ -/* - * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. - * This product includes software developed at Datadog (https://www.datadoghq.com/). - * Copyright 2016-Present Datadog, Inc. - */ - -package com.datadog.gradle.plugin - -import com.android.build.gradle.AppExtension -import com.android.build.gradle.api.ApplicationVariant -import java.io.File -import org.gradle.api.Plugin -import org.gradle.api.Project -import org.slf4j.LoggerFactory - -/** - * Plugin adding tasks for Android projects using Datadog's SDK for Android. - */ -class DdAndroidGradlePlugin : Plugin { - - // region Plugin - - /** @inheritdoc */ - override fun apply(target: Project) { - val androidExtension = target.extensions.findByType(AppExtension::class.java) - if (androidExtension == null) { - System.err.println(ERROR_NOT_ANDROID) - return - } - - val extension = target.extensions.create(EXT_NAME, DdExtension::class.java) - val apiKey = target.findProperty("DD_API_KEY")?.toString().orEmpty() - - target.afterEvaluate { - androidExtension.applicationVariants.forEach { - configureVariant(target, it, apiKey, extension) - } - } - } - - // endregion - - // region Internal - - @Suppress("DefaultLocale") - private fun configureVariant( - target: Project, - variant: ApplicationVariant, - apiKey: String, - extension: DdExtension - ) { - val flavorName = variant.flavorName - val uploadTaskName = UPLOAD_TASK_NAME + variant.name.capitalize() - - val uploadTask = target.tasks.create( - uploadTaskName, - DdMappingFileUploadTask::class.java - ) - uploadTask.apiKey = apiKey - uploadTask.site = extension.site - uploadTask.envName = extension.environmentName - uploadTask.variantName = flavorName - uploadTask.versionName = variant.versionName - uploadTask.serviceName = variant.applicationId - - val outputsDir = File(target.buildDir, "outputs") - val mappingDir = File(outputsDir, "mapping") - val flavorDir = File(mappingDir, variant.name) - uploadTask.mappingFilePath = File(flavorDir, "mapping.txt").path - } - - // endregion - - companion object { - - internal val LOGGER = LoggerFactory.getLogger("DdAndroidGradlePlugin") - - private const val EXT_NAME = "datadog" - - private const val UPLOAD_TASK_NAME = "uploadMapping" - - private const val ERROR_NOT_ANDROID = "The dd-android-gradle-plugin has been applied on " + - "a non android application project" - } -} diff --git a/dd-android-gradle-plugin/src/main/kotlin/com/datadog/gradle/plugin/DdExtension.kt b/dd-android-gradle-plugin/src/main/kotlin/com/datadog/gradle/plugin/DdExtension.kt deleted file mode 100644 index 428b0303a3..0000000000 --- a/dd-android-gradle-plugin/src/main/kotlin/com/datadog/gradle/plugin/DdExtension.kt +++ /dev/null @@ -1,26 +0,0 @@ -/* - * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. - * This product includes software developed at Datadog (https://www.datadoghq.com/). - * Copyright 2016-Present Datadog, Inc. - */ - -package com.datadog.gradle.plugin - -import com.datadog.gradle.plugin.internal.DdConfiguration -import java.io.Serializable - -/** - * Extension used to configure the `dd-android-gradle-plugin`. - */ -open class DdExtension : Serializable { - - /** - * The environment name for the application. - */ - var environmentName: String = "" - - /** - * The Datadog site to upload your data to (one of "US", "EU", "GOV"). - */ - var site: String = DdConfiguration.Site.US.name -} diff --git a/dd-android-gradle-plugin/src/main/kotlin/com/datadog/gradle/plugin/DdMappingFileUploadTask.kt b/dd-android-gradle-plugin/src/main/kotlin/com/datadog/gradle/plugin/DdMappingFileUploadTask.kt deleted file mode 100644 index 275ac0ba57..0000000000 --- a/dd-android-gradle-plugin/src/main/kotlin/com/datadog/gradle/plugin/DdMappingFileUploadTask.kt +++ /dev/null @@ -1,139 +0,0 @@ -/* - * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. - * This product includes software developed at Datadog (https://www.datadoghq.com/). - * Copyright 2016-Present Datadog, Inc. - */ - -package com.datadog.gradle.plugin - -import com.datadog.gradle.plugin.internal.DdAppIdentifier -import com.datadog.gradle.plugin.internal.DdConfiguration -import com.datadog.gradle.plugin.internal.OkHttpUploader -import com.datadog.gradle.plugin.internal.Uploader -import java.io.File -import org.gradle.api.DefaultTask -import org.gradle.api.tasks.Input -import org.gradle.api.tasks.Internal -import org.gradle.api.tasks.TaskAction - -/** - * A Gradle task to upload a Proguard/R8 mapping file to Datadog servers. - */ -open class DdMappingFileUploadTask : DefaultTask() { - - @get:Internal - internal var uploader: Uploader = OkHttpUploader() - - /** - * The API Key used to upload the data. - */ - @get: Input - var apiKey: String = "" - - /** - * The variant name of the application. - */ - @get:Input - var variantName: String = "" - - /** - * The version name of the application. - */ - @get: Input - var versionName: String = "" - - /** - * The service name of the application (by default, it is your app's package name). - */ - @get: Input - var serviceName: String = "" - - /** - * The environment name. - */ - @get: Input - var envName: String = "" - - /** - * The Datadog site to upload to (one of "US", "EU", "GOV"). - */ - @get: Input - var site: String = "" - - /** - * The path to the mapping file to upload. - */ - @get:Input - var mappingFilePath: String = "" - - init { - group = "datadog" - description = "Uploads the Proguard/R8 mapping file to Datadog" - } - - // region Task - - /** - * Uploads the mapping file to Datadog. - */ - @TaskAction - fun applyTask() { - validateConfiguration() - - val mappingFile = File(mappingFilePath) - if (!validateMappingFile(mappingFile)) return - - println( - "Uploading mapping file for configuration:\n" + - "- envName: {$envName}\n" + - "- versionName: {$versionName}\n" + - "- variantName: {$variantName}\n" + - "- serviceName: {$serviceName}\n" - ) - - val configuration = DdConfiguration( - site = DdConfiguration.Site.valueOf(site), - apiKey = apiKey - ) - uploader.upload( - configuration.buildUrl(), - mappingFile, - DdAppIdentifier( - serviceName = serviceName, - envName = envName, - version = versionName, - variant = variantName - ) - ) - } - - // endregion - - // region Internal - - @Suppress("CheckInternal") - private fun validateConfiguration() { - check(apiKey.isNotBlank()) { "You need to provide a valid client token" } - - val validSiteIds = DdConfiguration.Site.values().map { it.name } - check(site in validSiteIds) { - "You need to provide a valid site (one of ${validSiteIds.joinToString()})" - } - } - - @Suppress("CheckInternal") - private fun validateMappingFile(mappingFile: File): Boolean { - if (!mappingFile.exists()) { - println("There's no mapping file $mappingFilePath, nothing to upload") - return false - } - - check(mappingFile.isFile) { "Expected $mappingFilePath to be a file" } - - check(mappingFile.canRead()) { "Cannot read file $mappingFilePath" } - - return true - } - - // endregion -} diff --git a/dd-android-gradle-plugin/src/main/kotlin/com/datadog/gradle/plugin/internal/DdAppIdentifier.kt b/dd-android-gradle-plugin/src/main/kotlin/com/datadog/gradle/plugin/internal/DdAppIdentifier.kt deleted file mode 100644 index 5d0b27d61e..0000000000 --- a/dd-android-gradle-plugin/src/main/kotlin/com/datadog/gradle/plugin/internal/DdAppIdentifier.kt +++ /dev/null @@ -1,19 +0,0 @@ -/* - * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. - * This product includes software developed at Datadog (https://www.datadoghq.com/). - * Copyright 2016-Present Datadog, Inc. - */ - -package com.datadog.gradle.plugin.internal - -internal data class DdAppIdentifier( - val serviceName: String, - val envName: String, - val version: String, - val variant: String -) { - - override fun toString(): String { - return "$serviceName:$version {variant:$variant; env:$envName}" - } -} diff --git a/dd-android-gradle-plugin/src/main/kotlin/com/datadog/gradle/plugin/internal/DdConfiguration.kt b/dd-android-gradle-plugin/src/main/kotlin/com/datadog/gradle/plugin/internal/DdConfiguration.kt deleted file mode 100644 index bde6c3b6b9..0000000000 --- a/dd-android-gradle-plugin/src/main/kotlin/com/datadog/gradle/plugin/internal/DdConfiguration.kt +++ /dev/null @@ -1,22 +0,0 @@ -/* - * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. - * This product includes software developed at Datadog (https://www.datadoghq.com/). - * Copyright 2016-Present Datadog, Inc. - */ - -package com.datadog.gradle.plugin.internal - -internal data class DdConfiguration( - val site: Site, - val apiKey: String -) { - fun buildUrl(): String { - return "https://sourcemap-intake.${site.host}/v1/input/$apiKey" - } - - internal enum class Site(val host: String) { - US("datadoghq.com"), - EU("datadoghq.eu"), - GOV("ddog-gov.com") - } -} diff --git a/dd-android-gradle-plugin/src/main/kotlin/com/datadog/gradle/plugin/internal/OkHttpUploader.kt b/dd-android-gradle-plugin/src/main/kotlin/com/datadog/gradle/plugin/internal/OkHttpUploader.kt deleted file mode 100644 index 9394b3dc3f..0000000000 --- a/dd-android-gradle-plugin/src/main/kotlin/com/datadog/gradle/plugin/internal/OkHttpUploader.kt +++ /dev/null @@ -1,106 +0,0 @@ -/* - * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. - * This product includes software developed at Datadog (https://www.datadoghq.com/). - * Copyright 2016-Present Datadog, Inc. - */ - -package com.datadog.gradle.plugin.internal - -import com.datadog.gradle.plugin.DdAndroidGradlePlugin.Companion.LOGGER -import java.io.File -import java.lang.IllegalStateException -import java.net.HttpURLConnection -import okhttp3.MediaType -import okhttp3.MultipartBody -import okhttp3.OkHttpClient -import okhttp3.Request -import okhttp3.Response - -internal class OkHttpUploader : Uploader { - - // region Uploader - - @Suppress("TooGenericExceptionCaught") - override fun upload( - url: String, - file: File, - identifier: DdAppIdentifier - ) { - - val body = createBody(identifier, file) - - val client = OkHttpClient.Builder().build() - val request = Request.Builder() - .url(url) - .post(body) - .build() - - val call = client.newCall(request) - val response = try { - call.execute() - } catch (e: Throwable) { - LOGGER.error("Error uploading the mapping file for $identifier", e) - null - } - - handleResponse(response, identifier) - } - - // endregion - - // region Internal - - private fun createBody( - identifier: DdAppIdentifier, - file: File - ): MultipartBody { - val fileBody = MultipartBody.create(MEDIA_TYPE_TXT, file) - return MultipartBody.Builder() - .setType(MultipartBody.FORM) - .addFormDataPart("version", identifier.version) - .addFormDataPart("service", identifier.serviceName) - .addFormDataPart("variant", identifier.variant) - .addFormDataPart("type", TYPE_JVM_MAPPING_FILE) - .addFormDataPart("jvm_mapping_file", file.name, fileBody) - .build() - } - - @Suppress("ThrowingInternalException", "TooGenericExceptionThrown") - private fun handleResponse( - response: Response?, - identifier: DdAppIdentifier - ) { - val statusCode = response?.code() - when { - statusCode == null -> throw RuntimeException( - "Unable to upload mapping file for $identifier; check your network connection" - ) - statusCode in succesfulCodes -> LOGGER.info( - "Mapping file upload successful for $identifier" - ) - statusCode == HttpURLConnection.HTTP_FORBIDDEN -> throw IllegalStateException( - "Unable to upload mapping file for $identifier; " + - "verify that you're using a valid API Key" - ) - statusCode >= HttpURLConnection.HTTP_BAD_REQUEST -> throw IllegalStateException( - "Unable to upload mapping file for $identifier; " + - "it can be because the mapping file already exist for this version" - ) - } - } - - // endregion - - companion object { - - internal val MEDIA_TYPE_TXT = MediaType.parse("text/plain") - - internal val succesfulCodes = arrayOf( - HttpURLConnection.HTTP_OK, - HttpURLConnection.HTTP_CREATED, - HttpURLConnection.HTTP_ACCEPTED - ) - - internal const val TYPE_JVM_MAPPING_FILE = "jvm_mapping_file" - } -} diff --git a/dd-android-gradle-plugin/src/test/kotlin/com/datadog/gradle/plugin/ConfigurationForgeryFactory.kt b/dd-android-gradle-plugin/src/test/kotlin/com/datadog/gradle/plugin/ConfigurationForgeryFactory.kt deleted file mode 100644 index 042cb3261e..0000000000 --- a/dd-android-gradle-plugin/src/test/kotlin/com/datadog/gradle/plugin/ConfigurationForgeryFactory.kt +++ /dev/null @@ -1,20 +0,0 @@ -/* - * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. - * This product includes software developed at Datadog (https://www.datadoghq.com/). - * Copyright 2016-Present Datadog, Inc. - */ - -package com.datadog.gradle.plugin - -import com.datadog.gradle.plugin.internal.DdConfiguration -import fr.xgouchet.elmyr.Forge -import fr.xgouchet.elmyr.ForgeryFactory - -internal class ConfigurationForgeryFactory : ForgeryFactory { - override fun getForgery(forge: Forge): DdConfiguration { - return DdConfiguration( - apiKey = forge.anHexadecimalString(), - site = forge.getForgery() - ) - } -} diff --git a/dd-android-gradle-plugin/src/test/kotlin/com/datadog/gradle/plugin/Configurator.kt b/dd-android-gradle-plugin/src/test/kotlin/com/datadog/gradle/plugin/Configurator.kt deleted file mode 100644 index 0e5b35f69f..0000000000 --- a/dd-android-gradle-plugin/src/test/kotlin/com/datadog/gradle/plugin/Configurator.kt +++ /dev/null @@ -1,18 +0,0 @@ -/* - * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. - * This product includes software developed at Datadog (https://www.datadoghq.com/). - * Copyright 2016-Present Datadog, Inc. - */ - -package com.datadog.gradle.plugin - -import fr.xgouchet.elmyr.Forge -import fr.xgouchet.elmyr.ForgeConfigurator - -internal class Configurator : ForgeConfigurator { - - override fun configure(forge: Forge) { - forge.addFactory(IdentifierForgeryFactory()) - forge.addFactory(ConfigurationForgeryFactory()) - } -} diff --git a/dd-android-gradle-plugin/src/test/kotlin/com/datadog/gradle/plugin/DdMappingFileUploadTaskTest.kt b/dd-android-gradle-plugin/src/test/kotlin/com/datadog/gradle/plugin/DdMappingFileUploadTaskTest.kt deleted file mode 100644 index 0c36c085d4..0000000000 --- a/dd-android-gradle-plugin/src/test/kotlin/com/datadog/gradle/plugin/DdMappingFileUploadTaskTest.kt +++ /dev/null @@ -1,178 +0,0 @@ -/* - * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. - * This product includes software developed at Datadog (https://www.datadoghq.com/). - * Copyright 2016-Present Datadog, Inc. - */ - -package com.datadog.gradle.plugin - -import com.datadog.gradle.plugin.internal.DdAppIdentifier -import com.datadog.gradle.plugin.internal.DdConfiguration -import com.datadog.gradle.plugin.internal.Uploader -import com.nhaarman.mockitokotlin2.verify -import com.nhaarman.mockitokotlin2.verifyZeroInteractions -import fr.xgouchet.elmyr.annotation.Forgery -import fr.xgouchet.elmyr.annotation.StringForgery -import fr.xgouchet.elmyr.annotation.StringForgeryType -import fr.xgouchet.elmyr.junit5.ForgeExtension -import java.io.File -import java.lang.IllegalStateException -import org.gradle.testfixtures.ProjectBuilder -import org.junit.jupiter.api.Assumptions.assumeTrue -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.assertThrows -import org.junit.jupiter.api.extension.ExtendWith -import org.junit.jupiter.api.extension.Extensions -import org.junit.jupiter.api.io.TempDir -import org.mockito.Mock -import org.mockito.junit.jupiter.MockitoExtension -import org.mockito.junit.jupiter.MockitoSettings -import org.mockito.quality.Strictness - -@Extensions( - ExtendWith(MockitoExtension::class), - ExtendWith(ForgeExtension::class) -) -@MockitoSettings(strictness = Strictness.LENIENT) -internal class DdMappingFileUploadTaskTest { - - lateinit var testedTask: DdMappingFileUploadTask - - @TempDir - lateinit var tempDir: File - - @Mock - lateinit var mockUploader: Uploader - - @StringForgery - lateinit var fakeVariant: String - - @StringForgery - lateinit var fakeEnv: String - - @StringForgery - lateinit var fakeVersion: String - - @StringForgery - lateinit var fakeService: String - - @StringForgery(StringForgeryType.HEXADECIMAL) - lateinit var fakeApiKey: String - - @Forgery - lateinit var fakeSite: DdConfiguration.Site - - @BeforeEach - fun `set up`() { - - val fakeProject = ProjectBuilder.builder() - .withProjectDir(tempDir) - .build() - - testedTask = fakeProject.tasks.create( - "DdMappingFileUploadTask", - DdMappingFileUploadTask::class.java - ) - - testedTask.uploader = mockUploader - testedTask.apiKey = fakeApiKey - testedTask.variantName = fakeVariant - testedTask.envName = fakeEnv - testedTask.versionName = fakeVersion - testedTask.serviceName = fakeService - testedTask.site = fakeSite.name - } - - @Test - fun `𝕄 upload file π•Ž applyTask()`() { - // Given - val fakeFile = File(tempDir, "mapping.txt") - fakeFile.writeText("") - testedTask.mappingFilePath = fakeFile.path - val expectedUrl = DdConfiguration(fakeSite, fakeApiKey).buildUrl() - - // When - testedTask.applyTask() - - // Then - verify(mockUploader).upload( - expectedUrl, - fakeFile, - DdAppIdentifier( - serviceName = fakeService, - envName = fakeEnv, - version = fakeVersion, - variant = fakeVariant - ) - ) - } - - @Test - fun `𝕄 throw error π•Ž applyTask() {no api key}`() { - // Given - val fakeFile = File(tempDir, "mapping.txt") - fakeFile.writeText("") - testedTask.mappingFilePath = fakeFile.path - testedTask.apiKey = "" - - // When - assertThrows { - testedTask.applyTask() - } - - // Then - verifyZeroInteractions(mockUploader) - } - - @Test - fun `𝕄 throw error π•Ž applyTask() {invalid site}`( - @StringForgery siteName: String - ) { - assumeTrue(siteName !in listOf("US", "EU", "GOV")) - - // Given - val fakeFile = File(tempDir, "mapping.txt") - fakeFile.writeText("") - testedTask.mappingFilePath = fakeFile.path - testedTask.site = siteName - - // When - assertThrows { - testedTask.applyTask() - } - - // Then - verifyZeroInteractions(mockUploader) - } - - @Test - fun `𝕄 do nothing π•Ž applyTask() {no mapping file}`() { - // Given - val fakeFile = File(tempDir, "mapping.txt") - testedTask.mappingFilePath = fakeFile.path - - // When - testedTask.applyTask() - - // Then - verifyZeroInteractions(mockUploader) - } - - @Test - fun `𝕄 throw error π•Ž applyTask() {mapping file is dir}`() { - - // Given - val fakeFile = File(tempDir, "mapping.txt") - fakeFile.mkdirs() - testedTask.mappingFilePath = fakeFile.path - - // When - assertThrows { - testedTask.applyTask() - } - - // Then - verifyZeroInteractions(mockUploader) - } -} diff --git a/dd-android-gradle-plugin/src/test/kotlin/com/datadog/gradle/plugin/IdentifierForgeryFactory.kt b/dd-android-gradle-plugin/src/test/kotlin/com/datadog/gradle/plugin/IdentifierForgeryFactory.kt deleted file mode 100644 index 5a8318a72b..0000000000 --- a/dd-android-gradle-plugin/src/test/kotlin/com/datadog/gradle/plugin/IdentifierForgeryFactory.kt +++ /dev/null @@ -1,22 +0,0 @@ -/* - * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. - * This product includes software developed at Datadog (https://www.datadoghq.com/). - * Copyright 2016-Present Datadog, Inc. - */ - -package com.datadog.gradle.plugin - -import com.datadog.gradle.plugin.internal.DdAppIdentifier -import fr.xgouchet.elmyr.Forge -import fr.xgouchet.elmyr.ForgeryFactory - -internal class IdentifierForgeryFactory : ForgeryFactory { - override fun getForgery(forge: Forge): DdAppIdentifier { - return DdAppIdentifier( - serviceName = forge.aStringMatching("[a-z]{3}(\\.[a-z]{5,10}){2,4}"), - envName = forge.anAlphabeticalString(), - version = forge.aStringMatching("\\d\\.\\d{1,2}\\.\\d{1,3}"), - variant = forge.anAlphabeticalString() - ) - } -} diff --git a/dd-android-gradle-plugin/src/test/kotlin/com/datadog/gradle/plugin/RecordedRequestAssert.kt b/dd-android-gradle-plugin/src/test/kotlin/com/datadog/gradle/plugin/RecordedRequestAssert.kt deleted file mode 100644 index 3318f922f5..0000000000 --- a/dd-android-gradle-plugin/src/test/kotlin/com/datadog/gradle/plugin/RecordedRequestAssert.kt +++ /dev/null @@ -1,58 +0,0 @@ -/* - * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. - * This product includes software developed at Datadog (https://www.datadoghq.com/). - * Copyright 2016-Present Datadog, Inc. - */ - -package com.datadog.gradle.plugin - -import okhttp3.mockwebserver.RecordedRequest -import org.assertj.core.api.AbstractObjectAssert -import org.assertj.core.api.Assertions.assertThat - -internal class RecordedRequestAssert(actual: RecordedRequest?) : - AbstractObjectAssert( - actual, - RecordedRequestAssert::class.java - ) { - - val bodyContentUtf8 = actual?.body?.readUtf8() - - fun containsFormData(name: String, value: String): RecordedRequestAssert { - isNotNull() - assertThat(bodyContentUtf8) - .contains( - "Content-Disposition: form-data; name=\"$name\"\r\n" + - "Content-Length: ${value.length}\r\n\r\n$value\r\n" - ) - return this - } - - fun containsMultipartFile( - name: String, - fileName: String, - fileContent: String - ): RecordedRequestAssert { - isNotNull() - assertThat(bodyContentUtf8) - .contains( - "Content-Disposition: form-data; name=\"$name\"; filename=\"$fileName\"\r\n" + - "Content-Type: text/plain\r\n" + - "Content-Length: ${fileContent.length}\r\n\r\n$fileContent" - ) - return this - } - - fun hasMethod(expected: String): RecordedRequestAssert { - isNotNull() - assertThat(actual.method) - .isEqualTo(expected) - return this - } - - companion object { - fun assertThat(actual: RecordedRequest?): RecordedRequestAssert { - return RecordedRequestAssert(actual) - } - } -} diff --git a/dd-android-gradle-plugin/src/test/kotlin/com/datadog/gradle/plugin/internal/OkHttpUploaderTest.kt b/dd-android-gradle-plugin/src/test/kotlin/com/datadog/gradle/plugin/internal/OkHttpUploaderTest.kt deleted file mode 100644 index e41e04fef4..0000000000 --- a/dd-android-gradle-plugin/src/test/kotlin/com/datadog/gradle/plugin/internal/OkHttpUploaderTest.kt +++ /dev/null @@ -1,176 +0,0 @@ -/* - * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. - * This product includes software developed at Datadog (https://www.datadoghq.com/). - * Copyright 2016-Present Datadog, Inc. - */ - -package com.datadog.gradle.plugin.internal - -import com.datadog.gradle.plugin.Configurator -import com.datadog.gradle.plugin.RecordedRequestAssert.Companion.assertThat -import fr.xgouchet.elmyr.annotation.Forgery -import fr.xgouchet.elmyr.annotation.IntForgery -import fr.xgouchet.elmyr.annotation.StringForgery -import fr.xgouchet.elmyr.junit5.ForgeConfiguration -import fr.xgouchet.elmyr.junit5.ForgeExtension -import java.io.File -import java.lang.IllegalStateException -import java.net.HttpURLConnection -import okhttp3.mockwebserver.Dispatcher -import okhttp3.mockwebserver.MockResponse -import okhttp3.mockwebserver.MockWebServer -import okhttp3.mockwebserver.RecordedRequest -import org.assertj.core.api.Assertions.assertThat -import org.junit.jupiter.api.AfterEach -import org.junit.jupiter.api.Assumptions.assumeTrue -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.assertThrows -import org.junit.jupiter.api.extension.ExtendWith -import org.junit.jupiter.api.extension.Extensions -import org.junit.jupiter.api.io.TempDir -import org.mockito.junit.jupiter.MockitoExtension -import org.mockito.junit.jupiter.MockitoSettings -import org.mockito.quality.Strictness - -@Extensions( - ExtendWith(MockitoExtension::class), - ExtendWith(ForgeExtension::class) -) -@MockitoSettings(strictness = Strictness.LENIENT) -@ForgeConfiguration(Configurator::class) -internal class OkHttpUploaderTest { - - lateinit var testedUploader: Uploader - - @TempDir - lateinit var tempDir: File - - lateinit var fakeMappingFile: File - - @Forgery - lateinit var fakeIdentifier: DdAppIdentifier - - @StringForgery(regex = "[a-z]{8}\\.txt") - lateinit var fakeFileName: String - - @StringForgery - lateinit var fakeFileContent: String - - lateinit var mockWebServer: MockWebServer - - lateinit var mockResponse: MockResponse - - lateinit var mockDispatcher: Dispatcher - - var dispatchedRequest: RecordedRequest? = null - - @BeforeEach - fun `set up`() { - fakeMappingFile = File(tempDir, fakeFileName) - fakeMappingFile.writeText(fakeFileContent) - - mockWebServer = MockWebServer() - mockDispatcher = MockDispatcher() - mockWebServer.setDispatcher(mockDispatcher) - testedUploader = OkHttpUploader() - } - - @AfterEach - fun `tear down`() { - mockWebServer.shutdown() - dispatchedRequest = null - } - - @Test - fun `𝕄 upload proper request π•Ž upload()`() { - // Given - mockResponse = MockResponse() - .setResponseCode(HttpURLConnection.HTTP_OK) - .setBody("{}") - - // When - testedUploader.upload( - mockWebServer.url("/").toString(), - fakeMappingFile, - fakeIdentifier - ) - - // Then - assertThat(mockWebServer.requestCount).isEqualTo(1) - assertThat(dispatchedRequest) - .hasMethod("POST") - .containsFormData("version", fakeIdentifier.version) - .containsFormData("service", fakeIdentifier.serviceName) - .containsFormData("variant", fakeIdentifier.variant) - .containsFormData("type", OkHttpUploader.TYPE_JVM_MAPPING_FILE) - .containsMultipartFile("jvm_mapping_file", fakeFileName, fakeFileContent) - } - - @Test - fun `𝕄 throw exception π•Ž upload() {response 403}`() { - // Given - mockResponse = MockResponse() - .setResponseCode(403) - .setBody("{}") - - // When - assertThrows { - testedUploader.upload( - mockWebServer.url("/").toString(), - fakeMappingFile, - fakeIdentifier - ) - } - - // Then - assertThat(mockWebServer.requestCount).isEqualTo(1) - assertThat(dispatchedRequest) - .hasMethod("POST") - .containsFormData("version", fakeIdentifier.version) - .containsFormData("service", fakeIdentifier.serviceName) - .containsFormData("variant", fakeIdentifier.variant) - .containsFormData("type", OkHttpUploader.TYPE_JVM_MAPPING_FILE) - .containsMultipartFile("jvm_mapping_file", fakeFileName, fakeFileContent) - } - - @Test - fun `𝕄 throw exception π•Ž upload() {response 400-599}`( - @IntForgery(400, 600) statusCode: Int - ) { - // 407 will actually throw a protocol exception - // Received HTTP_PROXY_AUTH (407) code while not using proxy - assumeTrue(statusCode != 407) - - // Given - mockResponse = MockResponse() - .setResponseCode(statusCode) - .setBody("{}") - - // When - assertThrows { - testedUploader.upload( - mockWebServer.url("/").toString(), - fakeMappingFile, - fakeIdentifier - ) - } - - // Then - assertThat(mockWebServer.requestCount).isEqualTo(1) - assertThat(dispatchedRequest) - .hasMethod("POST") - .containsFormData("version", fakeIdentifier.version) - .containsFormData("service", fakeIdentifier.serviceName) - .containsFormData("variant", fakeIdentifier.variant) - .containsFormData("type", OkHttpUploader.TYPE_JVM_MAPPING_FILE) - .containsMultipartFile("jvm_mapping_file", fakeFileName, fakeFileContent) - } - - inner class MockDispatcher : Dispatcher() { - override fun dispatch(request: RecordedRequest?): MockResponse { - dispatchedRequest = request - return mockResponse - } - } -} diff --git a/dd-sdk-android-ndk/src/androidTest/kotlin/com/datadog/android/ndk/NdkTests.kt b/dd-sdk-android-ndk/src/androidTest/kotlin/com/datadog/android/ndk/NdkTests.kt index e0727fc45a..9cc11569df 100644 --- a/dd-sdk-android-ndk/src/androidTest/kotlin/com/datadog/android/ndk/NdkTests.kt +++ b/dd-sdk-android-ndk/src/androidTest/kotlin/com/datadog/android/ndk/NdkTests.kt @@ -12,8 +12,9 @@ import com.google.gson.JsonParser import fr.xgouchet.elmyr.junit4.ForgeRule import java.lang.RuntimeException import java.nio.charset.Charset -import java.util.UUID +import java.util.concurrent.TimeUnit import org.assertj.core.api.Assertions +import org.assertj.core.data.Offset import org.junit.Rule import org.junit.Test import org.junit.rules.TemporaryFolder @@ -51,79 +52,59 @@ internal class NdkTests { @Test fun mustWriteAnErrorLog_whenHandlingSignal_whenConsentUpdatedToGranted() { - val signal = forge.anInt(min = 1, max = 32) - val appId = randomUUIDOrNull() - val sessionId = randomUUIDOrNull() - val viewId = randomUUIDOrNull() - val serviceName = forge.anAlphabeticalString(size = 50) - val env = forge.anAlphabeticalString(size = 50) + val fakeSignal = forge.anInt(min = 1, max = 32) // we need to keep this this size because we are using a buffer of [30] size in c++ for // the error.signal attribute - val signalName = forge.anAlphabeticalString(size = 20) - val signalErrorMessage = forge.anAlphabeticalString() - initNdkErrorHandler( - temporaryFolder.root.absolutePath, - serviceName, - env, - appId, - sessionId, - viewId - ) + val fakeSignalName = forge.anAlphabeticalString(size = 20) + val fakeErrorMessage = forge.anAlphabeticalString() + val fakeErrorStack = forge.anAlphabeticalString() + initNdkErrorHandler(temporaryFolder.root.absolutePath) updateTrackingConsent(1) simulateSignalInterception( - signal, - signalName, - signalErrorMessage + fakeSignal, + fakeSignalName, + fakeErrorMessage, + fakeErrorStack ) // we need to give time to native part to write the file // otherwise we will get into race condition issues + val expectedTimestamp = System.currentTimeMillis() Thread.sleep(5000) // assert the log file - val inputStream = temporaryFolder.root.listFiles()?.first()?.inputStream() + val listFiles = temporaryFolder.root.listFiles() + val inputStream = listFiles?.first()?.inputStream() inputStream?.use { val jsonString = String(it.readBytes(), Charset.forName("utf-8")) val jsonObject = JsonParser.parseString(jsonString).asJsonObject - assertThat(jsonObject).hasField("service", serviceName) - assertThat(jsonObject).hasField("ddtags", "env:$env") - assertThat(jsonObject).hasField("status", "emergency") - assertThat(jsonObject).hasField("message", "Native crash detected") - assertThat(jsonObject).hasField("error.message", signalErrorMessage) - assertThat(jsonObject).hasField("error.signal", "$signalName: $signal") - assertThat(jsonObject).hasField("error.kind", "Native") - assertThat(jsonObject).hasField("logger.name", "crash") - assertThat(jsonObject).hasNullableField("application_id", appId) - assertThat(jsonObject).hasNullableField("session_id", sessionId) - assertThat(jsonObject).hasNullableField("view.id", viewId) + assertThat(jsonObject).hasField("signal", fakeSignal) + assertThat(jsonObject).hasField("signal_name", fakeSignalName) + assertThat(jsonObject).hasField("message", fakeErrorMessage) + assertThat(jsonObject).hasField("stacktrace", fakeErrorStack) + assertThat(jsonObject).hasField( + "timestamp", + expectedTimestamp, + Offset.offset(TimeUnit.SECONDS.toMillis(10)) + ) } } @Test fun mustNotWriteAnyLog_whenHandlingSignal_whenConsentUpdatedToPending() { val signal = forge.anInt(min = 1, max = 32) - val appId = randomUUIDOrNull() - val sessionId = randomUUIDOrNull() - val viewId = randomUUIDOrNull() - val serviceName = forge.anAlphabeticalString(size = 50) - val env = forge.anAlphabeticalString(size = 50) // we need to keep this this size because we are using a buffer of [30] size in c++ for // the error.signal attribute val signalName = forge.anAlphabeticalString(size = 20) - val signalErrorMessage = forge.anAlphabeticalString() - initNdkErrorHandler( - temporaryFolder.root.absolutePath, - serviceName, - env, - appId, - sessionId, - viewId - ) + val errorMessage = forge.anAlphabeticalString() + val errorStack = forge.anAlphabeticalString() + initNdkErrorHandler(temporaryFolder.root.absolutePath) updateTrackingConsent(0) simulateSignalInterception( signal, signalName, - signalErrorMessage + errorMessage, + errorStack ) // we need to give time to native part to write the file @@ -137,28 +118,18 @@ internal class NdkTests { @Test fun mustNotWriteAnyLog_whenHandlingSignal_whenConsentUpdatedToNotGranted() { val signal = forge.anInt(min = 1, max = 32) - val appId = randomUUIDOrNull() - val sessionId = randomUUIDOrNull() - val viewId = randomUUIDOrNull() - val serviceName = forge.anAlphabeticalString(size = 50) - val env = forge.anAlphabeticalString(size = 50) // we need to keep this this size because we are using a buffer of [30] size in c++ for // the error.signal attribute val signalName = forge.anAlphabeticalString(size = 20) - val signalErrorMessage = forge.anAlphabeticalString() - initNdkErrorHandler( - temporaryFolder.root.absolutePath, - serviceName, - env, - appId, - sessionId, - viewId - ) + val errorMessage = forge.anAlphabeticalString() + val errorStack = forge.anAlphabeticalString() + initNdkErrorHandler(temporaryFolder.root.absolutePath) updateTrackingConsent(2) simulateSignalInterception( signal, signalName, - signalErrorMessage + errorMessage, + errorStack ) // we need to give time to native part to write the file @@ -186,31 +157,23 @@ internal class NdkTests { /** * Will initialize the NDK crash reporter. * @param storageDir the storage directory for the reported crash logs - * @param serviceName the service name for the main context - * @param environment the environment name for the main context - * @param appId the application id for the rum context - * @param sessionId the session id to be passed into the rum context - * @param viewId the view id to be passed into the rum context */ private external fun initNdkErrorHandler( - storageDir: String, - serviceName: String, - environment: String, - appId: String?, - sessionId: String?, - viewId: String? + storageDir: String ) /** * Simulate a signal interception into the NDK crash reporter. * @param signal the signal id (between 1 and 32) * @param signalName the signal name (e.g. SIGHUP, SIGINT, SIGILL, etc.) - * @param signalMessage the signal error message + * @param errorMessage the error message + * @param errorStack the error stack */ private external fun simulateSignalInterception( signal: Int, signalName: String, - signalMessage: String + errorMessage: String, + errorStack: String ) /** @@ -222,12 +185,4 @@ internal class NdkTests { ) // endregion - - // region Internal - - private fun randomUUIDOrNull(): String? { - return forge.aNullable { UUID.randomUUID().toString() } - } - - // endregion } diff --git a/dd-sdk-android-ndk/src/main/cpp/CMakeLists.txt b/dd-sdk-android-ndk/src/main/cpp/CMakeLists.txt index adee0acb75..387fe3024a 100644 --- a/dd-sdk-android-ndk/src/main/cpp/CMakeLists.txt +++ b/dd-sdk-android-ndk/src/main/cpp/CMakeLists.txt @@ -1,5 +1,10 @@ cmake_minimum_required(VERSION 3.10.2) +include_directories( + ./ + utils + model +) add_library( # Sets the name of the library. datadog-native-lib # Sets the library as a shared library. @@ -7,16 +12,20 @@ add_library( # Sets the name of the library. # Provides a relative path to your source file(s). datadog-native-lib.cpp datadog-native-lib.h + model/crash-log.cpp + model/crash-log.h utils/signal-monitor.c utils/signal-monitor.h - utils/fileutils.cpp - utils/fileutils.h - utils/stringutils.cpp - utils/stringutils.h - utils/datetime.cpp - utils/datetime.h + utils/file-utils.cpp + utils/file-utils.h + utils/string-utils.cpp + utils/string-utils.h + utils/datetime-utils.cpp + utils/datetime-utils.h utils/backtrace-handler.cpp - utils/backtrace-handler.h) + utils/backtrace-handler.h + utils/format-utils.cpp + ) find_library( # Sets the name of the path variable. log-lib # Specifies the name of the NDK library that diff --git a/dd-sdk-android-ndk/src/main/cpp/datadog-native-lib.cpp b/dd-sdk-android-ndk/src/main/cpp/datadog-native-lib.cpp index ddb2f73be4..0973399db8 100644 --- a/dd-sdk-android-ndk/src/main/cpp/datadog-native-lib.cpp +++ b/dd-sdk-android-ndk/src/main/cpp/datadog-native-lib.cpp @@ -13,41 +13,18 @@ #include #include "android/log.h" -#include "utils/backtrace-handler.h" -#include "utils/datetime.h" -#include "utils/fileutils.h" -#include "utils/signal-monitor.h" -#include "utils/stringutils.h" +#include "crash-log.h" +#include "backtrace-handler.h" +#include "datetime-utils.h" +#include "file-utils.h" +#include "signal-monitor.h" +#include "string-utils.h" -typedef std::string string; - -static struct RumContext { - string application_id; - string session_id; - string view_id; - - RumContext() : application_id(), session_id(), view_id() {} - -} rum_context; static struct Context { - string generic_message; - string emergency_status; - string error_kind; - string logger_name; - string environment; - string service_name; - string storage_dir; - - Context() : - environment(), - service_name(), - storage_dir(), - generic_message("Native crash detected"), - emergency_status("emergency"), - error_kind("Native"), - logger_name("crash") { - } + std::string storage_dir; + + Context() : storage_dir() {} } main_context; @@ -58,50 +35,13 @@ static const uint8_t tracking_consent_granted = 1; static uint8_t tracking_consent = tracking_consent_pending; // 0 - PENDING, 1 - GRANTED, 2 - NOT-GRANTED -std::string get_serialized_log(int signal, - const char *signal_name, - const char *error_message, - const char *date, - const std::string backtrace) { - std::string serializedLog = "{ "; - if (!rum_context.application_id.empty()) { - serializedLog.append(R"("application_id": ")").append(rum_context.application_id) - .append("\","); - } - if (!rum_context.session_id.empty()) { - serializedLog.append(R"("session_id": ")").append(rum_context.session_id) - .append("\","); - } - if (!rum_context.view_id.empty()) { - serializedLog.append(R"("view.id": ")").append(rum_context.view_id) - .append("\","); - } - // these values are either constants or they are marked as NonNull in JVM so we do not have - // to check them here. - char tags[105]; // max 105 characters for the environment name - snprintf(tags, sizeof(tags), "env:%s", main_context.environment.c_str()); - serializedLog.append(R"("message": ")").append(main_context.generic_message).append("\","); - serializedLog.append(R"("service": ")").append(main_context.service_name).append("\","); - serializedLog.append(R"("logger.name": ")").append(main_context.logger_name).append("\","); - serializedLog.append(R"("ddtags": ")").append(tags).append("\","); - serializedLog.append(R"("status": ")").append(main_context.emergency_status).append("\","); - serializedLog.append(R"("date": ")").append(date).append("\","); - serializedLog.append(R"("error.stack": ")").append(backtrace).append("\","); - serializedLog.append(R"("error.message": ")").append(error_message).append("\","); - char formatted_signal_message[30]; - const size_t messageSize = - sizeof(formatted_signal_message) / sizeof(formatted_signal_message[0]); - snprintf(formatted_signal_message, - messageSize, "%s: %d", - signal_name, - signal); - serializedLog.append(R"("error.signal": ")").append(formatted_signal_message).append("\","); - serializedLog.append(R"("error.kind": ")").append(main_context.error_kind).append("\""); - serializedLog.append(" }"); - return serializedLog; -} +void write_crash_report(int signum, + const char *signal_name, + const char *error_message, + const char *error_stacktrace) { + using namespace std; + static const std::string crash_log_filename = "crash_log"; -void crash_signal_intercepted(int signal, const char *signal_name, const char *error_message) { // sync everything pthread_mutex_lock(&handler_mutex); if (tracking_consent != tracking_consent_granted) { @@ -124,58 +64,31 @@ void crash_signal_intercepted(int signal, const char *signal_name, const char *e return; } - // format the current GMT time - char date[100]; - const char *format = "%Y-%m-%d'T'%H:%M:%S.000Z"; - format_date(date, sizeof(date), format); - - // extract the generate_backtrace - std::string backtrace = backtrace::generate_backtrace(); - // serialize the log - std::string serialized_log = get_serialized_log(signal, signal_name, error_message, date, - backtrace); - - // dump the log into a new file - char filename[200]; - // The ARM_32 processors will use an unsigned long long to represent the uint_64. We will pick the - // String format that fits both ARM_32 and ARM_64 (llu). - #pragma clang diagnostic push - #pragma clang diagnostic ignored "-Wformat" - snprintf(filename, sizeof(filename), "%s/%llu", main_context.storage_dir.c_str(), - time_since_epoch()); - #pragma clang diagnostic pop - std::ofstream logs_file_output_stream(filename, std::ofstream::out | std::ofstream::app); - const char *text = serialized_log.c_str(); + string file_path = main_context.storage_dir.append("/").append(crash_log_filename); + long long timestamp = time_since_epoch(); + std::unique_ptr crash_log_ptr = std::make_unique(signum, + timestamp, + signal_name, + error_message, + error_stacktrace); + string serialized_log = crash_log_ptr->serialise(); + + // write the log in the crash log file + ofstream logs_file_output_stream(file_path.c_str(), + ofstream::out | ofstream::trunc); if (logs_file_output_stream.is_open()) { - logs_file_output_stream << text << "\n"; + logs_file_output_stream << serialized_log.c_str(); } logs_file_output_stream.close(); - pthread_mutex_unlock(&handler_mutex); } void update_main_context(JNIEnv *env, - jstring storage_path, - jstring service_name, - jstring environment) { + jstring storage_path) { using namespace stringutils; pthread_mutex_lock(&handler_mutex); main_context.storage_dir = copy_to_string(env, storage_path); - main_context.service_name = copy_to_string(env, service_name); - main_context.environment = copy_to_string(env, environment); - pthread_mutex_unlock(&handler_mutex); -} - -void update_rum_context(JNIEnv *env, - jstring application_id, - jstring session_id, - jstring view_id) { - using namespace stringutils; - pthread_mutex_lock(&handler_mutex); - rum_context.application_id = copy_to_string(env, application_id); - rum_context.session_id = copy_to_string(env, session_id); - rum_context.view_id = copy_to_string(env, view_id); pthread_mutex_unlock(&handler_mutex); } @@ -188,13 +101,11 @@ void update_tracking_consent(jint consent) { extern "C" JNIEXPORT void JNICALL Java_com_datadog_android_ndk_NdkCrashReportsPlugin_registerSignalHandler( JNIEnv *env, - jobject handler, + jobject /* this */, jstring storage_path, - jstring service_name, - jstring environment, jint consent) { - update_main_context(env, storage_path, service_name, environment); + update_main_context(env, storage_path); update_tracking_consent(consent); install_signal_handlers(); } @@ -213,14 +124,4 @@ Java_com_datadog_android_ndk_NdkCrashReportsPlugin_updateTrackingConsent( jobject /* this */, jint consent) { update_tracking_consent(consent); -} - -extern "C" JNIEXPORT void JNICALL -Java_com_datadog_android_ndk_NdkCrashReportsPlugin_updateRumContext( - JNIEnv *env, - jobject /* this */, - jstring application_id, - jstring session_id, - jstring view_id) { - update_rum_context(env, application_id, session_id, view_id); } \ No newline at end of file diff --git a/dd-sdk-android-ndk/src/main/cpp/datadog-native-lib.h b/dd-sdk-android-ndk/src/main/cpp/datadog-native-lib.h index 81b2ef4820..d3db819156 100644 --- a/dd-sdk-android-ndk/src/main/cpp/datadog-native-lib.h +++ b/dd-sdk-android-ndk/src/main/cpp/datadog-native-lib.h @@ -15,18 +15,14 @@ extern "C" { #endif void update_main_context(JNIEnv *env, - jstring storage_path, - jstring service_name, - jstring environment); - -void update_rum_context(JNIEnv *env, - jstring application_id, - jstring session_id, - jstring view_id); + jstring storage_path); void update_tracking_consent(jint consent); -void crash_signal_intercepted(int signal, const char *signal_name, const char *error_message); +void write_crash_report(int signum, + const char *signal_name, + const char *error_message, + const char *error_stacktrace); #ifdef __cplusplus } diff --git a/dd-sdk-android-ndk/src/main/cpp/model/crash-log.cpp b/dd-sdk-android-ndk/src/main/cpp/model/crash-log.cpp new file mode 100644 index 0000000000..3122d0d643 --- /dev/null +++ b/dd-sdk-android-ndk/src/main/cpp/model/crash-log.cpp @@ -0,0 +1,34 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + + +#include "crash-log.h" +#include +#include "format-utils.cpp" + +CrashLog::CrashLog( + int signal, + uint64_t timestamp, + const std::string signal_name, + const std::string error_message, + const std::string error_stacktrace) { + this->signal = signal; + this->timestamp = timestamp; + this->signal_name = signal_name; + this->error_message = error_message; + this->error_stacktrace = error_stacktrace; +} + +std::string CrashLog::serialise() { + using namespace std; + static const string json_formatter = R"({"signal":%s,"timestamp":%s,"signal_name":"%s","message":"%s","stacktrace":"%s"})"; + return strformat::format(json_formatter, + to_string(this->signal).c_str(), + to_string(this->timestamp).c_str(), + this->signal_name.c_str(), + this->error_message.c_str(), + this->error_stacktrace.c_str()); +} \ No newline at end of file diff --git a/dd-sdk-android-ndk/src/main/cpp/model/crash-log.h b/dd-sdk-android-ndk/src/main/cpp/model/crash-log.h new file mode 100644 index 0000000000..ac2608ca19 --- /dev/null +++ b/dd-sdk-android-ndk/src/main/cpp/model/crash-log.h @@ -0,0 +1,30 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + + +#ifndef DD_SDK_ANDROID_CRASH_LOG_H +#define DD_SDK_ANDROID_CRASH_LOG_H + +#include + +class CrashLog { + int signal; + uint64_t timestamp; + std::string signal_name; + std::string error_message; + std::string error_stacktrace; +public: + CrashLog( + int signal, + uint64_t timestamp, + const std::string error_message, + const std::string error_stacktrace, + const std::string signal_name); + + std::string serialise(); +}; + +#endif //DD_SDK_ANDROID_CRASH_LOG_H diff --git a/dd-sdk-android-ndk/src/main/cpp/utils/backtrace-handler.cpp b/dd-sdk-android-ndk/src/main/cpp/utils/backtrace-handler.cpp index 6fbdddf1df..c573dc7d16 100644 --- a/dd-sdk-android-ndk/src/main/cpp/utils/backtrace-handler.cpp +++ b/dd-sdk-android-ndk/src/main/cpp/utils/backtrace-handler.cpp @@ -16,9 +16,6 @@ #include #include - -static const size_t STACK_SIZE = 30; - struct BacktraceState { uintptr_t *current; uintptr_t *end; @@ -52,29 +49,29 @@ namespace { char address_as_hexa[20]; // The ARM_32 processors will use an unsigned long long to represent a pointer so we will choose the // String format that fits both ARM_32 and ARM_64 (lx). - #pragma clang diagnostic push - #pragma clang diagnostic ignored "-Wformat" +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wformat" std::snprintf(address_as_hexa, sizeof(address_as_hexa), "0x%lx", address); - #pragma clang diagnostic pop +#pragma clang diagnostic pop return std::string(address_as_hexa); } - void get_info_from_address(const uintptr_t address, std::string *backtrace) { - backtrace->append(std::to_string(address)); + void get_info_from_address(size_t index, const uintptr_t address, std::string *backtrace) { + backtrace->append(std::to_string(index)); Dl_info info; int fetch_info_success = dladdr(reinterpret_cast(address), &info); if (fetch_info_success) { if (info.dli_fname) { - backtrace->append(" "); + backtrace->append(" "); backtrace->append(info.dli_fname); } - backtrace->append(" "); + backtrace->append(" "); backtrace->append(address_to_hexa(address)); if (info.dli_sname) { - backtrace->append(" "); + backtrace->append(" "); backtrace->append(info.dli_sname); } @@ -82,8 +79,7 @@ namespace { backtrace->append(" "); backtrace->append("+"); backtrace->append(" "); - const uintptr_t address_offset = - address - reinterpret_cast(info.dli_fbase); + auto address_offset = reinterpret_cast(info.dli_fbase); backtrace->append(std::to_string(address_offset)); } @@ -94,25 +90,29 @@ namespace { } -namespace backtrace { - - std::string generate_backtrace() { - // define the buffer which will hold pointers to stack memory addresses - uintptr_t buffer[STACK_SIZE]; - // we will now unwind the stack and capture all the memory addresses up to STACK_SIZE in - // the buffer - const size_t captured_stacksize = capture_backtrace(buffer, STACK_SIZE); - std::string backtrace; - for (size_t idx = 0; idx < captured_stacksize; ++idx) { - // we will iterate through all the stack addresses and translate each address in - // readable informationdsadsa - get_info_from_address(buffer[idx], &backtrace); +bool copyString(const std::string &str, char *ptr, size_t max_size) { + size_t str_size = str.size(); + size_t copy_size = std::min(str_size, max_size - 1); + memcpy(ptr, str.data(), copy_size); + ptr[str.size()] = '\0'; + return copy_size == str_size; +} - } - return backtrace; +bool generate_backtrace(char *backtrace_ptr, size_t max_size) { + // define the buffer which will hold pointers to stack memory addresses + uintptr_t buffer[max_stack_frames]; + // we will now unwind the stack and capture all the memory addresses up to max_stack_frames in + // the buffer + const size_t number_of_captured_frames = capture_backtrace(buffer, max_stack_frames); + std::string backtrace; + for (size_t idx = 0; idx < number_of_captured_frames; ++idx) { + // we will iterate through all the stack addresses and translate each address in + // readable information + get_info_from_address(idx, buffer[idx], &backtrace); } + return copyString(backtrace, backtrace_ptr, max_size); +} -} diff --git a/dd-sdk-android-ndk/src/main/cpp/utils/backtrace-handler.h b/dd-sdk-android-ndk/src/main/cpp/utils/backtrace-handler.h index 60397c0139..eb86ca7cf2 100644 --- a/dd-sdk-android-ndk/src/main/cpp/utils/backtrace-handler.h +++ b/dd-sdk-android-ndk/src/main/cpp/utils/backtrace-handler.h @@ -4,14 +4,24 @@ * Copyright 2016-Present Datadog, Inc. */ -#include #ifndef BACKTRACE_HANDLER_H #define BACKTRACE_HANDLER_H -namespace backtrace { +#include - std::string generate_backtrace(); -} +#ifdef __cplusplus +extern "C" { +#endif + +const size_t max_stack_frames = 100; +const size_t max_characters_per_stack_frame = 2048; +const size_t max_stack_size = max_stack_frames * max_characters_per_stack_frame; +// We cannot use a namespace here as this function will be called from C file (signal_monitor.c) +bool generate_backtrace(char *backtrace_ptr, size_t max_size); + +#ifdef __cplusplus +} +#endif #endif \ No newline at end of file diff --git a/dd-sdk-android-ndk/src/main/cpp/utils/datetime.cpp b/dd-sdk-android-ndk/src/main/cpp/utils/datetime-utils.cpp similarity index 96% rename from dd-sdk-android-ndk/src/main/cpp/utils/datetime.cpp rename to dd-sdk-android-ndk/src/main/cpp/utils/datetime-utils.cpp index d3eb5f2150..38420e196d 100644 --- a/dd-sdk-android-ndk/src/main/cpp/utils/datetime.cpp +++ b/dd-sdk-android-ndk/src/main/cpp/utils/datetime-utils.cpp @@ -4,7 +4,7 @@ * Copyright 2016-Present Datadog, Inc. */ -#include "datetime.h" +#include "datetime-utils.h" #include #include diff --git a/dd-sdk-android-ndk/src/main/cpp/utils/datetime.h b/dd-sdk-android-ndk/src/main/cpp/utils/datetime-utils.h similarity index 100% rename from dd-sdk-android-ndk/src/main/cpp/utils/datetime.h rename to dd-sdk-android-ndk/src/main/cpp/utils/datetime-utils.h diff --git a/dd-sdk-android-ndk/src/main/cpp/utils/fileutils.cpp b/dd-sdk-android-ndk/src/main/cpp/utils/file-utils.cpp similarity index 96% rename from dd-sdk-android-ndk/src/main/cpp/utils/fileutils.cpp rename to dd-sdk-android-ndk/src/main/cpp/utils/file-utils.cpp index f551b06e00..610c291d6e 100644 --- a/dd-sdk-android-ndk/src/main/cpp/utils/fileutils.cpp +++ b/dd-sdk-android-ndk/src/main/cpp/utils/file-utils.cpp @@ -4,7 +4,7 @@ * Copyright 2016-Present Datadog, Inc. */ -#include "fileutils.h" +#include "file-utils.h" #include #include diff --git a/dd-sdk-android-ndk/src/main/cpp/utils/fileutils.h b/dd-sdk-android-ndk/src/main/cpp/utils/file-utils.h similarity index 100% rename from dd-sdk-android-ndk/src/main/cpp/utils/fileutils.h rename to dd-sdk-android-ndk/src/main/cpp/utils/file-utils.h diff --git a/dd-sdk-android-ndk/src/main/cpp/utils/format-utils.cpp b/dd-sdk-android-ndk/src/main/cpp/utils/format-utils.cpp new file mode 100644 index 0000000000..9b2496acd6 --- /dev/null +++ b/dd-sdk-android-ndk/src/main/cpp/utils/format-utils.cpp @@ -0,0 +1,17 @@ +#include + +namespace strformat { + + template + std::string format(std::string formatter, Args... args) { + using namespace std; + const auto formatter_value = formatter.c_str(); + // first we compute the size of the buffer required to format this string + // we will add 1 at the end for the end of string /0 character + const size_t size = snprintf(nullptr, 0, formatter_value, args...) + 1; + char buffer[size]; + snprintf(buffer, size, formatter_value, args...); + return string(buffer, buffer + size - 1); + } + +} diff --git a/dd-sdk-android-ndk/src/main/cpp/utils/signal-monitor.c b/dd-sdk-android-ndk/src/main/cpp/utils/signal-monitor.c index cec4ff72b7..2eb0f1c268 100644 --- a/dd-sdk-android-ndk/src/main/cpp/utils/signal-monitor.c +++ b/dd-sdk-android-ndk/src/main/cpp/utils/signal-monitor.c @@ -18,7 +18,8 @@ #include #include -#include "../datadog-native-lib.h" +#include "backtrace-handler.h" +#include "datadog-native-lib.h" static const char *LOG_TAG = "DatadogNdkCrashReporter"; static sigset_t signals_mask; @@ -70,7 +71,7 @@ static const struct signal handled_signals[] = { {SIGILL, "SIGILL", "Illegal instruction"}, {SIGBUS, "SIGBUS", "Bus error (bad memory access)"}, {SIGFPE, "SIGFPE", "Floating-point exception"}, - {SIGABRT, "SIGABRT", "The process was terminated"}, + {SIGABRT, "SIGABRT", "The process was terminated"}, {SIGSEGV, "SIGSEGV", "Segmentation violation (invalid memory reference)"}, {SIGQUIT, "SIGQUIT", "Application Not Responding"} }; @@ -121,9 +122,14 @@ void handle_signal(int signum, siginfo_t *info, void *user_context) { for (int i = 0; i < signals_array_size; i++) { const int signal = handled_signals[i].signal_value; if (signal == signum) { - crash_signal_intercepted(signal, - handled_signals[i].signal_name, - handled_signals[i].signal_error_message); + char backtrace[max_stack_size]; + // in case the stacktrace is bigger than the required size it will be truncated + generate_backtrace(backtrace, max_stack_size); + write_crash_report(signal, + handled_signals[i].signal_name, + handled_signals[i].signal_error_message, + backtrace); + break; } } diff --git a/dd-sdk-android-ndk/src/main/cpp/utils/stringutils.cpp b/dd-sdk-android-ndk/src/main/cpp/utils/string-utils.cpp similarity index 92% rename from dd-sdk-android-ndk/src/main/cpp/utils/stringutils.cpp rename to dd-sdk-android-ndk/src/main/cpp/utils/string-utils.cpp index 243b4c2b99..d6a3d733b2 100644 --- a/dd-sdk-android-ndk/src/main/cpp/utils/stringutils.cpp +++ b/dd-sdk-android-ndk/src/main/cpp/utils/string-utils.cpp @@ -4,10 +4,11 @@ * Copyright 2016-Present Datadog, Inc. */ -#include "stringutils.h" +#include "string-utils.h" #include #include +#include namespace stringutils { @@ -24,4 +25,5 @@ namespace stringutils { return result; } + } diff --git a/dd-sdk-android-ndk/src/main/cpp/utils/stringutils.h b/dd-sdk-android-ndk/src/main/cpp/utils/string-utils.h similarity index 100% rename from dd-sdk-android-ndk/src/main/cpp/utils/stringutils.h rename to dd-sdk-android-ndk/src/main/cpp/utils/string-utils.h diff --git a/dd-sdk-android-ndk/src/main/kotlin/com/datadog/android/ndk/NdkCrashReportsPlugin.kt b/dd-sdk-android-ndk/src/main/kotlin/com/datadog/android/ndk/NdkCrashReportsPlugin.kt index cae93f42ee..2b2707177b 100644 --- a/dd-sdk-android-ndk/src/main/kotlin/com/datadog/android/ndk/NdkCrashReportsPlugin.kt +++ b/dd-sdk-android-ndk/src/main/kotlin/com/datadog/android/ndk/NdkCrashReportsPlugin.kt @@ -46,14 +46,12 @@ class NdkCrashReportsPlugin : DatadogPlugin { } val ndkCrashesDirs = File( - config.context.filesDir.absolutePath + - File.separator + - config.featurePersistenceDirName + config.context.filesDir, + NDK_CRASH_REPORTS_FOLDER ) + ndkCrashesDirs.mkdirs() registerSignalHandler( ndkCrashesDirs.absolutePath, - config.serviceName, - config.envName, consentToInt(config.trackingConsent) ) } @@ -65,12 +63,7 @@ class NdkCrashReportsPlugin : DatadogPlugin { unregisterSignalHandler() } - override fun onContextChanged(context: DatadogContext) { - // TODO: RUMM-637 Only update the rum context if the `bundleWithRum` config attribute is true - context.rum?.let { - updateRumContext(it.applicationId, it.sessionId, it.viewId) - } - } + override fun onContextChanged(context: DatadogContext) {} // endregion @@ -94,24 +87,17 @@ class NdkCrashReportsPlugin : DatadogPlugin { private external fun registerSignalHandler( storagePath: String, - serviceName: String, - environment: String, consent: Int ) private external fun unregisterSignalHandler() - private external fun updateRumContext( - applicationId: String?, - sessionId: String?, - viewId: String? - ) - private external fun updateTrackingConsent(consent: Int) // endregion companion object { + internal const val NDK_CRASH_REPORTS_FOLDER = "ndk_crash_reports" private const val TAG: String = "NdkCrashReportsPlugin" private const val ERROR_LOADING_NATIVE_MESSAGE: String = "We could not load the native library" diff --git a/dd-sdk-android-ndk/src/test/cpp/CMakeLists.txt b/dd-sdk-android-ndk/src/test/cpp/CMakeLists.txt index 8e6f2fde6d..d6788becfd 100644 --- a/dd-sdk-android-ndk/src/test/cpp/CMakeLists.txt +++ b/dd-sdk-android-ndk/src/test/cpp/CMakeLists.txt @@ -7,11 +7,13 @@ add_library( datadog-native-lib-test SHARED integration-tests.cpp - test_datetime_utils.cpp - test_generate_backtrace.cpp - test_signal_monitor.cpp - test_utils.cpp - test_utils.h + test-crash-log.cpp + test-datetime-utils.cpp + test-format-utils.cpp + test-generate-backtrace.cpp + test-signal-monitor.cpp + test-utils.cpp + test-utils.h ) find_library( # Sets the name of the path variable. log-lib diff --git a/dd-sdk-android-ndk/src/test/cpp/integration-tests.cpp b/dd-sdk-android-ndk/src/test/cpp/integration-tests.cpp index 5d5dd0069f..1347804114 100644 --- a/dd-sdk-android-ndk/src/test/cpp/integration-tests.cpp +++ b/dd-sdk-android-ndk/src/test/cpp/integration-tests.cpp @@ -4,10 +4,7 @@ #include #include #include "greatest/greatest.h" -#include "utils/stringutils.h" - -// override the GREATES_PRINTF macro to use the android logcat -#define GREATEST_FPRINTF(ignore, fmt, ...) __android_log_print(ANDROID_LOG_INFO, "DatadogNDKTests", fmt, ##__VA_ARGS__) +#include "utils/string-utils.h" SUITE (datetime_utils); @@ -15,6 +12,10 @@ SUITE (backtrace_generation); SUITE (signal_monitor); +SUITE (string_utils); + +SUITE (crash_log); + GREATEST_MAIN_DEFS(); @@ -46,6 +47,8 @@ int run_test_suites() { GREATEST_MAIN_BEGIN(); RUN_SUITE(datetime_utils); RUN_SUITE(signal_monitor); + RUN_SUITE(string_utils); + RUN_SUITE(crash_log); // This test fails on Bitrise on the first backtrace line assertion even and was not able to // detect why so far. My guess is related with Linux environment, I actually logged the line // and checked the regEx on top and was passing locally. We will disable this test for now as @@ -57,8 +60,9 @@ int run_test_suites() { void test_generate_log( const int signal, const char *signal_name, - const char *signal_error_message) { - crash_signal_intercepted(signal, signal_name, signal_error_message); + const char *signal_error_message, + const char *error_stack) { + write_crash_report(signal, signal_name, signal_error_message, error_stack); } @@ -74,31 +78,32 @@ Java_com_datadog_android_ndk_NdkTests_runNdkStandaloneTests(JNIEnv *env, jobject extern "C" JNIEXPORT void JNICALL -Java_com_datadog_android_ndk_NdkTests_initNdkErrorHandler(JNIEnv *env, jobject thiz, - jstring storage_dir, - jstring service_name, - jstring env_name, - jstring app_id, - jstring session_id, - jstring view_id) { - - update_main_context(env, storage_dir, service_name, env_name); - update_rum_context(env, app_id, session_id, view_id); +Java_com_datadog_android_ndk_NdkTests_initNdkErrorHandler( + JNIEnv *env, + jobject thiz, + jstring storage_dir) { + + update_main_context(env, storage_dir); } extern "C" JNIEXPORT void JNICALL -Java_com_datadog_android_ndk_NdkTests_simulateSignalInterception(JNIEnv *env, jobject thiz, - jint signal, - jstring signal_name, - jstring signal_message) { +Java_com_datadog_android_ndk_NdkTests_simulateSignalInterception( + JNIEnv *env, + jobject thiz, + jint signal, + jstring signal_name, + jstring error_message, + jstring error_stack) { const int c_signal = (int) signal; const char *name = env->GetStringUTFChars(signal_name, 0); - const char *message = env->GetStringUTFChars(signal_message, 0); - test_generate_log(c_signal, name, message); + const char *message = env->GetStringUTFChars(error_message, 0); + const char *stack = env->GetStringUTFChars(error_stack, 0); + test_generate_log(c_signal, name, message, stack); env->ReleaseStringUTFChars(signal_name, name); - env->ReleaseStringUTFChars(signal_message, message); + env->ReleaseStringUTFChars(error_message, message); + env->ReleaseStringUTFChars(error_stack, stack); } extern "C" diff --git a/dd-sdk-android-ndk/src/test/cpp/test-crash-log.cpp b/dd-sdk-android-ndk/src/test/cpp/test-crash-log.cpp new file mode 100644 index 0000000000..2c4019cc93 --- /dev/null +++ b/dd-sdk-android-ndk/src/test/cpp/test-crash-log.cpp @@ -0,0 +1,49 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +#include +#include "greatest/greatest.h" +#include "model/crash-log.h" + +using namespace std; + +TEST test_will_serialise_the_crash_log(void) { + const string fake_error_message = "an error message"; + const string fake_error_stacktrace = "an error stacktrace"; + const int fake_error_signal = 2; + const uint64_t fake_timestamp = 100; + const string fake_signal_name = "a signal name"; + unique_ptr crashLogPointer = make_unique(fake_error_signal, + fake_timestamp, + fake_signal_name, + fake_error_message, + fake_error_stacktrace); + const string expected_serialised_log = string("{\"signal\":") + .append(to_string(fake_error_signal)) + .append(",\"timestamp\":") + .append(to_string(fake_timestamp)) + .append(",\"signal_name\":") + .append("\"") + .append(fake_signal_name) + .append("\"") + .append(",\"message\":") + .append("\"") + .append(fake_error_message) + .append("\"") + .append(",\"stacktrace\":") + .append("\"") + .append(fake_error_stacktrace) + .append("\"") + .append("}"); + const string serialised_log = crashLogPointer->serialise(); + ASSERT_STR_EQ(serialised_log.c_str(), expected_serialised_log.c_str()); + PASS(); +} + +SUITE (crash_log) { + RUN_TEST(test_will_serialise_the_crash_log); +} + diff --git a/dd-sdk-android-ndk/src/test/cpp/test_datetime_utils.cpp b/dd-sdk-android-ndk/src/test/cpp/test-datetime-utils.cpp similarity index 92% rename from dd-sdk-android-ndk/src/test/cpp/test_datetime_utils.cpp rename to dd-sdk-android-ndk/src/test/cpp/test-datetime-utils.cpp index 66fd9b457f..bd5f8345ba 100644 --- a/dd-sdk-android-ndk/src/test/cpp/test_datetime_utils.cpp +++ b/dd-sdk-android-ndk/src/test/cpp/test-datetime-utils.cpp @@ -1,7 +1,6 @@ #include - #include "greatest/greatest.h" -#include "utils/datetime.h" +#include "utils/datetime-utils.h" TEST test_generate_event_date_format(void) { char buffer[100]; diff --git a/dd-sdk-android-ndk/src/test/cpp/test-format-utils.cpp b/dd-sdk-android-ndk/src/test/cpp/test-format-utils.cpp new file mode 100644 index 0000000000..e72a3d0778 --- /dev/null +++ b/dd-sdk-android-ndk/src/test/cpp/test-format-utils.cpp @@ -0,0 +1,51 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +#include +#include +#include "greatest/greatest.h" +#include "utils/format-utils.cpp" + +TEST test_generates_formatted_string(void) { + const std::string formatter = "Given %s will return %s from %s"; + const std::string formatted_string = strformat::format(formatter, "A", "B", "C"); + const std::string expected_formatted_string = "Given A will return B from C"; + ASSERT_STR_EQ(formatted_string.c_str(), expected_formatted_string.c_str()); + PASS(); +} + +TEST test_does_not_throw_exception_if_not_enough_arguments(void) { + const std::string formatter = "%s %s %s %s"; + std::runtime_error *expected_error = nullptr; + try { + const std::string formatted_string = strformat::format("%s %s %s %s", "A", "B", "C"); + } + catch (std::runtime_error &error) { + expected_error = &error; + } + ASSERT(expected_error == nullptr); + PASS(); +} + +TEST test_does_not_throw_exception_if_not_enough_arguments_placeholders(void) { + const std::string formatter = "%s %s %s %s"; + std::runtime_error *expected_error = nullptr; + try { + const std::string formatted_string = strformat::format("%s", "A", "B", "C"); + } + catch (std::runtime_error &error) { + expected_error = &error; + } + ASSERT(expected_error == nullptr); + PASS(); +} + +SUITE (string_utils) { + RUN_TEST(test_generates_formatted_string); + RUN_TEST(test_does_not_throw_exception_if_not_enough_arguments); + RUN_TEST(test_does_not_throw_exception_if_not_enough_arguments_placeholders); +} + diff --git a/dd-sdk-android-ndk/src/test/cpp/test-generate-backtrace.cpp b/dd-sdk-android-ndk/src/test/cpp/test-generate-backtrace.cpp new file mode 100644 index 0000000000..69a87b1c8e --- /dev/null +++ b/dd-sdk-android-ndk/src/test/cpp/test-generate-backtrace.cpp @@ -0,0 +1,49 @@ +#include "test-utils.h" + +#include +#include +#include + +#include "greatest/greatest.h" +#include "utils/backtrace-handler.h" + +TEST test_generate_backtrace(void) { + char backtrace[max_stack_size]; + const bool was_successful = generate_backtrace(backtrace, max_stack_size); + std::list backtrace_lines = testutils::split_backtrace_into_lines( + backtrace); + // we don't know if the stack is big enough to cover the required max size of 30 + unsigned int lines_count = backtrace_lines.size(); + ASSERT(was_successful); + const char *regex = "(\\d+)(.*)0[xX][0-9a-fA-F]+(.*)"; + ASSERT(lines_count > 0 && lines_count <= max_stack_frames); + for (auto it = backtrace_lines.begin(); it != backtrace_lines.end(); ++it) { + ASSERT(std::regex_match(it->c_str(), std::regex(regex))); + } + PASS(); +} + +TEST test_generate_backtrace_will_return_false_if_size_is_exceeded(void) { + const size_t backtrace_size = 1; + char backtrace[backtrace_size]; + const bool was_successful = generate_backtrace(backtrace, backtrace_size); + ASSERT_FALSE(was_successful); + PASS(); +} + +TEST test_generate_backtrace_will_return_truncated_string_if_size_is_exceeded(void) { + const size_t backtrace_size = 3; + char backtrace[backtrace_size]; + const bool was_successful = generate_backtrace(backtrace, backtrace_size); + ASSERT_FALSE(was_successful); + ASSERT(backtrace[0] != '\0'); + ASSERT(backtrace[1] != '\0'); + PASS(); +} + + +SUITE (backtrace_generation) { + RUN_TEST(test_generate_backtrace); + RUN_TEST(test_generate_backtrace_will_return_false_if_size_is_exceeded); + RUN_TEST(test_generate_backtrace_will_return_truncated_string_if_size_is_exceeded); +} diff --git a/dd-sdk-android-ndk/src/test/cpp/test_signal_monitor.cpp b/dd-sdk-android-ndk/src/test/cpp/test-signal-monitor.cpp similarity index 100% rename from dd-sdk-android-ndk/src/test/cpp/test_signal_monitor.cpp rename to dd-sdk-android-ndk/src/test/cpp/test-signal-monitor.cpp diff --git a/dd-sdk-android-ndk/src/test/cpp/test_utils.cpp b/dd-sdk-android-ndk/src/test/cpp/test-utils.cpp similarity index 83% rename from dd-sdk-android-ndk/src/test/cpp/test_utils.cpp rename to dd-sdk-android-ndk/src/test/cpp/test-utils.cpp index 387cc02b64..2cb6e299b3 100644 --- a/dd-sdk-android-ndk/src/test/cpp/test_utils.cpp +++ b/dd-sdk-android-ndk/src/test/cpp/test-utils.cpp @@ -1,4 +1,11 @@ -#include "test_utils.h" +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + + +#include "test-utils.h" #include #include diff --git a/dd-sdk-android-ndk/src/test/cpp/test_utils.h b/dd-sdk-android-ndk/src/test/cpp/test-utils.h similarity index 100% rename from dd-sdk-android-ndk/src/test/cpp/test_utils.h rename to dd-sdk-android-ndk/src/test/cpp/test-utils.h diff --git a/dd-sdk-android-ndk/src/test/cpp/test_generate_backtrace.cpp b/dd-sdk-android-ndk/src/test/cpp/test_generate_backtrace.cpp deleted file mode 100644 index 4ca85717e0..0000000000 --- a/dd-sdk-android-ndk/src/test/cpp/test_generate_backtrace.cpp +++ /dev/null @@ -1,27 +0,0 @@ -#include "test_utils.h" - -#include -#include -#include - -#include "greatest/greatest.h" -#include "utils/backtrace-handler.h" - -TEST test_generate_backtrace(void) { - std::string backtrace = backtrace::generate_backtrace(); - std::list backtrace_lines = testutils::split_backtrace_into_lines( - backtrace.c_str()); - // we don't know if the stack is big enough to cover the required max size of 30 - unsigned int lines_count = backtrace_lines.size(); - const char *regex = "(\\d+)(.*)0[xX][0-9a-fA-F]+(.*)"; - ASSERT(lines_count > 0 && lines_count <= 30); - for (auto it = backtrace_lines.begin(); it != backtrace_lines.end(); ++it) { - ASSERT(std::regex_match(it->c_str(), std::regex(regex))); - } - PASS(); -} - - -SUITE (backtrace_generation) { - RUN_TEST(test_generate_backtrace); -} diff --git a/dd-sdk-android-ndk/src/test/cpp/utils/greatest/greatest.h b/dd-sdk-android-ndk/src/test/cpp/utils/greatest/greatest.h index 1209c0510f..06353e2538 100644 --- a/dd-sdk-android-ndk/src/test/cpp/utils/greatest/greatest.h +++ b/dd-sdk-android-ndk/src/test/cpp/utils/greatest/greatest.h @@ -125,7 +125,7 @@ int main(int argc, char **argv) { /* Make it possible to replace fprintf with another * function with the same interface. */ #ifndef GREATEST_FPRINTF -#define GREATEST_FPRINTF fprintf +#define GREATEST_FPRINTF(ignore, fmt, ...) __android_log_print(ANDROID_LOG_INFO, "DatadogNDKTests", fmt, ##__VA_ARGS__) #endif #if GREATEST_USE_LONGJMP diff --git a/dd-sdk-android-ndk/src/test/kotlin/NdkCrashReportsPluginTest.kt b/dd-sdk-android-ndk/src/test/kotlin/NdkCrashReportsPluginTest.kt index 19b4f3accb..bcbb07e991 100644 --- a/dd-sdk-android-ndk/src/test/kotlin/NdkCrashReportsPluginTest.kt +++ b/dd-sdk-android-ndk/src/test/kotlin/NdkCrashReportsPluginTest.kt @@ -4,14 +4,22 @@ * Copyright 2016-Present Datadog, Inc. */ +import android.content.Context import com.datadog.android.ndk.NdkCrashReportsPlugin +import com.datadog.android.plugin.DatadogPluginConfig import com.datadog.android.privacy.TrackingConsent +import com.datadog.tools.unit.setFieldValue +import com.nhaarman.mockitokotlin2.mock +import com.nhaarman.mockitokotlin2.whenever +import fr.xgouchet.elmyr.Forge import fr.xgouchet.elmyr.junit5.ForgeExtension +import java.io.File import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith import org.junit.jupiter.api.extension.Extensions +import org.junit.jupiter.api.io.TempDir import org.mockito.junit.jupiter.MockitoExtension import org.mockito.junit.jupiter.MockitoSettings import org.mockito.quality.Strictness @@ -25,6 +33,9 @@ internal class NdkCrashReportsPluginTest { lateinit var testedPlugin: NdkCrashReportsPlugin + @TempDir + lateinit var tempDir: File + @BeforeEach fun `set up`() { testedPlugin = NdkCrashReportsPlugin() @@ -50,4 +61,61 @@ internal class NdkCrashReportsPluginTest { NdkCrashReportsPlugin.TRACKING_CONSENT_NOT_GRANTED ) } + + @Test + fun `M create the NDK crash reports directory W register { nativeLibrary loaded }`( + forge: Forge + ) { + // GIVEN + val mockedContext: Context = mock { + whenever(it.filesDir).thenReturn(tempDir) + } + val config = DatadogPluginConfig( + mockedContext, + forge.anAlphabeticalString(), + forge.anAlphabeticalString(), + forge.anAlphabeticalString(), + forge.aValueFrom(TrackingConsent::class.java) + ) + testedPlugin.setFieldValue("nativeLibraryLoaded", true) + + // WHEN + try { + testedPlugin.register(config) + } catch (e: UnsatisfiedLinkError) { + // Do nothing. Just to avoid the NDK linkage error. + } + + // THEN + val ndkCrashDirectory = File(tempDir, NdkCrashReportsPlugin.NDK_CRASH_REPORTS_FOLDER) + assertThat(ndkCrashDirectory.exists()).isTrue() + } + + @Test + fun `M do nothing W register { nativeLibrary not loaded }`( + forge: Forge + ) { + // GIVEN + val mockedContext: Context = mock { + whenever(it.filesDir).thenReturn(tempDir) + } + val config = DatadogPluginConfig( + mockedContext, + forge.anAlphabeticalString(), + forge.anAlphabeticalString(), + forge.anAlphabeticalString(), + forge.aValueFrom(TrackingConsent::class.java) + ) + + // WHEN + try { + testedPlugin.register(config) + } catch (e: UnsatisfiedLinkError) { + // Do nothing. Just to avoid the NDK linkage error. + } + + // THEN + val ndkCrashDirectory = File(tempDir, NdkCrashReportsPlugin.NDK_CRASH_REPORTS_FOLDER) + assertThat(ndkCrashDirectory.exists()).isFalse() + } } diff --git a/dd-sdk-android/apiSurface b/dd-sdk-android/apiSurface index beca581764..d68075d1d2 100644 --- a/dd-sdk-android/apiSurface +++ b/dd-sdk-android/apiSurface @@ -67,7 +67,7 @@ class com.datadog.android.DatadogEventListener : okhttp3.EventListener class Factory : okhttp3.EventListener.Factory override fun create(okhttp3.Call): okhttp3.EventListener open class com.datadog.android.DatadogInterceptor : com.datadog.android.tracing.TracingInterceptor - DEPRECATED constructor(List, com.datadog.android.tracing.TracedRequestListener = NoOpTracedRequestListener()) + constructor(List, com.datadog.android.tracing.TracedRequestListener = NoOpTracedRequestListener()) constructor(com.datadog.android.tracing.TracedRequestListener = NoOpTracedRequestListener()) override fun intercept(okhttp3.Interceptor.Chain): okhttp3.Response override fun onRequestIntercepted(okhttp3.Request, io.opentracing.Span?, okhttp3.Response?, Throwable?) @@ -229,6 +229,7 @@ object com.datadog.android.rum.RumAttributes const val SOURCE: String const val VARIANT: String const val SDK_VERSION: String + const val INTERNAL_ERROR_TYPE: String const val INTERNAL_TIMESTAMP: String const val TRACE_ID: String const val SPAN_ID: String @@ -259,6 +260,7 @@ enum com.datadog.android.rum.RumErrorSource - AGENT - WEBVIEW class com.datadog.android.rum.RumInterceptor : com.datadog.android.DatadogInterceptor + constructor(List = emptyList()) interface com.datadog.android.rum.RumMonitor fun startView(Any, String, Map = emptyMap()) fun stopView(Any, Map = emptyMap()) @@ -294,55 +296,90 @@ class com.datadog.android.rum.model.ActionEvent constructor(kotlin.Long, Application, kotlin.String? = null, Session, View, Usr? = null, Connectivity? = null, Dd, Action) val type: kotlin.String fun toJson(): com.google.gson.JsonElement + companion object + fun fromJson(kotlin.String): ActionEvent class Application constructor(kotlin.String) fun toJson(): com.google.gson.JsonElement + companion object + fun fromJson(kotlin.String): Application class Session - constructor(kotlin.String, SessionType) + constructor(kotlin.String, SessionType, kotlin.Boolean? = null) fun toJson(): com.google.gson.JsonElement + companion object + fun fromJson(kotlin.String): Session class View constructor(kotlin.String, kotlin.String? = null, kotlin.String) fun toJson(): com.google.gson.JsonElement + companion object + fun fromJson(kotlin.String): View class Usr constructor(kotlin.String? = null, kotlin.String? = null, kotlin.String? = null) fun toJson(): com.google.gson.JsonElement + companion object + fun fromJson(kotlin.String): Usr class Connectivity constructor(Status, kotlin.collections.List, Cellular? = null) fun toJson(): com.google.gson.JsonElement + companion object + fun fromJson(kotlin.String): Connectivity class Dd val formatVersion: kotlin.Long fun toJson(): com.google.gson.JsonElement + companion object + fun fromJson(kotlin.String): Dd class Action constructor(ActionType, kotlin.String? = null, kotlin.Long? = null, Target? = null, Error? = null, Crash? = null, LongTask? = null, Resource? = null) fun toJson(): com.google.gson.JsonElement + companion object + fun fromJson(kotlin.String): Action class Cellular constructor(kotlin.String? = null, kotlin.String? = null) fun toJson(): com.google.gson.JsonElement + companion object + fun fromJson(kotlin.String): Cellular class Target constructor(kotlin.String) fun toJson(): com.google.gson.JsonElement + companion object + fun fromJson(kotlin.String): Target class Error constructor(kotlin.Long) fun toJson(): com.google.gson.JsonElement + companion object + fun fromJson(kotlin.String): Error class Crash constructor(kotlin.Long) fun toJson(): com.google.gson.JsonElement + companion object + fun fromJson(kotlin.String): Crash class LongTask constructor(kotlin.Long) fun toJson(): com.google.gson.JsonElement + companion object + fun fromJson(kotlin.String): LongTask class Resource constructor(kotlin.Long) fun toJson(): com.google.gson.JsonElement + companion object + fun fromJson(kotlin.String): Resource enum SessionType + constructor(kotlin.String) - USER - SYNTHETICS fun toJson(): com.google.gson.JsonElement + companion object + fun fromJson(kotlin.String): SessionType enum Status + constructor(kotlin.String) - CONNECTED - NOT_CONNECTED - MAYBE fun toJson(): com.google.gson.JsonElement + companion object + fun fromJson(kotlin.String): Status enum Interface + constructor(kotlin.String) - BLUETOOTH - CELLULAR - ETHERNET @@ -353,7 +390,10 @@ class com.datadog.android.rum.model.ActionEvent - UNKNOWN - NONE fun toJson(): com.google.gson.JsonElement + companion object + fun fromJson(kotlin.String): Interface enum ActionType + constructor(kotlin.String) - CUSTOM - CLICK - TAP @@ -362,53 +402,86 @@ class com.datadog.android.rum.model.ActionEvent - APPLICATION_START - BACK fun toJson(): com.google.gson.JsonElement + companion object + fun fromJson(kotlin.String): ActionType class com.datadog.android.rum.model.ErrorEvent constructor(kotlin.Long, Application, kotlin.String? = null, Session, View, Usr? = null, Connectivity? = null, Dd, Error, Action? = null) val type: kotlin.String fun toJson(): com.google.gson.JsonElement + companion object + fun fromJson(kotlin.String): ErrorEvent class Application constructor(kotlin.String) fun toJson(): com.google.gson.JsonElement + companion object + fun fromJson(kotlin.String): Application class Session - constructor(kotlin.String, SessionType) + constructor(kotlin.String, SessionType, kotlin.Boolean? = null) fun toJson(): com.google.gson.JsonElement + companion object + fun fromJson(kotlin.String): Session class View constructor(kotlin.String, kotlin.String? = null, kotlin.String) fun toJson(): com.google.gson.JsonElement + companion object + fun fromJson(kotlin.String): View class Usr constructor(kotlin.String? = null, kotlin.String? = null, kotlin.String? = null) fun toJson(): com.google.gson.JsonElement + companion object + fun fromJson(kotlin.String): Usr class Connectivity constructor(Status, kotlin.collections.List, Cellular? = null) fun toJson(): com.google.gson.JsonElement + companion object + fun fromJson(kotlin.String): Connectivity class Dd val formatVersion: kotlin.Long fun toJson(): com.google.gson.JsonElement + companion object + fun fromJson(kotlin.String): Dd class Error - constructor(kotlin.String, Source, kotlin.String? = null, kotlin.Boolean? = null, Resource? = null) + constructor(kotlin.String, Source, kotlin.String? = null, kotlin.Boolean? = null, kotlin.String? = null, Resource? = null) fun toJson(): com.google.gson.JsonElement + companion object + fun fromJson(kotlin.String): Error class Action constructor(kotlin.String) fun toJson(): com.google.gson.JsonElement + companion object + fun fromJson(kotlin.String): Action class Cellular constructor(kotlin.String? = null, kotlin.String? = null) fun toJson(): com.google.gson.JsonElement + companion object + fun fromJson(kotlin.String): Cellular class Resource constructor(Method, kotlin.Long, kotlin.String, Provider? = null) fun toJson(): com.google.gson.JsonElement + companion object + fun fromJson(kotlin.String): Resource class Provider constructor(kotlin.String? = null, kotlin.String? = null, ProviderType? = null) fun toJson(): com.google.gson.JsonElement + companion object + fun fromJson(kotlin.String): Provider enum SessionType + constructor(kotlin.String) - USER - SYNTHETICS fun toJson(): com.google.gson.JsonElement + companion object + fun fromJson(kotlin.String): SessionType enum Status + constructor(kotlin.String) - CONNECTED - NOT_CONNECTED - MAYBE fun toJson(): com.google.gson.JsonElement + companion object + fun fromJson(kotlin.String): Status enum Interface + constructor(kotlin.String) - BLUETOOTH - CELLULAR - ETHERNET @@ -419,7 +492,10 @@ class com.datadog.android.rum.model.ErrorEvent - UNKNOWN - NONE fun toJson(): com.google.gson.JsonElement + companion object + fun fromJson(kotlin.String): Interface enum Source + constructor(kotlin.String) - NETWORK - SOURCE - CONSOLE @@ -428,7 +504,10 @@ class com.datadog.android.rum.model.ErrorEvent - WEBVIEW - CUSTOM fun toJson(): com.google.gson.JsonElement + companion object + fun fromJson(kotlin.String): Source enum Method + constructor(kotlin.String) - POST - GET - HEAD @@ -436,7 +515,10 @@ class com.datadog.android.rum.model.ErrorEvent - DELETE - PATCH fun toJson(): com.google.gson.JsonElement + companion object + fun fromJson(kotlin.String): Method enum ProviderType + constructor(kotlin.String) - AD - ADVERTISING - ANALYTICS @@ -452,69 +534,112 @@ class com.datadog.android.rum.model.ErrorEvent - UTILITY - VIDEO fun toJson(): com.google.gson.JsonElement + companion object + fun fromJson(kotlin.String): ProviderType class com.datadog.android.rum.model.ResourceEvent constructor(kotlin.Long, Application, kotlin.String? = null, Session, View, Usr? = null, Connectivity? = null, Dd? = null, Resource, Action? = null) val type: kotlin.String fun toJson(): com.google.gson.JsonElement + companion object + fun fromJson(kotlin.String): ResourceEvent class Application constructor(kotlin.String) fun toJson(): com.google.gson.JsonElement + companion object + fun fromJson(kotlin.String): Application class Session - constructor(kotlin.String, SessionType) + constructor(kotlin.String, SessionType, kotlin.Boolean? = null) fun toJson(): com.google.gson.JsonElement + companion object + fun fromJson(kotlin.String): Session class View constructor(kotlin.String, kotlin.String? = null, kotlin.String) fun toJson(): com.google.gson.JsonElement + companion object + fun fromJson(kotlin.String): View class Usr constructor(kotlin.String? = null, kotlin.String? = null, kotlin.String? = null) fun toJson(): com.google.gson.JsonElement + companion object + fun fromJson(kotlin.String): Usr class Connectivity constructor(Status, kotlin.collections.List, Cellular? = null) fun toJson(): com.google.gson.JsonElement + companion object + fun fromJson(kotlin.String): Connectivity class Dd constructor(kotlin.String? = null, kotlin.String? = null) val formatVersion: kotlin.Long fun toJson(): com.google.gson.JsonElement + companion object + fun fromJson(kotlin.String): Dd class Resource constructor(kotlin.String? = null, ResourceType, Method? = null, kotlin.String, kotlin.Long? = null, kotlin.Long, kotlin.Long? = null, Redirect? = null, Dns? = null, Connect? = null, Ssl? = null, FirstByte? = null, Download? = null, Provider? = null) fun toJson(): com.google.gson.JsonElement + companion object + fun fromJson(kotlin.String): Resource class Action constructor(kotlin.String) fun toJson(): com.google.gson.JsonElement + companion object + fun fromJson(kotlin.String): Action class Cellular constructor(kotlin.String? = null, kotlin.String? = null) fun toJson(): com.google.gson.JsonElement + companion object + fun fromJson(kotlin.String): Cellular class Redirect constructor(kotlin.Long, kotlin.Long) fun toJson(): com.google.gson.JsonElement + companion object + fun fromJson(kotlin.String): Redirect class Dns constructor(kotlin.Long, kotlin.Long) fun toJson(): com.google.gson.JsonElement + companion object + fun fromJson(kotlin.String): Dns class Connect constructor(kotlin.Long, kotlin.Long) fun toJson(): com.google.gson.JsonElement + companion object + fun fromJson(kotlin.String): Connect class Ssl constructor(kotlin.Long, kotlin.Long) fun toJson(): com.google.gson.JsonElement + companion object + fun fromJson(kotlin.String): Ssl class FirstByte constructor(kotlin.Long, kotlin.Long) fun toJson(): com.google.gson.JsonElement + companion object + fun fromJson(kotlin.String): FirstByte class Download constructor(kotlin.Long, kotlin.Long) fun toJson(): com.google.gson.JsonElement + companion object + fun fromJson(kotlin.String): Download class Provider constructor(kotlin.String? = null, kotlin.String? = null, ProviderType? = null) fun toJson(): com.google.gson.JsonElement + companion object + fun fromJson(kotlin.String): Provider enum SessionType + constructor(kotlin.String) - USER - SYNTHETICS fun toJson(): com.google.gson.JsonElement + companion object + fun fromJson(kotlin.String): SessionType enum Status + constructor(kotlin.String) - CONNECTED - NOT_CONNECTED - MAYBE fun toJson(): com.google.gson.JsonElement + companion object + fun fromJson(kotlin.String): Status enum Interface + constructor(kotlin.String) - BLUETOOTH - CELLULAR - ETHERNET @@ -525,7 +650,10 @@ class com.datadog.android.rum.model.ResourceEvent - UNKNOWN - NONE fun toJson(): com.google.gson.JsonElement + companion object + fun fromJson(kotlin.String): Interface enum ResourceType + constructor(kotlin.String) - DOCUMENT - XHR - BEACON @@ -537,7 +665,10 @@ class com.datadog.android.rum.model.ResourceEvent - MEDIA - OTHER fun toJson(): com.google.gson.JsonElement + companion object + fun fromJson(kotlin.String): ResourceType enum Method + constructor(kotlin.String) - POST - GET - HEAD @@ -545,7 +676,10 @@ class com.datadog.android.rum.model.ResourceEvent - DELETE - PATCH fun toJson(): com.google.gson.JsonElement + companion object + fun fromJson(kotlin.String): Method enum ProviderType + constructor(kotlin.String) - AD - ADVERTISING - ANALYTICS @@ -561,52 +695,89 @@ class com.datadog.android.rum.model.ResourceEvent - UTILITY - VIDEO fun toJson(): com.google.gson.JsonElement + companion object + fun fromJson(kotlin.String): ProviderType class com.datadog.android.rum.model.ViewEvent constructor(kotlin.Long, Application, kotlin.String? = null, Session, View, Usr? = null, Connectivity? = null, Dd) val type: kotlin.String fun toJson(): com.google.gson.JsonElement + companion object + fun fromJson(kotlin.String): ViewEvent class Application constructor(kotlin.String) fun toJson(): com.google.gson.JsonElement + companion object + fun fromJson(kotlin.String): Application class Session - constructor(kotlin.String, Type) + constructor(kotlin.String, Type, kotlin.Boolean? = null) fun toJson(): com.google.gson.JsonElement + companion object + fun fromJson(kotlin.String): Session class View - constructor(kotlin.String, kotlin.String? = null, kotlin.String, kotlin.Long? = null, LoadingType? = null, kotlin.Long, kotlin.Long? = null, kotlin.Long? = null, kotlin.Long? = null, kotlin.Double? = null, kotlin.Long? = null, kotlin.Long? = null, kotlin.Long? = null, kotlin.Long? = null, kotlin.Boolean? = null, Action, Error, Crash? = null, LongTask? = null, Resource) + constructor(kotlin.String, kotlin.String? = null, kotlin.String, kotlin.Long? = null, LoadingType? = null, kotlin.Long, kotlin.Long? = null, kotlin.Long? = null, kotlin.Long? = null, kotlin.Long? = null, kotlin.Double? = null, kotlin.Long? = null, kotlin.Long? = null, kotlin.Long? = null, kotlin.Long? = null, CustomTimings? = null, kotlin.Boolean? = null, Action, Error, Crash? = null, LongTask? = null, Resource) fun toJson(): com.google.gson.JsonElement + companion object + fun fromJson(kotlin.String): View class Usr constructor(kotlin.String? = null, kotlin.String? = null, kotlin.String? = null) fun toJson(): com.google.gson.JsonElement + companion object + fun fromJson(kotlin.String): Usr class Connectivity constructor(Status, kotlin.collections.List, Cellular? = null) fun toJson(): com.google.gson.JsonElement + companion object + fun fromJson(kotlin.String): Connectivity class Dd constructor(kotlin.Long) val formatVersion: kotlin.Long fun toJson(): com.google.gson.JsonElement + companion object + fun fromJson(kotlin.String): Dd + class CustomTimings + constructor(kotlin.collections.Map = emptyMap()) + fun toJson(): com.google.gson.JsonElement + companion object + fun fromJson(kotlin.String): CustomTimings class Action constructor(kotlin.Long) fun toJson(): com.google.gson.JsonElement + companion object + fun fromJson(kotlin.String): Action class Error constructor(kotlin.Long) fun toJson(): com.google.gson.JsonElement + companion object + fun fromJson(kotlin.String): Error class Crash constructor(kotlin.Long) fun toJson(): com.google.gson.JsonElement + companion object + fun fromJson(kotlin.String): Crash class LongTask constructor(kotlin.Long) fun toJson(): com.google.gson.JsonElement + companion object + fun fromJson(kotlin.String): LongTask class Resource constructor(kotlin.Long) fun toJson(): com.google.gson.JsonElement + companion object + fun fromJson(kotlin.String): Resource class Cellular constructor(kotlin.String? = null, kotlin.String? = null) fun toJson(): com.google.gson.JsonElement + companion object + fun fromJson(kotlin.String): Cellular enum Type + constructor(kotlin.String) - USER - SYNTHETICS fun toJson(): com.google.gson.JsonElement + companion object + fun fromJson(kotlin.String): Type enum LoadingType + constructor(kotlin.String) - INITIAL_LOAD - ROUTE_CHANGE - ACTIVITY_DISPLAY @@ -616,12 +787,18 @@ class com.datadog.android.rum.model.ViewEvent - VIEW_CONTROLLER_DISPLAY - VIEW_CONTROLLER_REDISPLAY fun toJson(): com.google.gson.JsonElement + companion object + fun fromJson(kotlin.String): LoadingType enum Status + constructor(kotlin.String) - CONNECTED - NOT_CONNECTED - MAYBE fun toJson(): com.google.gson.JsonElement + companion object + fun fromJson(kotlin.String): Status enum Interface + constructor(kotlin.String) - BLUETOOTH - CELLULAR - ETHERNET @@ -632,6 +809,8 @@ class com.datadog.android.rum.model.ViewEvent - UNKNOWN - NONE fun toJson(): com.google.gson.JsonElement + companion object + fun fromJson(kotlin.String): Interface class com.datadog.android.rum.resource.RumResourceInputStream : java.io.InputStream constructor(java.io.InputStream, String) override fun read(): Int @@ -644,12 +823,15 @@ class com.datadog.android.rum.resource.RumResourceInputStream : java.io.InputStr override fun reset() override fun close() companion object -class com.datadog.android.rum.tracking.AcceptAllActivities : ComponentPredicate +open class com.datadog.android.rum.tracking.AcceptAllActivities : ComponentPredicate override fun accept(android.app.Activity): Boolean -class com.datadog.android.rum.tracking.AcceptAllDefaultFragment : ComponentPredicate + override fun getViewName(android.app.Activity): String? +open class com.datadog.android.rum.tracking.AcceptAllDefaultFragment : ComponentPredicate override fun accept(android.app.Fragment): Boolean -class com.datadog.android.rum.tracking.AcceptAllSupportFragments : ComponentPredicate + override fun getViewName(android.app.Fragment): String? +open class com.datadog.android.rum.tracking.AcceptAllSupportFragments : ComponentPredicate override fun accept(androidx.fragment.app.Fragment): Boolean + override fun getViewName(androidx.fragment.app.Fragment): String? abstract class com.datadog.android.rum.tracking.ActivityLifecycleTrackingStrategy : android.app.Application.ActivityLifecycleCallbacks, TrackingStrategy override fun register(android.content.Context) override fun unregister(android.content.Context?) @@ -672,6 +854,7 @@ class com.datadog.android.rum.tracking.ActivityViewTrackingStrategy : ActivityLi override fun onActivityDestroyed(android.app.Activity) interface com.datadog.android.rum.tracking.ComponentPredicate fun accept(T): Boolean + fun getViewName(T): String? class com.datadog.android.rum.tracking.FragmentViewTrackingStrategy : ActivityLifecycleTrackingStrategy, ViewTrackingStrategy constructor(Boolean, ComponentPredicate = AcceptAllSupportFragments(), ComponentPredicate = AcceptAllDefaultFragment()) override fun onActivityStarted(android.app.Activity) @@ -727,7 +910,7 @@ class com.datadog.android.tracing.AndroidTracer : com.datadog.opentracing.DDTrac interface com.datadog.android.tracing.TracedRequestListener fun onRequestIntercepted(okhttp3.Request, io.opentracing.Span, okhttp3.Response?, Throwable?) open class com.datadog.android.tracing.TracingInterceptor : okhttp3.Interceptor - DEPRECATED constructor(List, TracedRequestListener = NoOpTracedRequestListener()) + constructor(List, TracedRequestListener = NoOpTracedRequestListener()) constructor(TracedRequestListener = NoOpTracedRequestListener()) override fun intercept(okhttp3.Interceptor.Chain): okhttp3.Response protected open fun onRequestIntercepted(okhttp3.Request, io.opentracing.Span?, okhttp3.Response?, Throwable?) diff --git a/dd-sdk-android/src/main/json/_common-schema.json b/dd-sdk-android/src/main/json/_common-schema.json index 54df46c8ed..58c8addc43 100644 --- a/dd-sdk-android/src/main/json/_common-schema.json +++ b/dd-sdk-android/src/main/json/_common-schema.json @@ -58,6 +58,11 @@ "description": "Type of the session", "enum": ["user", "synthetics"], "readOnly": true + }, + "has_replay": { + "type": "boolean", + "description": "Whether this session has a replay", + "readOnly": true } }, "readOnly": true diff --git a/dd-sdk-android/src/main/json/error-schema.json b/dd-sdk-android/src/main/json/error-schema.json index 8a534d4138..25157427b8 100644 --- a/dd-sdk-android/src/main/json/error-schema.json +++ b/dd-sdk-android/src/main/json/error-schema.json @@ -49,6 +49,11 @@ "description": "Whether this error crashed the host application", "readOnly": true }, + "type": { + "type": "string", + "description": "The type of the error", + "readOnly": true + }, "resource": { "type": "object", "description": "Resource properties of the error", diff --git a/dd-sdk-android/src/main/json/view-schema.json b/dd-sdk-android/src/main/json/view-schema.json index cea3c2c525..8686bcc356 100644 --- a/dd-sdk-android/src/main/json/view-schema.json +++ b/dd-sdk-android/src/main/json/view-schema.json @@ -69,6 +69,12 @@ "minimum": 0, "readOnly": true }, + "first_input_time": { + "type": "integer", + "description": "Duration in ns to the first input", + "minimum": 0, + "readOnly": true + }, "cumulative_layout_shift": { "type": "number", "description": "Total layout shift score that occured on the view", @@ -99,6 +105,16 @@ "minimum": 0, "readOnly": true }, + "custom_timings": { + "type": "object", + "description": "User custom timings of the view", + "additionalProperties": { + "type": "integer", + "minimum": 0, + "readOnly": true + }, + "readOnly": true + }, "is_active": { "type": "boolean", "description": "Whether the View corresponding to this event is considered active", diff --git a/dd-sdk-android/src/main/kotlin/com/datadog/android/Datadog.kt b/dd-sdk-android/src/main/kotlin/com/datadog/android/Datadog.kt index 7a0fdd2afe..02cf04e8bb 100644 --- a/dd-sdk-android/src/main/kotlin/com/datadog/android/Datadog.kt +++ b/dd-sdk-android/src/main/kotlin/com/datadog/android/Datadog.kt @@ -19,10 +19,13 @@ import com.datadog.android.core.internal.utils.warnDeprecated import com.datadog.android.error.internal.CrashReportsFeature import com.datadog.android.log.internal.LogsFeature import com.datadog.android.log.internal.domain.Log +import com.datadog.android.log.internal.domain.LogGenerator import com.datadog.android.log.internal.user.UserInfo import com.datadog.android.privacy.TrackingConsent import com.datadog.android.rum.internal.RumFeature +import com.datadog.android.rum.internal.ndk.DatadogNdkCrashHandler import com.datadog.android.tracing.internal.TracesFeature +import java.io.File import java.lang.IllegalArgumentException import java.util.concurrent.atomic.AtomicBoolean @@ -33,7 +36,7 @@ import java.util.concurrent.atomic.AtomicBoolean object Datadog { internal val initialized = AtomicBoolean(false) - internal val startupTimeNs = System.nanoTime() + internal val startupTimeNs: Long = System.nanoTime() internal var libraryVerbosity = Int.MAX_VALUE private set @@ -149,6 +152,24 @@ object Datadog { initialized.set(true) + // handle NDK crash reports here + if (CoreFeature.isMainProcess) { + DatadogNdkCrashHandler( + File(context.filesDir, DatadogNdkCrashHandler.NDK_CRASH_REPORTS_FOLDER_NAME), + CoreFeature.persistenceExecutorService, + LogsFeature.persistenceStrategy.getWriter(), + RumFeature.persistenceStrategy.getWriter(), + LogGenerator( + CoreFeature.serviceName, + DatadogNdkCrashHandler.LOGGER_NAME, + CoreFeature.networkInfoProvider, + CoreFeature.userInfoProvider, + CoreFeature.envName, + CoreFeature.packageVersion + ) + ).handleNdkCrash() + } + // Issue #154 (β€œThread starting during runtime shutdown”) // Make sure we stop Datadog when the Runtime shuts down Runtime.getRuntime() @@ -189,6 +210,9 @@ object Datadog { appContext: Context ) { if (configuration != null) { + if (CoreFeature.rumApplicationId.isNullOrBlank()) { + devLogger.w(WARNING_MESSAGE_APPLICATION_ID_IS_NULL) + } RumFeature.initialize(appContext, configuration) } } @@ -301,11 +325,19 @@ object Datadog { internal const val MESSAGE_ALREADY_INITIALIZED = "The Datadog library has already been initialized." + internal const val WARNING_MESSAGE_APPLICATION_ID_IS_NULL = + "You're trying to enable RUM but no Application Id was provided. " + + "Please pass this value into the Datadog Credentials:\n" + + "val credentials = " + + "Credentials" + + "(\"\", \"\", \"\", \"\")\n" + + "Datadog.initialize(context, credentials, configuration, trackingConsent);" + internal const val MESSAGE_NOT_INITIALIZED = "Datadog has not been initialized.\n" + "Please add the following code in your application's onCreate() method:\n" + - "val config = DatadogConfig.Builder(\"\", \"\", " + - "\"\").build()\n" + - "Datadog.initialize(context, config);" + "val credentials = Credentials" + + "(\"\", \"\", \"\", \"\")\n" + + "Datadog.initialize(context, credentials, configuration, trackingConsent);" internal const val MESSAGE_DEPRECATED = "%s has been deprecated. " + "If you need it, submit an issue at https://github.com/DataDog/dd-sdk-android/issues/" diff --git a/dd-sdk-android/src/main/kotlin/com/datadog/android/DatadogConfig.kt b/dd-sdk-android/src/main/kotlin/com/datadog/android/DatadogConfig.kt index 2d9881a800..658369bd57 100644 --- a/dd-sdk-android/src/main/kotlin/com/datadog/android/DatadogConfig.kt +++ b/dd-sdk-android/src/main/kotlin/com/datadog/android/DatadogConfig.kt @@ -258,7 +258,7 @@ private constructor( */ fun setRumEnabled(enabled: Boolean): Builder { if (enabled && rumConfig.applicationId == UUID(0, 0)) { - devLogger.w(RUM_NOT_INITIALISED_WARNING_MESSAGE) + devLogger.w(Datadog.WARNING_MESSAGE_APPLICATION_ID_IS_NULL) return this } rumEnabled = enabled @@ -536,12 +536,6 @@ private constructor( companion object { private val URL_REGEX = Regex("^(http|https)://(.*)") - internal const val RUM_NOT_INITIALISED_WARNING_MESSAGE = - "You're trying to enable RUM but no Application Id was provided. " + - "Please use the following line to create your DatadogConfig:\n" + - "val config = " + - "DatadogConfig.Builder" + - "(\"\", \"\", \"\").build()" } } diff --git a/dd-sdk-android/src/main/kotlin/com/datadog/android/DatadogInterceptor.kt b/dd-sdk-android/src/main/kotlin/com/datadog/android/DatadogInterceptor.kt index f66547468c..a834668b3e 100644 --- a/dd-sdk-android/src/main/kotlin/com/datadog/android/DatadogInterceptor.kt +++ b/dd-sdk-android/src/main/kotlin/com/datadog/android/DatadogInterceptor.kt @@ -86,27 +86,21 @@ internal constructor( * Creates a [TracingInterceptor] to automatically create a trace around OkHttp [Request]s, and * track RUM Resources. * - * @param tracedHosts a list of all the hosts that you want to be automatically tracked - * by our APM [TracingInterceptor]. If no host provided the interceptor won't trace - * any OkHttp [Request], nor propagate tracing information to the backend. - * Please note that the host constraint will only be applied on the [TracingInterceptor] and we - * will continue to dispatch RUM Resource events for each request without applying any host - * filtering. + * @param firstPartyHosts the list of first party hosts. + * Requests made to a URL with any one of these hosts (or any subdomain) will: + * - be considered a first party RUM Resource and categorised as such in your RUM dashboard; + * - be wrapped in a Span and have trace id injected to get a full flame-graph in APM. + * If no host provided the interceptor won't trace any OkHttp [Request], nor propagate tracing + * information to the backend, but RUM Resource events will still be sent for each request. * @param tracedRequestListener which listens on the intercepted [okhttp3.Request] and offers * the possibility to modify the created [io.opentracing.Span]. */ @JvmOverloads - @Deprecated( - "Hosts should be defined in the DatadogConfig.setFirstPartyHosts()", - ReplaceWith( - expression = "DatadogInterceptor(tracedRequestListener)" - ) - ) constructor( - tracedHosts: List, + firstPartyHosts: List, tracedRequestListener: TracedRequestListener = NoOpTracedRequestListener() ) : this( - tracedHosts, + firstPartyHosts, tracedRequestListener, CoreFeature.firstPartyHostDetector, { AndroidTracer.Builder().build() } diff --git a/dd-sdk-android/src/main/kotlin/com/datadog/android/core/configuration/Configuration.kt b/dd-sdk-android/src/main/kotlin/com/datadog/android/core/configuration/Configuration.kt index 1f711051db..d6f00c0f29 100644 --- a/dd-sdk-android/src/main/kotlin/com/datadog/android/core/configuration/Configuration.kt +++ b/dd-sdk-android/src/main/kotlin/com/datadog/android/core/configuration/Configuration.kt @@ -8,6 +8,7 @@ package com.datadog.android.core.configuration import android.os.Build import com.datadog.android.DatadogEndpoint +import com.datadog.android.DatadogInterceptor import com.datadog.android.core.internal.event.NoOpEventMapper import com.datadog.android.core.internal.utils.devLogger import com.datadog.android.event.EventMapper diff --git a/dd-sdk-android/src/main/kotlin/com/datadog/android/core/internal/data/file/ImmediateFileWriter.kt b/dd-sdk-android/src/main/kotlin/com/datadog/android/core/internal/data/file/ImmediateFileWriter.kt index c352adf3b9..9446bac01b 100644 --- a/dd-sdk-android/src/main/kotlin/com/datadog/android/core/internal/data/file/ImmediateFileWriter.kt +++ b/dd-sdk-android/src/main/kotlin/com/datadog/android/core/internal/data/file/ImmediateFileWriter.kt @@ -18,7 +18,7 @@ import java.io.FileNotFoundException import java.io.FileOutputStream import java.io.IOException -internal class ImmediateFileWriter( +internal open class ImmediateFileWriter( internal val fileOrchestrator: Orchestrator, private val serializer: Serializer, separator: CharSequence = PayloadDecoration.JSON_ARRAY_DECORATION.separator @@ -40,47 +40,40 @@ internal class ImmediateFileWriter( // endregion - // region Internal - @SuppressWarnings("TooGenericExceptionCaught") private fun consume(model: T) { - val data = try { - serializer.serialize(model) - } catch (e: Throwable) { - sdkLogger.w("Unable to serialize ${model.javaClass.simpleName}", e) - return - } - - if (data.length >= MAX_ITEM_SIZE) { - devLogger.e("Unable to persist data, serialized size is too big\n$data") - } else { - synchronized(this) { - writeData(data) - } + serialiseEvent(model)?.let { + persistData(it, model) } } - private fun writeData(data: String) { - val dataAsByteArray = data.toByteArray(Charsets.UTF_8) + // region Protected + + protected open fun writeData(data: ByteArray, model: T) { val file = try { - fileOrchestrator.getWritableFile(dataAsByteArray.size) + fileOrchestrator.getWritableFile(data.size) } catch (e: SecurityException) { sdkLogger.e("Unable to access batch file directory", e) null } if (file != null) { - writeDataToFile(file, dataAsByteArray) + writeDataToFile(file, data) } else { sdkLogger.e("Could not get a valid file") } } - private fun writeDataToFile(file: File, dataAsByteArray: ByteArray) { + protected fun writeDataToFile( + file: File, + dataAsByteArray: ByteArray, + append: Boolean = true, + withSeparator: Boolean = true + ) { try { - val outputStream = FileOutputStream(file, true) + val outputStream = FileOutputStream(file, append) outputStream.use { stream -> - lockFileAndWriteData(stream, file, dataAsByteArray) + lockFileAndWriteData(stream, file, dataAsByteArray, withSeparator) } } catch (e: IllegalStateException) { sdkLogger.e("Exception when trying to lock the file: [${file.canonicalPath}] ", e) @@ -91,13 +84,38 @@ internal class ImmediateFileWriter( } } + // endregion + + // region Internal + + @SuppressWarnings("TooGenericExceptionCaught") + private fun serialiseEvent(model: T): String? { + return try { + serializer.serialize(model) + } catch (e: Throwable) { + sdkLogger.w("Unable to serialize ${model.javaClass.simpleName}", e) + null + } + } + + private fun persistData(data: String, model: T) { + if (data.length >= MAX_ITEM_SIZE) { + devLogger.e("Unable to persist data, serialized size is too big\n$data") + } else { + synchronized(this) { + writeData(data.toByteArray(Charsets.UTF_8), model) + } + } + } + private fun lockFileAndWriteData( stream: FileOutputStream, file: File, - dataAsByteArray: ByteArray + dataAsByteArray: ByteArray, + withSeparator: Boolean = true ) { stream.channel.lock().use { - if (file.length() > 0) { + if (file.length() > 0 && withSeparator) { stream.write(separatorBytes + dataAsByteArray) } else { stream.write(dataAsByteArray) diff --git a/dd-sdk-android/src/main/kotlin/com/datadog/android/core/internal/domain/Deserializer.kt b/dd-sdk-android/src/main/kotlin/com/datadog/android/core/internal/domain/Deserializer.kt new file mode 100644 index 0000000000..043f0aa8e9 --- /dev/null +++ b/dd-sdk-android/src/main/kotlin/com/datadog/android/core/internal/domain/Deserializer.kt @@ -0,0 +1,15 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.datadog.android.core.internal.domain + +/** + * The Serializer generic interface. Should be implemented by any custom serializer. + */ +internal interface Deserializer { + + fun deserialize(model: String): T? +} diff --git a/dd-sdk-android/src/main/kotlin/com/datadog/android/core/internal/domain/FilePersistenceStrategy.kt b/dd-sdk-android/src/main/kotlin/com/datadog/android/core/internal/domain/FilePersistenceStrategy.kt index e90b896dc8..eca52f1d7b 100644 --- a/dd-sdk-android/src/main/kotlin/com/datadog/android/core/internal/domain/FilePersistenceStrategy.kt +++ b/dd-sdk-android/src/main/kotlin/com/datadog/android/core/internal/domain/FilePersistenceStrategy.kt @@ -6,10 +6,12 @@ package com.datadog.android.core.internal.domain +import com.datadog.android.core.internal.data.Orchestrator import com.datadog.android.core.internal.data.Reader import com.datadog.android.core.internal.data.Writer import com.datadog.android.core.internal.data.file.FileOrchestrator import com.datadog.android.core.internal.data.file.FileReader +import com.datadog.android.core.internal.data.file.ImmediateFileWriter import com.datadog.android.core.internal.domain.batching.ConsentAwareDataWriter import com.datadog.android.core.internal.domain.batching.DataProcessorFactory import com.datadog.android.core.internal.domain.batching.DefaultConsentAwareDataWriter @@ -28,7 +30,11 @@ internal open class FilePersistenceStrategy( filePersistenceConfig: FilePersistenceConfig = FilePersistenceConfig(), payloadDecoration: PayloadDecoration = PayloadDecoration.JSON_ARRAY_DECORATION, trackingConsentProvider: ConsentProvider, - eventMapper: EventMapper = NoOpEventMapper() + eventMapper: EventMapper = NoOpEventMapper(), + fileWriterFactory: (Orchestrator, Serializer, CharSequence) -> Writer = + { fileOrchestrator, eventSerializer, eventSeparator -> + ImmediateFileWriter(fileOrchestrator, eventSerializer, eventSeparator) + } ) : PersistenceStrategy { internal val intermediateFileOrchestrator = FileOrchestrator( @@ -57,7 +63,8 @@ internal open class FilePersistenceStrategy( serializer, payloadDecoration.separator, executorService, - eventMapper + eventMapper, + fileWriterFactory ), migratorsFactory = DefaultMigratorFactory( intermediateStorageFolder.absolutePath, diff --git a/dd-sdk-android/src/main/kotlin/com/datadog/android/core/internal/domain/batching/DataProcessorFactory.kt b/dd-sdk-android/src/main/kotlin/com/datadog/android/core/internal/domain/batching/DataProcessorFactory.kt index 1af8cf21c1..f9f9f328cb 100644 --- a/dd-sdk-android/src/main/kotlin/com/datadog/android/core/internal/domain/batching/DataProcessorFactory.kt +++ b/dd-sdk-android/src/main/kotlin/com/datadog/android/core/internal/domain/batching/DataProcessorFactory.kt @@ -7,6 +7,7 @@ package com.datadog.android.core.internal.domain.batching import com.datadog.android.core.internal.data.Orchestrator +import com.datadog.android.core.internal.data.Writer import com.datadog.android.core.internal.data.file.ImmediateFileWriter import com.datadog.android.core.internal.domain.Serializer import com.datadog.android.core.internal.domain.batching.processors.DataProcessor @@ -23,7 +24,11 @@ internal class DataProcessorFactory( private val serializer: Serializer, private val separator: CharSequence, private val executorService: ExecutorService, - private val eventMapper: EventMapper = NoOpEventMapper() + private val eventMapper: EventMapper = NoOpEventMapper(), + private val fileWriterFactory: (Orchestrator, Serializer, CharSequence) -> Writer = + { fileOrchestrator, eventSerializer, eventSeparator -> + ImmediateFileWriter(fileOrchestrator, eventSerializer, eventSeparator) + } ) { fun resolveProcessor(consent: TrackingConsent): DataProcessor { @@ -32,14 +37,14 @@ internal class DataProcessorFactory( intermediateFileOrchestrator.reset() DefaultDataProcessor( executorService, - ImmediateFileWriter(intermediateFileOrchestrator, serializer, separator), + fileWriterFactory(intermediateFileOrchestrator, serializer, separator), eventMapper ) } TrackingConsent.GRANTED -> { DefaultDataProcessor( executorService, - ImmediateFileWriter(targetFileOrchestrator, serializer, separator), + fileWriterFactory(targetFileOrchestrator, serializer, separator), eventMapper ) } diff --git a/dd-sdk-android/src/main/kotlin/com/datadog/android/log/internal/domain/LogSerializer.kt b/dd-sdk-android/src/main/kotlin/com/datadog/android/log/internal/domain/LogSerializer.kt index 1842ff76a9..5f78a4ce5a 100644 --- a/dd-sdk-android/src/main/kotlin/com/datadog/android/log/internal/domain/LogSerializer.kt +++ b/dd-sdk-android/src/main/kotlin/com/datadog/android/log/internal/domain/LogSerializer.kt @@ -123,7 +123,10 @@ internal class LogSerializer( jsonLog: JsonObject ) { log.throwable?.let { - jsonLog.addProperty(LogAttributes.ERROR_KIND, it.javaClass.simpleName) + jsonLog.addProperty( + LogAttributes.ERROR_KIND, + it.javaClass.canonicalName ?: it.javaClass.simpleName + ) jsonLog.addProperty(LogAttributes.ERROR_MESSAGE, it.message) jsonLog.addProperty(LogAttributes.ERROR_STACK, it.loggableStackTrace()) } @@ -157,14 +160,6 @@ internal class LogSerializer( internal const val USER_EXTRA_GROUP_VERBOSE_NAME = "user extra information" internal val reservedAttributes = arrayOf( - LogAttributes.HOST, - LogAttributes.MESSAGE, - LogAttributes.STATUS, - LogAttributes.SERVICE_NAME, - LogAttributes.SOURCE, - LogAttributes.ERROR_KIND, - LogAttributes.ERROR_MESSAGE, - LogAttributes.ERROR_STACK, TAG_DATADOG_TAGS ) diff --git a/dd-sdk-android/src/main/kotlin/com/datadog/android/rum/RumAttributes.kt b/dd-sdk-android/src/main/kotlin/com/datadog/android/rum/RumAttributes.kt index 9c479dbc6d..3e615bae66 100644 --- a/dd-sdk-android/src/main/kotlin/com/datadog/android/rum/RumAttributes.kt +++ b/dd-sdk-android/src/main/kotlin/com/datadog/android/rum/RumAttributes.kt @@ -59,6 +59,11 @@ object RumAttributes { // region Internal + /** + * Overrides the automatic RUM error event type with a custom one. + */ + const val INTERNAL_ERROR_TYPE: String = "_dd.error_type" + /** * Overrides the automatic RUM event timestamp with a custom one. */ diff --git a/dd-sdk-android/src/main/kotlin/com/datadog/android/rum/RumInterceptor.kt b/dd-sdk-android/src/main/kotlin/com/datadog/android/rum/RumInterceptor.kt index 4f9d95c82d..dd49bbc32b 100644 --- a/dd-sdk-android/src/main/kotlin/com/datadog/android/rum/RumInterceptor.kt +++ b/dd-sdk-android/src/main/kotlin/com/datadog/android/rum/RumInterceptor.kt @@ -11,6 +11,7 @@ import com.datadog.android.DatadogInterceptor import com.datadog.android.rum.tracking.ViewTrackingStrategy import okhttp3.Interceptor import okhttp3.OkHttpClient +import okhttp3.Request /** * Provides automatic RUM integration for [OkHttpClient] by way of the [Interceptor] system. @@ -31,5 +32,13 @@ import okhttp3.OkHttpClient * .addInterceptor(new RumInterceptor()) * .build(); * ``` + * @param firstPartyHosts the list of first party hosts. + * Requests made to a URL with any one of these hosts (or any subdomain) will: + * - be considered a first party RUM Resource and categorised as such in your RUM dashboard; + * - be wrapped in a Span and have trace id injected to get a full flame-graph in APM. + * If no host provided the interceptor won't trace any OkHttp [Request], nor propagate tracing + * information to the backend, but RUM Resource events will still be sent for each request. */ -class RumInterceptor : DatadogInterceptor(listOf("")) +class RumInterceptor( + firstPartyHosts: List = emptyList() +) : DatadogInterceptor(firstPartyHosts) diff --git a/dd-sdk-android/src/main/kotlin/com/datadog/android/rum/internal/data/file/RumFileWriter.kt b/dd-sdk-android/src/main/kotlin/com/datadog/android/rum/internal/data/file/RumFileWriter.kt new file mode 100644 index 0000000000..4d1d65e1ba --- /dev/null +++ b/dd-sdk-android/src/main/kotlin/com/datadog/android/rum/internal/data/file/RumFileWriter.kt @@ -0,0 +1,49 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.datadog.android.rum.internal.data.file + +import com.datadog.android.core.internal.data.Orchestrator +import com.datadog.android.core.internal.data.file.ImmediateFileWriter +import com.datadog.android.core.internal.domain.PayloadDecoration +import com.datadog.android.core.internal.domain.Serializer +import com.datadog.android.rum.internal.domain.event.RumEvent +import com.datadog.android.rum.model.ViewEvent +import java.io.File + +internal class RumFileWriter( + ndkCrashDataDirectory: File, + fileOrchestrator: Orchestrator, + serializer: Serializer, + separator: CharSequence = PayloadDecoration.JSON_ARRAY_DECORATION.separator +) : ImmediateFileWriter(fileOrchestrator, serializer, separator) { + + private val lastViewEventFile: File + + init { + ndkCrashDataDirectory.mkdirs() + lastViewEventFile = File(ndkCrashDataDirectory, LAST_VIEW_EVENT_FILE_NAME) + } + + // region ImmediateFileWriter + + override fun writeData(data: ByteArray, model: RumEvent) { + super.writeData(data, model) + if (model.event is ViewEvent) { + if (!lastViewEventFile.exists()) { + lastViewEventFile.createNewFile() + } + // persist the serialised ViewEvent in the NDK crash data folder + writeDataToFile(lastViewEventFile, data, false) + } + } + + // endregion + + companion object { + internal const val LAST_VIEW_EVENT_FILE_NAME = "last_view_event" + } +} diff --git a/dd-sdk-android/src/main/kotlin/com/datadog/android/rum/internal/domain/RumFileStrategy.kt b/dd-sdk-android/src/main/kotlin/com/datadog/android/rum/internal/domain/RumFileStrategy.kt index 5f4f8ed6ce..c511c0a2c3 100644 --- a/dd-sdk-android/src/main/kotlin/com/datadog/android/rum/internal/domain/RumFileStrategy.kt +++ b/dd-sdk-android/src/main/kotlin/com/datadog/android/rum/internal/domain/RumFileStrategy.kt @@ -12,8 +12,10 @@ import com.datadog.android.core.internal.domain.FilePersistenceStrategy import com.datadog.android.core.internal.domain.PayloadDecoration import com.datadog.android.core.internal.privacy.ConsentProvider import com.datadog.android.event.EventMapper +import com.datadog.android.rum.internal.data.file.RumFileWriter import com.datadog.android.rum.internal.domain.event.RumEvent import com.datadog.android.rum.internal.domain.event.RumEventSerializer +import com.datadog.android.rum.internal.ndk.DatadogNdkCrashHandler import java.io.File import java.util.concurrent.ExecutorService @@ -31,7 +33,15 @@ internal class RumFileStrategy( filePersistenceConfig, PayloadDecoration.NEW_LINE_DECORATION, trackingConsentProvider, - eventMapper + eventMapper, + fileWriterFactory = { fileOrchestrator, eventSerializer, eventSeparator -> + RumFileWriter( + File(context.filesDir, DatadogNdkCrashHandler.NDK_CRASH_REPORTS_FOLDER_NAME), + fileOrchestrator, + eventSerializer, + eventSeparator + ) + } ) { companion object { internal const val VERSION = 1 diff --git a/dd-sdk-android/src/main/kotlin/com/datadog/android/rum/internal/domain/event/RumEvent.kt b/dd-sdk-android/src/main/kotlin/com/datadog/android/rum/internal/domain/event/RumEvent.kt index 3880566c63..c4a950918a 100644 --- a/dd-sdk-android/src/main/kotlin/com/datadog/android/rum/internal/domain/event/RumEvent.kt +++ b/dd-sdk-android/src/main/kotlin/com/datadog/android/rum/internal/domain/event/RumEvent.kt @@ -9,6 +9,5 @@ package com.datadog.android.rum.internal.domain.event internal data class RumEvent( val event: Any, val globalAttributes: Map, - val userExtraAttributes: Map, - val customTimings: Map? = null + val userExtraAttributes: Map ) diff --git a/dd-sdk-android/src/main/kotlin/com/datadog/android/rum/internal/domain/event/RumEventDeserializer.kt b/dd-sdk-android/src/main/kotlin/com/datadog/android/rum/internal/domain/event/RumEventDeserializer.kt new file mode 100644 index 0000000000..eb2ee4195c --- /dev/null +++ b/dd-sdk-android/src/main/kotlin/com/datadog/android/rum/internal/domain/event/RumEventDeserializer.kt @@ -0,0 +1,97 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.datadog.android.rum.internal.domain.event + +import com.datadog.android.core.internal.domain.Deserializer +import com.datadog.android.core.internal.utils.sdkLogger +import com.datadog.android.rum.model.ActionEvent +import com.datadog.android.rum.model.ErrorEvent +import com.datadog.android.rum.model.ResourceEvent +import com.datadog.android.rum.model.ViewEvent +import com.google.gson.JsonObject +import com.google.gson.JsonParseException +import com.google.gson.JsonParser + +internal class RumEventDeserializer : Deserializer { + + // region Deserializer + + override fun deserialize(model: String): RumEvent? { + return try { + val jsonObject = JsonParser.parseString(model).asJsonObject + val userAttributes: MutableMap = mutableMapOf() + val globalAttributes: MutableMap = mutableMapOf() + + resolveCustomAttributes(userAttributes, globalAttributes, jsonObject) + + val deserializedBundledEvent = parseEvent( + jsonObject.getAsJsonPrimitive(EVENT_TYPE_KEY_NAME)?.asString, + model + ) + RumEvent( + deserializedBundledEvent, + globalAttributes, + userAttributes + ) + } catch (e: JsonParseException) { + sdkLogger.e("Error while trying to deserialize the serialized RumEvent: $model", e) + null + } + } + + // endregion + + // region Internal + + private fun resolveCustomAttributes( + userAttributes: MutableMap, + globalAttributes: MutableMap, + jsonObject: JsonObject + ) { + val globalAttrPrefix = RumEventSerializer.GLOBAL_ATTRIBUTE_PREFIX + '.' + val userAttrPrefix = RumEventSerializer.USER_ATTRIBUTE_PREFIX + '.' + val globalAttrPrefixLength = globalAttrPrefix.length + val userAttrPrefixLength = userAttrPrefix.length + + jsonObject.keySet().forEach { + when { + it.startsWith(userAttrPrefix) -> { + userAttributes[it.substring(userAttrPrefixLength)] = jsonObject.get(it) + } + it.startsWith(globalAttrPrefix) -> { + globalAttributes[it.substring(globalAttrPrefixLength)] = jsonObject.get(it) + } + } + } + } + + @SuppressWarnings("ThrowingInternalException") + @Throws(JsonParseException::class) + private fun parseEvent(eventType: String?, jsonString: String): Any { + return when (eventType) { + EVENT_TYPE_VIEW -> ViewEvent.fromJson(jsonString) + EVENT_TYPE_RESOURCE -> ResourceEvent.fromJson(jsonString) + EVENT_TYPE_ACTION -> ActionEvent.fromJson(jsonString) + EVENT_TYPE_ERROR -> ErrorEvent.fromJson(jsonString) + else -> throw JsonParseException( + "We could not deserialize the event with type: $eventType" + ) + } + } + + // endregion + + companion object { + const val EVENT_TYPE_KEY_NAME = "type" + + // Maybe we need to expose these as static constants in the POKOs from the Generator ?? + const val EVENT_TYPE_VIEW = "view" + const val EVENT_TYPE_RESOURCE = "resource" + const val EVENT_TYPE_ACTION = "action" + const val EVENT_TYPE_ERROR = "error" + } +} diff --git a/dd-sdk-android/src/main/kotlin/com/datadog/android/rum/internal/domain/event/RumEventSerializer.kt b/dd-sdk-android/src/main/kotlin/com/datadog/android/rum/internal/domain/event/RumEventSerializer.kt index 9ddf79a6d6..81621d5cfa 100644 --- a/dd-sdk-android/src/main/kotlin/com/datadog/android/rum/internal/domain/event/RumEventSerializer.kt +++ b/dd-sdk-android/src/main/kotlin/com/datadog/android/rum/internal/domain/event/RumEventSerializer.kt @@ -45,17 +45,6 @@ internal class RumEventSerializer( json, USER_ATTRIBUTE_PREFIX ) - model.customTimings?.let { - addCustomAttributes( - dataConstraints.validateAttributes( - it, - keyPrefix = VIEW_CUSTOM_TIMINGS_ATTRIBUTE_PREFIX, - attributesGroupName = CUSTOM_TIMINGS_GROUP_VERBOSE_NAME - ), - json, - VIEW_CUSTOM_TIMINGS_ATTRIBUTE_PREFIX - ) - } return json.toString() } @@ -97,14 +86,13 @@ internal class RumEventSerializer( ) internal val ignoredAttributes = setOf( - RumAttributes.INTERNAL_TIMESTAMP + RumAttributes.INTERNAL_TIMESTAMP, + RumAttributes.INTERNAL_ERROR_TYPE ) internal const val GLOBAL_ATTRIBUTE_PREFIX: String = "context" internal const val USER_ATTRIBUTE_PREFIX: String = "$GLOBAL_ATTRIBUTE_PREFIX.usr" - internal const val VIEW_CUSTOM_TIMINGS_ATTRIBUTE_PREFIX: String = "view.custom_timings" internal const val USER_EXTRA_GROUP_VERBOSE_NAME = "user extra information" - internal const val CUSTOM_TIMINGS_GROUP_VERBOSE_NAME = "view custom timings" } } diff --git a/dd-sdk-android/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumRawEvent.kt b/dd-sdk-android/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumRawEvent.kt index 4fc1335b7d..58509aeb62 100644 --- a/dd-sdk-android/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumRawEvent.kt +++ b/dd-sdk-android/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumRawEvent.kt @@ -89,7 +89,8 @@ internal sealed class RumRawEvent { val stacktrace: String?, val isFatal: Boolean, val attributes: Map, - override val eventTime: Time = Time() + override val eventTime: Time = Time(), + val type: String? = null ) : RumRawEvent() internal data class UpdateViewLoadingTime( diff --git a/dd-sdk-android/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumResourceScope.kt b/dd-sdk-android/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumResourceScope.kt index 50a4337c99..a29ebed974 100644 --- a/dd-sdk-android/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumResourceScope.kt +++ b/dd-sdk-android/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumResourceScope.kt @@ -176,10 +176,18 @@ internal class RumResourceScope( userExtraAttributes = user.extraInfo ) writer.write(rumEvent) + if (isFailedHttpRequest(statusCode)) { + val errorMessage = ERROR_MSG_FORMAT.format(method, url) + sendError(errorMessage, RumErrorSource.NETWORK, statusCode, null, writer) + } parentScope.handleEvent(RumRawEvent.SentResource(), writer) sent = true } + private fun isFailedHttpRequest(statusCode: Long?): Boolean { + return (statusCode ?: 0) >= HTTP_ERROR_CODE_THRESHOLD + } + private fun resolveResourceProvider(): ResourceEvent.Provider? { return if (firstPartyHostDetector.isFirstPartyUrl(url)) { ResourceEvent.Provider( @@ -195,7 +203,7 @@ internal class RumResourceScope( message: String, source: RumErrorSource, statusCode: Long?, - throwable: Throwable, + throwable: Throwable?, writer: Writer ) { attributes.putAll(GlobalRum.globalAttributes) @@ -207,14 +215,15 @@ internal class RumResourceScope( error = ErrorEvent.Error( message = message, source = source.toSchemaSource(), - stack = throwable.loggableStackTrace(), + stack = throwable?.loggableStackTrace(), isCrash = false, resource = ErrorEvent.Resource( url = url, method = method.toErrorMethod(), statusCode = statusCode ?: 0, provider = resolveErrorProvider() - ) + ), + type = resolveErrorType(statusCode, throwable) ), action = context.actionId?.let { ErrorEvent.Action(it) }, view = ErrorEvent.View( @@ -263,9 +272,22 @@ internal class RumResourceScope( } } + private fun resolveErrorType(statusCode: Long?, throwable: Throwable?): String? { + return if (throwable != null) { + throwable.javaClass.canonicalName + } else if (statusCode != null) { + ERROR_TYPE_BASED_ON_STATUS_CODE_FORMAT.format(statusCode) + } else { + null + } + } + // endregion companion object { + internal const val HTTP_ERROR_CODE_THRESHOLD = 400 + internal const val ERROR_MSG_FORMAT = "Request error %s %s" + internal const val ERROR_TYPE_BASED_ON_STATUS_CODE_FORMAT = "HTTP %d" fun fromEvent( parentScope: RumScope, event: RumRawEvent.StartResource, diff --git a/dd-sdk-android/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumSessionScope.kt b/dd-sdk-android/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumSessionScope.kt index 354263701f..3fd79bb189 100644 --- a/dd-sdk-android/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumSessionScope.kt +++ b/dd-sdk-android/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumSessionScope.kt @@ -6,6 +6,9 @@ package com.datadog.android.rum.internal.domain.scope +import android.os.Build +import android.os.Process +import android.os.SystemClock import com.datadog.android.Datadog import com.datadog.android.core.internal.data.NoOpWriter import com.datadog.android.core.internal.data.Writer @@ -101,7 +104,7 @@ internal class RumSessionScope( ) { if (!applicationDisplayed) { applicationDisplayed = true - val applicationStartTime = resetSessionTime ?: Datadog.startupTimeNs + val applicationStartTime = resolveStartupTimeNs() viewScope.handleEvent( RumRawEvent.ApplicationStarted(event.eventTime, applicationStartTime), writer @@ -109,6 +112,18 @@ internal class RumSessionScope( } } + private fun resolveStartupTimeNs(): Long { + val resetTimeNs = resetSessionTime + return when { + resetTimeNs != null -> resetTimeNs + Build.VERSION.SDK_INT >= Build.VERSION_CODES.N -> { + val diffMs = SystemClock.elapsedRealtime() - Process.getStartElapsedRealtime() + System.nanoTime() - TimeUnit.MILLISECONDS.toNanos(diffMs) + } + else -> Datadog.startupTimeNs + } + } + @Synchronized private fun updateSessionIdIfNeeded() { val nanoTime = System.nanoTime() diff --git a/dd-sdk-android/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumViewScope.kt b/dd-sdk-android/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumViewScope.kt index 521ef9822e..3402cc775e 100644 --- a/dd-sdk-android/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumViewScope.kt +++ b/dd-sdk-android/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumViewScope.kt @@ -184,14 +184,15 @@ internal class RumViewScope( val user = CoreFeature.userInfoProvider.getUserInfo() val updatedAttributes = addExtraAttributes(event.attributes) val networkInfo = CoreFeature.networkInfoProvider.getLatestNetworkInfo() - + val errorType = event.type ?: event.throwable?.javaClass?.canonicalName val errorEvent = ErrorEvent( date = event.eventTime.timestamp, error = ErrorEvent.Error( message = event.message, source = event.source.toSchemaSource(), stack = event.stacktrace ?: event.throwable?.loggableStackTrace(), - isCrash = event.isFatal + isCrash = event.isFatal, + type = errorType ), action = context.actionId?.let { ErrorEvent.Action(it) }, view = ErrorEvent.View( @@ -280,6 +281,11 @@ internal class RumViewScope( val updatedDurationNs = event.eventTime.nanoTime - startedNanos val context = getRumContext() val user = CoreFeature.userInfoProvider.getUserInfo() + val timings = if (customTimings.isNotEmpty()) { + ViewEvent.CustomTimings(LinkedHashMap(customTimings)) + } else { + null + } val viewEvent = ViewEvent( date = eventTimestamp, @@ -293,6 +299,7 @@ internal class RumViewScope( resource = ViewEvent.Resource(resourceCount), error = ViewEvent.Error(errorCount), crash = ViewEvent.Crash(crashCount), + customTimings = timings, isActive = !stopped ), usr = ViewEvent.Usr( @@ -308,8 +315,7 @@ internal class RumViewScope( val rumEvent = RumEvent( event = viewEvent, globalAttributes = attributes, - userExtraAttributes = user.extraInfo, - customTimings = customTimings + userExtraAttributes = user.extraInfo ) writer.write(rumEvent) } diff --git a/dd-sdk-android/src/main/kotlin/com/datadog/android/rum/internal/monitor/DatadogRumMonitor.kt b/dd-sdk-android/src/main/kotlin/com/datadog/android/rum/internal/monitor/DatadogRumMonitor.kt index fab43a3571..b72ff98084 100644 --- a/dd-sdk-android/src/main/kotlin/com/datadog/android/rum/internal/monitor/DatadogRumMonitor.kt +++ b/dd-sdk-android/src/main/kotlin/com/datadog/android/rum/internal/monitor/DatadogRumMonitor.kt @@ -141,8 +141,18 @@ internal class DatadogRumMonitor( attributes: Map ) { val eventTime = getEventTime(attributes) + val errorType = getErrorType(attributes) handleEvent( - RumRawEvent.AddError(message, source, throwable, null, false, attributes, eventTime) + RumRawEvent.AddError( + message, + source, + throwable, + null, + false, + attributes, + eventTime, + errorType + ) ) } @@ -153,8 +163,18 @@ internal class DatadogRumMonitor( attributes: Map ) { val eventTime = getEventTime(attributes) + val errorType = getErrorType(attributes) handleEvent( - RumRawEvent.AddError(message, source, null, stacktrace, false, attributes, eventTime) + RumRawEvent.AddError( + message, + source, + null, + stacktrace, + false, + attributes, + eventTime, + errorType + ) ) } @@ -230,6 +250,10 @@ internal class DatadogRumMonitor( return (attributes[RumAttributes.INTERNAL_TIMESTAMP] as? Long)?.asTime() ?: Time() } + private fun getErrorType(attributes: Map): String? { + return attributes[RumAttributes.INTERNAL_ERROR_TYPE] as? String + } + // endregion companion object { diff --git a/dd-sdk-android/src/main/kotlin/com/datadog/android/rum/internal/ndk/DatadogNdkCrashHandler.kt b/dd-sdk-android/src/main/kotlin/com/datadog/android/rum/internal/ndk/DatadogNdkCrashHandler.kt new file mode 100644 index 0000000000..b8152a90aa --- /dev/null +++ b/dd-sdk-android/src/main/kotlin/com/datadog/android/rum/internal/ndk/DatadogNdkCrashHandler.kt @@ -0,0 +1,262 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.datadog.android.rum.internal.ndk + +import com.datadog.android.core.internal.data.Writer +import com.datadog.android.core.internal.domain.Deserializer +import com.datadog.android.core.internal.utils.sdkLogger +import com.datadog.android.log.LogAttributes +import com.datadog.android.log.internal.domain.Log +import com.datadog.android.log.internal.domain.LogGenerator +import com.datadog.android.rum.internal.data.file.RumFileWriter +import com.datadog.android.rum.internal.domain.event.RumEvent +import com.datadog.android.rum.internal.domain.event.RumEventDeserializer +import com.datadog.android.rum.model.ErrorEvent +import com.datadog.android.rum.model.ViewEvent +import com.google.gson.JsonSyntaxException +import java.io.File +import java.io.IOException +import java.util.concurrent.ExecutorService +import java.util.concurrent.TimeUnit + +internal class DatadogNdkCrashHandler( + private val ndkCrashDataDirectory: File, + private val dataPersistenceExecutorService: ExecutorService, + private val asyncLogWriter: Writer, + private val asyncRumWriter: Writer, + private val logGenerator: LogGenerator, + private val rumEventDeserializer: Deserializer = RumEventDeserializer() +) : NdkCrashHandler { + + override fun handleNdkCrash() { + dataPersistenceExecutorService.submit { + checkAndHandleNdkCrashReport() + } + } + + private fun checkAndHandleNdkCrashReport() { + if (ndkCrashDataDirectory.exists()) { + val ndkCrashLog = findLastNdCrashLog() + val lastRumViewEvent = findLastRumViewEvent() + handleNdkCrashLog(ndkCrashLog, lastRumViewEvent) + clearCrashLog() + } + } + + private fun findLastNdCrashLog(): NdkCrashLog? { + val crashLogFile = + ndkCrashDataDirectory + .listFiles { _, name -> name == CRASH_LOG_FILE_NAME } + ?.firstOrNull() + if (crashLogFile != null) { + return readFromFile( + crashLogFile, + NdkCrashLog::class.java, + "Malformed ndk crash error log", + "Error while trying to read the ndk crash log" + ) + } + return null + } + + private fun findLastRumViewEvent(): RumEvent? { + val lastViewEventFile = + ndkCrashDataDirectory.listFiles { _, name -> + name == RumFileWriter.LAST_VIEW_EVENT_FILE_NAME + }?.firstOrNull() + if (lastViewEventFile != null) { + return readFromFile( + lastViewEventFile, + RumEvent::class.java, + "Malformed RUM ViewEvent log", + "Error while trying to read the last rum view event log" + ) + } + return null + } + + private fun readFromFile( + file: File, + type: Class, + parsingErrorMessage: String, + ioErrorMessage: String + ): T? { + return try { + val serializedData = file.readText(Charsets.UTF_8) + @Suppress("UNCHECKED_CAST") + if (type == RumEvent::class.java) { + rumEventDeserializer.deserialize(serializedData) as? T + } else { + NdkCrashLog.fromJson(serializedData) as T + } + } catch (e: JsonSyntaxException) { + sdkLogger.e(parsingErrorMessage, e) + null + } catch (e: IOException) { + sdkLogger.e(ioErrorMessage, e) + null + } + } + + private fun handleNdkCrashLog(ndkCrashLog: NdkCrashLog?, lastRumViewEvent: RumEvent?) { + if (ndkCrashLog == null) { + return + } + val errorLogMessage = NDK_ERROR_LOG_MESSAGE.format(ndkCrashLog.signalName) + val bundledViewEvent = lastRumViewEvent?.event as? ViewEvent + val logAttributes: Map + if (lastRumViewEvent != null && bundledViewEvent != null) { + logAttributes = mapOf( + LogAttributes.RUM_SESSION_ID to bundledViewEvent.session.id, + LogAttributes.RUM_APPLICATION_ID to bundledViewEvent.application.id, + LogAttributes.RUM_VIEW_ID to bundledViewEvent.view.id, + LogAttributes.ERROR_STACK to ndkCrashLog.stacktrace + ) + updateViewEventAndSendError( + errorLogMessage, + ndkCrashLog, + lastRumViewEvent, + bundledViewEvent + ) + } else { + logAttributes = mapOf( + LogAttributes.ERROR_STACK to ndkCrashLog.stacktrace + ) + } + + sendCrashLogEvent(errorLogMessage, logAttributes, ndkCrashLog) + } + + private fun updateViewEventAndSendError( + errorLogMessage: String, + ndkCrashLog: NdkCrashLog, + lastRumViewEvent: RumEvent, + bundledViewEvent: ViewEvent + ) { + // update the error count + val toSendErrorEvent = resolveFromLastRumViewEvent( + errorLogMessage, + ndkCrashLog, + lastRumViewEvent, + bundledViewEvent + ) + val sessionsTimeDifference = System.currentTimeMillis() - ndkCrashLog.timestamp + if (sessionsTimeDifference < VIEW_EVENT_AVAILABILITY_TIME_THRESHOLD + ) { + val toSendRumEvent = resolveFromLastRumViewEvent(lastRumViewEvent, bundledViewEvent) + asyncRumWriter.write(toSendRumEvent) + } + asyncRumWriter.write(toSendErrorEvent) + } + + private fun sendCrashLogEvent( + errorLogMessage: String, + logAttributes: Map, + ndkCrashLog: NdkCrashLog + ) { + val log = logGenerator.generateLog( + Log.CRASH, + errorLogMessage, + null, + logAttributes, + emptySet(), + ndkCrashLog.timestamp, + bundleWithTraces = false, + bundleWithRum = false + ) + + asyncLogWriter.write(log) + } + + private fun resolveFromLastRumViewEvent( + lastRumViewEvent: RumEvent, + bundledViewEvent: ViewEvent + ): RumEvent { + return lastRumViewEvent.copy( + event = bundledViewEvent.copy( + view = bundledViewEvent.view.copy( + error = bundledViewEvent.view.error + .copy(count = bundledViewEvent.view.error.count + 1), + isActive = false + ), + dd = bundledViewEvent.dd.copy( + documentVersion = bundledViewEvent.dd.documentVersion + 1 + ) + ) + ) + } + + private fun resolveFromLastRumViewEvent( + errorLogMessage: String, + ndkCrashLog: NdkCrashLog, + rumViewEvent: RumEvent, + bundledViewEvent: ViewEvent + ): RumEvent { + val connectivity = bundledViewEvent.connectivity?.let { + val connectivityStatus = + ErrorEvent.Status.valueOf(it.status.name) + val connectivityInterfaces = it.interfaces.map { ErrorEvent.Interface.valueOf(it.name) } + val cellular = ErrorEvent.Cellular( + it.cellular?.technology, + it.cellular?.carrierName + ) + ErrorEvent.Connectivity(connectivityStatus, connectivityInterfaces, cellular) + } + return RumEvent( + ErrorEvent( + ndkCrashLog.timestamp, + ErrorEvent.Application(bundledViewEvent.application.id), + bundledViewEvent.service, + ErrorEvent.Session(bundledViewEvent.session.id, ErrorEvent.SessionType.USER), + ErrorEvent.View( + bundledViewEvent.view.id, + bundledViewEvent.view.referrer, + bundledViewEvent.view.url + ), + ErrorEvent.Usr( + bundledViewEvent.usr?.id, + bundledViewEvent.usr?.name, + bundledViewEvent.usr?.email + ), + connectivity, + ErrorEvent.Dd(), + ErrorEvent.Error( + errorLogMessage, + ErrorEvent.Source.SOURCE, + ndkCrashLog.stacktrace, + true, + ndkCrashLog.signalName + ) + ), + rumViewEvent.globalAttributes, + rumViewEvent.userExtraAttributes + ) + } + + @SuppressWarnings("TooGenericExceptionCaught") + private fun clearCrashLog() { + if (ndkCrashDataDirectory.exists()) { + try { + ndkCrashDataDirectory.listFiles()?.forEach { it.deleteRecursively() } + } catch (e: Throwable) { + sdkLogger.e( + "Unable to clear the NDK crash report file:" + + " ${ndkCrashDataDirectory.absolutePath}", + e + ) + } + } + } + + companion object { + internal val VIEW_EVENT_AVAILABILITY_TIME_THRESHOLD = TimeUnit.HOURS.toMillis(4) + const val CRASH_LOG_FILE_NAME = "crash_log" + const val LOGGER_NAME = "ndk_crash" + const val NDK_ERROR_LOG_MESSAGE = "NDK crash detected with signal: %s" + internal const val NDK_CRASH_REPORTS_FOLDER_NAME = "ndk_crash_reports" + } +} diff --git a/dd-android-gradle-plugin/src/main/kotlin/com/datadog/gradle/plugin/internal/Uploader.kt b/dd-sdk-android/src/main/kotlin/com/datadog/android/rum/internal/ndk/NdkCrashHandler.kt similarity index 56% rename from dd-android-gradle-plugin/src/main/kotlin/com/datadog/gradle/plugin/internal/Uploader.kt rename to dd-sdk-android/src/main/kotlin/com/datadog/android/rum/internal/ndk/NdkCrashHandler.kt index 86c5f2611c..14406424b0 100644 --- a/dd-android-gradle-plugin/src/main/kotlin/com/datadog/gradle/plugin/internal/Uploader.kt +++ b/dd-sdk-android/src/main/kotlin/com/datadog/android/rum/internal/ndk/NdkCrashHandler.kt @@ -4,15 +4,11 @@ * Copyright 2016-Present Datadog, Inc. */ -package com.datadog.gradle.plugin.internal +package com.datadog.android.rum.internal.ndk -import java.io.File +import com.datadog.tools.annotation.NoOpImplementation -internal interface Uploader { - - fun upload( - url: String, - file: File, - identifier: DdAppIdentifier - ) +@NoOpImplementation +internal interface NdkCrashHandler { + fun handleNdkCrash() } diff --git a/dd-sdk-android/src/main/kotlin/com/datadog/android/rum/internal/ndk/NdkCrashLog.kt b/dd-sdk-android/src/main/kotlin/com/datadog/android/rum/internal/ndk/NdkCrashLog.kt new file mode 100644 index 0000000000..952e26157f --- /dev/null +++ b/dd-sdk-android/src/main/kotlin/com/datadog/android/rum/internal/ndk/NdkCrashLog.kt @@ -0,0 +1,51 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.datadog.android.rum.internal.ndk + +import com.google.gson.JsonObject +import com.google.gson.JsonParseException +import com.google.gson.JsonParser + +internal data class NdkCrashLog( + val signal: Int, + val timestamp: Long, + val signalName: String, + val message: String, + val stacktrace: String +) { + + internal fun toJson(): String { + val jsonObject = JsonObject() + jsonObject.addProperty(SIGNAL_KEY_NAME, signal) + jsonObject.addProperty(SIGNAL_NAME_KEY_NAME, signalName) + jsonObject.addProperty(TIMESTAMP_KEY_NAME, timestamp) + jsonObject.addProperty(MESSAGE_KEY_NAME, message) + jsonObject.addProperty(STACKTRACE_KEY_NAME, stacktrace) + return jsonObject.toString() + } + + companion object { + + internal const val SIGNAL_KEY_NAME = "signal" + internal const val TIMESTAMP_KEY_NAME = "timestamp" + internal const val MESSAGE_KEY_NAME = "message" + internal const val SIGNAL_NAME_KEY_NAME = "signal_name" + internal const val STACKTRACE_KEY_NAME = "stacktrace" + + @Throws(JsonParseException::class) + internal fun fromJson(jsonString: String): NdkCrashLog { + val jsonObject = JsonParser.parseString(jsonString).asJsonObject + return NdkCrashLog( + jsonObject.get(SIGNAL_KEY_NAME).asInt, + jsonObject.get(TIMESTAMP_KEY_NAME).asLong, + jsonObject.get(SIGNAL_NAME_KEY_NAME).asString, + jsonObject.get(MESSAGE_KEY_NAME).asString, + jsonObject.get(STACKTRACE_KEY_NAME).asString + ) + } + } +} diff --git a/dd-sdk-android/src/main/kotlin/com/datadog/android/rum/internal/tracking/AndroidXFragmentLifecycleCallbacks.kt b/dd-sdk-android/src/main/kotlin/com/datadog/android/rum/internal/tracking/AndroidXFragmentLifecycleCallbacks.kt index 7577f5986e..b558c5a01a 100644 --- a/dd-sdk-android/src/main/kotlin/com/datadog/android/rum/internal/tracking/AndroidXFragmentLifecycleCallbacks.kt +++ b/dd-sdk-android/src/main/kotlin/com/datadog/android/rum/internal/tracking/AndroidXFragmentLifecycleCallbacks.kt @@ -77,11 +77,13 @@ internal open class AndroidXFragmentLifecycleCallbacks( componentPredicate.runIfValid(f) { val key = resolveKey(it) viewLoadingTimer.onFinishedLoading(key) - rumMonitor.startView( - key, - it.resolveViewName(), - argumentsProvider(it) - ) + val customViewName = componentPredicate.getViewName(f) + val viewName = if (customViewName.isNullOrBlank()) { + it.resolveViewName() + } else { + customViewName + } + rumMonitor.startView(key, viewName, argumentsProvider(it)) val loadingTime = viewLoadingTimer.getLoadingTime(key) if (loadingTime != null) { advancedRumMonitor.updateViewLoadingTime( diff --git a/dd-sdk-android/src/main/kotlin/com/datadog/android/rum/internal/tracking/OreoFragmentLifecycleCallbacks.kt b/dd-sdk-android/src/main/kotlin/com/datadog/android/rum/internal/tracking/OreoFragmentLifecycleCallbacks.kt index d1a4045488..db51a7868b 100644 --- a/dd-sdk-android/src/main/kotlin/com/datadog/android/rum/internal/tracking/OreoFragmentLifecycleCallbacks.kt +++ b/dd-sdk-android/src/main/kotlin/com/datadog/android/rum/internal/tracking/OreoFragmentLifecycleCallbacks.kt @@ -77,8 +77,14 @@ internal class OreoFragmentLifecycleCallbacks( override fun onFragmentResumed(fm: FragmentManager, f: Fragment) { super.onFragmentResumed(fm, f) componentPredicate.runIfValid(f) { + val customViewName = componentPredicate.getViewName(f) + val viewName = if (customViewName.isNullOrBlank()) { + it.resolveViewName() + } else { + customViewName + } viewLoadingTimer.onFinishedLoading(f) - rumMonitor.startView(it, it.resolveViewName(), argumentsProvider(it)) + rumMonitor.startView(it, viewName, argumentsProvider(it)) val loadingTime = viewLoadingTimer.getLoadingTime(it) if (loadingTime != null) { advancedRumMonitor.updateViewLoadingTime( diff --git a/dd-sdk-android/src/main/kotlin/com/datadog/android/rum/tracking/AcceptAllActivities.kt b/dd-sdk-android/src/main/kotlin/com/datadog/android/rum/tracking/AcceptAllActivities.kt index cbdb098046..554de39783 100644 --- a/dd-sdk-android/src/main/kotlin/com/datadog/android/rum/tracking/AcceptAllActivities.kt +++ b/dd-sdk-android/src/main/kotlin/com/datadog/android/rum/tracking/AcceptAllActivities.kt @@ -13,9 +13,15 @@ import android.app.Activity * to be tracked as a RUM View event. * This is the default behaviour for the [ActivityViewTrackingStrategy]. */ -class AcceptAllActivities : ComponentPredicate { +open class AcceptAllActivities : ComponentPredicate { + /** @inheritdoc */ override fun accept(component: Activity): Boolean { return true } + + /** @inheritdoc */ + override fun getViewName(component: Activity): String? { + return null + } } diff --git a/dd-sdk-android/src/main/kotlin/com/datadog/android/rum/tracking/AcceptAllDefaultFragment.kt b/dd-sdk-android/src/main/kotlin/com/datadog/android/rum/tracking/AcceptAllDefaultFragment.kt index da9393a3d4..125386deea 100644 --- a/dd-sdk-android/src/main/kotlin/com/datadog/android/rum/tracking/AcceptAllDefaultFragment.kt +++ b/dd-sdk-android/src/main/kotlin/com/datadog/android/rum/tracking/AcceptAllDefaultFragment.kt @@ -12,9 +12,16 @@ import android.app.Fragment * A predefined [ComponentPredicate] which accepts all [Fragment] to be tracked as RUM View event. * This is the default behaviour for the [FragmentViewTrackingStrategy]. */ -class AcceptAllDefaultFragment : ComponentPredicate { +@Suppress("DEPRECATION") +open class AcceptAllDefaultFragment : ComponentPredicate { + /** @inheritdoc */ override fun accept(component: Fragment): Boolean { return true } + + /** @inheritdoc */ + override fun getViewName(component: Fragment): String? { + return null + } } diff --git a/dd-sdk-android/src/main/kotlin/com/datadog/android/rum/tracking/AcceptAllSupportFragments.kt b/dd-sdk-android/src/main/kotlin/com/datadog/android/rum/tracking/AcceptAllSupportFragments.kt index 9e4fb0e6f2..c811347d35 100644 --- a/dd-sdk-android/src/main/kotlin/com/datadog/android/rum/tracking/AcceptAllSupportFragments.kt +++ b/dd-sdk-android/src/main/kotlin/com/datadog/android/rum/tracking/AcceptAllSupportFragments.kt @@ -12,9 +12,15 @@ import androidx.fragment.app.Fragment * A predefined [ComponentPredicate] which accepts all [Fragment] to be tracked as RUM View * event. This is the default behaviour of the [FragmentViewTrackingStrategy]. */ -class AcceptAllSupportFragments : ComponentPredicate { +open class AcceptAllSupportFragments : ComponentPredicate { + /** @inheritdoc */ override fun accept(component: Fragment): Boolean { return true } + + /** @inheritdoc */ + override fun getViewName(component: Fragment): String? { + return null + } } diff --git a/dd-sdk-android/src/main/kotlin/com/datadog/android/rum/tracking/ActivityViewTrackingStrategy.kt b/dd-sdk-android/src/main/kotlin/com/datadog/android/rum/tracking/ActivityViewTrackingStrategy.kt index a076d5b672..de59d17b33 100644 --- a/dd-sdk-android/src/main/kotlin/com/datadog/android/rum/tracking/ActivityViewTrackingStrategy.kt +++ b/dd-sdk-android/src/main/kotlin/com/datadog/android/rum/tracking/ActivityViewTrackingStrategy.kt @@ -8,6 +8,7 @@ package com.datadog.android.rum.tracking import android.app.Activity import android.os.Bundle +import com.datadog.android.core.internal.utils.resolveViewName import com.datadog.android.core.internal.utils.runIfValid import com.datadog.android.rum.GlobalRum import com.datadog.android.rum.internal.monitor.AdvancedRumMonitor @@ -50,13 +51,20 @@ class ActivityViewTrackingStrategy @JvmOverloads constructor( override fun onActivityResumed(activity: Activity) { super.onActivityResumed(activity) componentPredicate.runIfValid(activity) { - val javaClass = it.javaClass - val vieName = javaClass.canonicalName ?: javaClass.simpleName - val attributes = - if (trackExtras) convertToRumAttributes(it.intent?.extras) else emptyMap() + val customViewName = componentPredicate.getViewName(activity) + val viewName = if (customViewName.isNullOrBlank()) { + it.resolveViewName() + } else { + customViewName + } + val attributes = if (trackExtras) { + convertToRumAttributes(it.intent?.extras) + } else { + emptyMap() + } GlobalRum.monitor.startView( it, - vieName, + viewName, attributes ) // we still need to call onFinishedLoading here for API bellow 29 as the diff --git a/dd-sdk-android/src/main/kotlin/com/datadog/android/rum/tracking/ComponentPredicate.kt b/dd-sdk-android/src/main/kotlin/com/datadog/android/rum/tracking/ComponentPredicate.kt index ab1b5e50b8..81bc3b2d06 100644 --- a/dd-sdk-android/src/main/kotlin/com/datadog/android/rum/tracking/ComponentPredicate.kt +++ b/dd-sdk-android/src/main/kotlin/com/datadog/android/rum/tracking/ComponentPredicate.kt @@ -24,4 +24,10 @@ interface ComponentPredicate { * */ fun accept(component: T): Boolean + + /** + * Sets a custome name for the tracked RUM View. + * @return the name to use for this view (if null or blank, the default will be used) + */ + fun getViewName(component: T): String? } diff --git a/dd-sdk-android/src/main/kotlin/com/datadog/android/rum/tracking/NavigationViewTrackingStrategy.kt b/dd-sdk-android/src/main/kotlin/com/datadog/android/rum/tracking/NavigationViewTrackingStrategy.kt index 14fb84a07f..0ed2d536e2 100644 --- a/dd-sdk-android/src/main/kotlin/com/datadog/android/rum/tracking/NavigationViewTrackingStrategy.kt +++ b/dd-sdk-android/src/main/kotlin/com/datadog/android/rum/tracking/NavigationViewTrackingStrategy.kt @@ -48,6 +48,10 @@ class NavigationViewTrackingStrategy( override fun accept(component: Fragment): Boolean { return !NavHostFragment::class.java.isAssignableFrom(component.javaClass) } + + override fun getViewName(component: Fragment): String? { + return null + } } // region ActivityLifecycleTrackingStrategy diff --git a/dd-sdk-android/src/main/kotlin/com/datadog/android/tracing/AndroidTracer.kt b/dd-sdk-android/src/main/kotlin/com/datadog/android/tracing/AndroidTracer.kt index 7540840344..61bce87d80 100644 --- a/dd-sdk-android/src/main/kotlin/com/datadog/android/tracing/AndroidTracer.kt +++ b/dd-sdk-android/src/main/kotlin/com/datadog/android/tracing/AndroidTracer.kt @@ -7,9 +7,11 @@ package com.datadog.android.tracing import com.datadog.android.core.internal.CoreFeature +import com.datadog.android.core.internal.utils.devLogger import com.datadog.android.log.LogAttributes import com.datadog.android.log.Logger import com.datadog.android.rum.GlobalRum +import com.datadog.android.rum.internal.RumFeature import com.datadog.android.tracing.internal.TracesFeature import com.datadog.android.tracing.internal.data.TraceWriter import com.datadog.android.tracing.internal.handlers.AndroidSpanLogsHandler @@ -73,6 +75,13 @@ class AndroidTracer internal constructor( * Builds a [AndroidTracer] based on the current state of this Builder. */ fun build(): AndroidTracer { + if (!TracesFeature.isInitialized()) { + devLogger.e(TRACING_NOT_ENABLED_ERROR_MESSAGE) + } + if (bundleWithRumEnabled && !RumFeature.isInitialized()) { + devLogger.e(RUM_NOT_ENABLED_ERROR_MESSAGE) + bundleWithRumEnabled = false + } return AndroidTracer( config(), TraceWriter(TracesFeature.persistenceStrategy.getWriter()), @@ -169,6 +178,15 @@ class AndroidTracer internal constructor( // endregion companion object { + internal const val TRACING_NOT_ENABLED_ERROR_MESSAGE = + "You're trying to create an AndroidTracer instance, " + + "but the Tracing feature was disabled in your DatadogConfig. " + + "No tracing data will be sent." + internal const val RUM_NOT_ENABLED_ERROR_MESSAGE = + "You're trying to bundle the traces with a RUM context, " + + "but the RUM feature was disabled in your DatadogConfig. " + + "No RUM context will be attached to your traces in this case." + // the minimum closed spans required for triggering a flush and deliver // everything to the writer internal const val DEFAULT_PARTIAL_MIN_FLUSH = 5 diff --git a/dd-sdk-android/src/main/kotlin/com/datadog/android/tracing/TracingInterceptor.kt b/dd-sdk-android/src/main/kotlin/com/datadog/android/tracing/TracingInterceptor.kt index 4c8ee8ad86..ddaff7d85b 100644 --- a/dd-sdk-android/src/main/kotlin/com/datadog/android/tracing/TracingInterceptor.kt +++ b/dd-sdk-android/src/main/kotlin/com/datadog/android/tracing/TracingInterceptor.kt @@ -62,8 +62,7 @@ import okhttp3.Response */ @Suppress("StringLiteralDuplication") open class TracingInterceptor -@JvmOverloads internal constructor( - @Deprecated("hosts should be defined in the DatadogConfig.setFirstPartyHosts()") +internal constructor( internal val tracedHosts: List, internal val tracedRequestListener: TracedRequestListener, internal val firstPartyHostDetector: FirstPartyHostDetector, @@ -73,11 +72,12 @@ open class TracingInterceptor private val localTracerReference: AtomicReference = AtomicReference() + private val localFirstPartyHostDetector = FirstPartyHostDetector(tracedHosts) + init { - if (tracedHosts.isEmpty() && firstPartyHostDetector.isEmpty()) { + if (localFirstPartyHostDetector.isEmpty() && firstPartyHostDetector.isEmpty()) { devLogger.w(WARNING_TRACING_NO_HOSTS) } - firstPartyHostDetector.addKnownHosts(tracedHosts) } /** @@ -89,12 +89,6 @@ open class TracingInterceptor * @param tracedRequestListener a listener for automatically created [Span]s */ @JvmOverloads - @Deprecated( - "Hosts should be defined in the DatadogConfig.setFirstPartyHosts().", - ReplaceWith( - expression = "TracingInterceptor(tracedRequestListener)" - ) - ) constructor( tracedHosts: List, tracedRequestListener: TracedRequestListener = NoOpTracedRequestListener() diff --git a/dd-sdk-android/src/main/kotlin/com/datadog/android/tracing/internal/data/TraceWriter.kt b/dd-sdk-android/src/main/kotlin/com/datadog/android/tracing/internal/data/TraceWriter.kt index cac4ffa9ec..db497e7941 100644 --- a/dd-sdk-android/src/main/kotlin/com/datadog/android/tracing/internal/data/TraceWriter.kt +++ b/dd-sdk-android/src/main/kotlin/com/datadog/android/tracing/internal/data/TraceWriter.kt @@ -7,6 +7,7 @@ package com.datadog.android.tracing.internal.data import com.datadog.android.rum.GlobalRum +import com.datadog.android.rum.RumAttributes import com.datadog.android.rum.RumErrorSource import com.datadog.android.rum.internal.monitor.AdvancedRumMonitor import com.datadog.opentracing.DDSpan @@ -25,9 +26,6 @@ internal class TraceWriter( override fun write(trace: MutableList?) { trace?.let { - it.filter { it.isError }.forEach { span -> - sendRumErrorEvent(span) - } writer.write(it) } } @@ -45,38 +43,48 @@ internal class TraceWriter( // region Internals private fun sendRumErrorEvent(span: DDSpan) { + val errorType = span.tags[DDTags.ERROR_TYPE] as? String + val errorMessage = span.tags[DDTags.ERROR_MSG] as? String + val composedErrorMessage = spanErrorMessage(span.operationName, errorType, errorMessage) + val attributes = if (errorType != null) { + mapOf(RumAttributes.INTERNAL_ERROR_TYPE to errorType) + } else { + emptyMap() + } (GlobalRum.get() as? AdvancedRumMonitor)?.addErrorWithStacktrace( - spanErrorMessage(span), + composedErrorMessage, RumErrorSource.SOURCE, span.tags[DDTags.ERROR_STACK]?.toString(), - emptyMap() + attributes ) } - private fun spanErrorMessage(span: DDSpan): String { - val errorType = span.tags[DDTags.ERROR_TYPE] - val errorMessage = span.tags[DDTags.ERROR_MSG] + private fun spanErrorMessage( + operationName: String, + errorType: String?, + errorMessage: String? + ): String { return when { errorMessage != null && errorType != null -> SPAN_ERROR_WITH_TYPE_AND_MESSAGE_FORMAT.format( Locale.US, - span.operationName, + operationName, errorType, errorMessage ) errorType != null -> SPAN_ERROR_WITH_TYPE_FORMAT.format( Locale.US, - span.operationName, + operationName, errorType ) errorMessage != null -> SPAN_ERROR_WITH_MESSAGE_FORMAT.format( Locale.US, - span.operationName, + operationName, errorMessage ) - else -> SPAN_ERROR_FORMAT.format(Locale.US, span.operationName) + else -> SPAN_ERROR_FORMAT.format(Locale.US, operationName) } } diff --git a/dd-sdk-android/src/test/kotlin/com/datadog/android/DatadogConfigBuilderTest.kt b/dd-sdk-android/src/test/kotlin/com/datadog/android/DatadogConfigBuilderTest.kt index 413ffaa003..2d566e1f85 100644 --- a/dd-sdk-android/src/test/kotlin/com/datadog/android/DatadogConfigBuilderTest.kt +++ b/dd-sdk-android/src/test/kotlin/com/datadog/android/DatadogConfigBuilderTest.kt @@ -1144,7 +1144,7 @@ internal class DatadogConfigBuilderTest { assertThat(config.rumConfig).isNull() verify(mockDevLogHandler).handleLog( Log.WARN, - DatadogConfig.Builder.RUM_NOT_INITIALISED_WARNING_MESSAGE + Datadog.WARNING_MESSAGE_APPLICATION_ID_IS_NULL ) } diff --git a/dd-sdk-android/src/test/kotlin/com/datadog/android/DatadogTest.kt b/dd-sdk-android/src/test/kotlin/com/datadog/android/DatadogTest.kt index 3867e2aa49..be14db3650 100644 --- a/dd-sdk-android/src/test/kotlin/com/datadog/android/DatadogTest.kt +++ b/dd-sdk-android/src/test/kotlin/com/datadog/android/DatadogTest.kt @@ -30,6 +30,7 @@ import com.datadog.tools.unit.extensions.ApiLevelExtension import com.datadog.tools.unit.invokeMethod import com.nhaarman.mockitokotlin2.doReturn import com.nhaarman.mockitokotlin2.mock +import com.nhaarman.mockitokotlin2.never import com.nhaarman.mockitokotlin2.verify import com.nhaarman.mockitokotlin2.verifyZeroInteractions import com.nhaarman.mockitokotlin2.whenever @@ -86,9 +87,6 @@ internal class DatadogTest { @StringForgery(regex = "[a-zA-Z0-9_:./-]{0,195}[a-zA-Z0-9_./-]") lateinit var fakeEnvName: String - @StringForgery(regex = "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}") - lateinit var fakeApplicationId: String - @TempDir lateinit var tempRootDir: File @@ -303,6 +301,58 @@ internal class DatadogTest { assertThat(RumFeature.initialized.get()).isEqualTo(rumEnabled) } + @Test + fun `𝕄 log a warning π•Ž initialize() { null applicationID, rumEnabled }`() { + // Given + val credentials = Credentials(fakeToken, fakeEnvName, fakeVariant, null, null) + val configuration = Configuration.Builder( + logsEnabled = true, + tracesEnabled = true, + crashReportsEnabled = true, + rumEnabled = true + ).build() + + // When + Datadog.initialize(mockAppContext, credentials, configuration, fakeConsent) + + // Then + assertThat(CoreFeature.initialized.get()).isTrue() + assertThat(LogsFeature.initialized.get()).isTrue() + assertThat(CrashReportsFeature.initialized.get()).isTrue() + assertThat(TracesFeature.initialized.get()).isTrue() + assertThat(RumFeature.initialized.get()).isTrue() + verify(mockDevLogHandler).handleLog( + android.util.Log.WARN, + Datadog.WARNING_MESSAGE_APPLICATION_ID_IS_NULL + ) + } + + @Test + fun `𝕄 do nothing π•Ž initialize() { null applicationID, rumDisabled }`() { + // Given + val credentials = Credentials(fakeToken, fakeEnvName, fakeVariant, null, null) + val configuration = Configuration.Builder( + logsEnabled = true, + tracesEnabled = true, + crashReportsEnabled = true, + rumEnabled = false + ).build() + + // When + Datadog.initialize(mockAppContext, credentials, configuration, fakeConsent) + + // Then + assertThat(CoreFeature.initialized.get()).isTrue() + assertThat(LogsFeature.initialized.get()).isTrue() + assertThat(CrashReportsFeature.initialized.get()).isTrue() + assertThat(TracesFeature.initialized.get()).isTrue() + assertThat(RumFeature.initialized.get()).isFalse() + verify(mockDevLogHandler, never()).handleLog( + android.util.Log.WARN, + Datadog.WARNING_MESSAGE_APPLICATION_ID_IS_NULL + ) + } + // region Deprecated @Test @@ -357,25 +407,46 @@ internal class DatadogTest { } @Test - fun `𝕄 initialize features π•Ž initialize(context, config) deprecated method`() { + fun `𝕄 log a warning π•Ž initialize(context, config) { null applicationID, rumEnabled }`() { // Given - val credentials = Credentials(fakeToken, fakeEnvName, fakeVariant, fakeApplicationId, null) - val configuration = Configuration.Builder( - logsEnabled = true, - tracesEnabled = true, - crashReportsEnabled = true, - rumEnabled = true - ).build() + val config = DatadogConfig.Builder(fakeToken, fakeEnvName) + .setRumEnabled(true) + .build() // When - Datadog.initialize(mockAppContext, credentials, configuration, fakeConsent) + Datadog.initialize(mockAppContext, config) // Then assertThat(CoreFeature.initialized.get()).isTrue() assertThat(LogsFeature.initialized.get()).isTrue() assertThat(CrashReportsFeature.initialized.get()).isTrue() assertThat(TracesFeature.initialized.get()).isTrue() - assertThat(RumFeature.initialized.get()).isTrue() + assertThat(RumFeature.initialized.get()).isFalse() + verify(mockDevLogHandler).handleLog( + android.util.Log.WARN, + Datadog.WARNING_MESSAGE_APPLICATION_ID_IS_NULL + ) + } + + @Test + fun `𝕄 do nothing π•Ž initialize(context, config) { null applicationID, rumDisabled }`() { + // Given + val config = DatadogConfig.Builder(fakeToken, fakeEnvName) + .build() + + // When + Datadog.initialize(mockAppContext, config) + + // Then + assertThat(CoreFeature.initialized.get()).isTrue() + assertThat(LogsFeature.initialized.get()).isTrue() + assertThat(CrashReportsFeature.initialized.get()).isTrue() + assertThat(TracesFeature.initialized.get()).isTrue() + assertThat(RumFeature.initialized.get()).isFalse() + verify(mockDevLogHandler, never()).handleLog( + android.util.Log.WARN, + Datadog.WARNING_MESSAGE_APPLICATION_ID_IS_NULL + ) } // endregion diff --git a/dd-sdk-android/src/test/kotlin/com/datadog/android/core/internal/domain/assertj/PersistenceStrategyAssert.kt b/dd-sdk-android/src/test/kotlin/com/datadog/android/core/internal/domain/assertj/PersistenceStrategyAssert.kt index c581d84a25..dbbf3a3221 100644 --- a/dd-sdk-android/src/test/kotlin/com/datadog/android/core/internal/domain/assertj/PersistenceStrategyAssert.kt +++ b/dd-sdk-android/src/test/kotlin/com/datadog/android/core/internal/domain/assertj/PersistenceStrategyAssert.kt @@ -10,6 +10,7 @@ import com.datadog.android.core.internal.data.file.FileOrchestrator import com.datadog.android.core.internal.data.file.ImmediateFileWriter import com.datadog.android.core.internal.domain.FilePersistenceConfig import com.datadog.android.core.internal.domain.FilePersistenceStrategy +import com.datadog.android.core.internal.domain.batching.ConsentAwareDataWriter import com.datadog.android.core.internal.domain.batching.DefaultConsentAwareDataWriter import org.assertj.core.api.AbstractObjectAssert import org.assertj.core.api.Assertions.assertThat @@ -54,6 +55,13 @@ internal class PersistenceStrategyAssert(actual: FilePersistenceStrateg return this } + fun hasFileInternalWriterInstanceOf(type: Class): PersistenceStrategyAssert { + assertThat(actual.getWriter()).isInstanceOfSatisfying(ConsentAwareDataWriter::class.java) { + assertThat(it.getInternalWriter()).isInstanceOf(type) + } + return this + } + fun hasConfig(config: FilePersistenceConfig): PersistenceStrategyAssert { assertThat(actual.intermediateFileOrchestrator.filePersistenceConfig) .isEqualToComparingFieldByField(config) diff --git a/dd-sdk-android/src/test/kotlin/com/datadog/android/core/internal/domain/batching/DataProcessorFactoryTest.kt b/dd-sdk-android/src/test/kotlin/com/datadog/android/core/internal/domain/batching/DataProcessorFactoryTest.kt index 3fa6d71560..31f94578e0 100644 --- a/dd-sdk-android/src/test/kotlin/com/datadog/android/core/internal/domain/batching/DataProcessorFactoryTest.kt +++ b/dd-sdk-android/src/test/kotlin/com/datadog/android/core/internal/domain/batching/DataProcessorFactoryTest.kt @@ -7,6 +7,7 @@ package com.datadog.android.core.internal.domain.batching import com.datadog.android.core.internal.data.Orchestrator +import com.datadog.android.core.internal.data.Writer import com.datadog.android.core.internal.data.file.ImmediateFileWriter import com.datadog.android.core.internal.domain.Serializer import com.datadog.android.core.internal.domain.batching.processors.DefaultDataProcessor @@ -14,9 +15,11 @@ import com.datadog.android.core.internal.domain.batching.processors.NoOpDataProc import com.datadog.android.event.EventMapper import com.datadog.android.privacy.TrackingConsent import com.datadog.android.utils.forge.Configurator +import com.nhaarman.mockitokotlin2.any import com.nhaarman.mockitokotlin2.mock import com.nhaarman.mockitokotlin2.verify import com.nhaarman.mockitokotlin2.verifyNoMoreInteractions +import com.nhaarman.mockitokotlin2.whenever import fr.xgouchet.elmyr.Forge import fr.xgouchet.elmyr.junit5.ForgeConfiguration import fr.xgouchet.elmyr.junit5.ForgeExtension @@ -139,4 +142,34 @@ internal class DataProcessorFactoryTest { // THEN assertThat(processor).isInstanceOf(NoOpDataProcessor::class.java) } + + @Test + fun `M use the fileWriterFactory W provided`(forge: Forge) { + // GIVEN + val mockFileWriterFactory = + mock<(Orchestrator, Serializer, CharSequence) -> Writer>() + val mockFileWriter: Writer = mock() + whenever(mockFileWriterFactory.invoke(any(), any(), any())).thenReturn(mockFileWriter) + testedFactory = DataProcessorFactory( + mockedIntermediateFileOrchestrator, + mockedTargetFileOrchestrator, + mockedSerializer, + fakeEventsSeparator, + mockedExecutorService, + fileWriterFactory = mockFileWriterFactory + ) + + // WHEN + val processor = testedFactory.resolveProcessor( + forge.aValueFrom( + TrackingConsent::class.java, + exclude = listOf(TrackingConsent.NOT_GRANTED) + ) + ) + + // THEN + assertThat(processor).isInstanceOfSatisfying(DefaultDataProcessor::class.java) { + assertThat(it.dataWriter).isEqualTo(mockFileWriter) + } + } } diff --git a/dd-sdk-android/src/test/kotlin/com/datadog/android/log/internal/domain/LogFileStrategyTest.kt b/dd-sdk-android/src/test/kotlin/com/datadog/android/log/internal/domain/LogFileStrategyTest.kt index f07a3a812f..67d0cf90c4 100644 --- a/dd-sdk-android/src/test/kotlin/com/datadog/android/log/internal/domain/LogFileStrategyTest.kt +++ b/dd-sdk-android/src/test/kotlin/com/datadog/android/log/internal/domain/LogFileStrategyTest.kt @@ -7,6 +7,7 @@ package com.datadog.android.log.internal.domain import android.content.Context +import com.datadog.android.core.internal.data.file.ImmediateFileWriter import com.datadog.android.core.internal.domain.FilePersistenceConfig import com.datadog.android.core.internal.domain.assertj.PersistenceStrategyAssert import com.datadog.android.core.internal.privacy.ConsentProvider @@ -15,6 +16,7 @@ import com.datadog.android.utils.forge.Configurator import com.datadog.android.utils.mockContext import com.nhaarman.mockitokotlin2.doReturn import com.nhaarman.mockitokotlin2.whenever +import fr.xgouchet.elmyr.Forge import fr.xgouchet.elmyr.annotation.Forgery import fr.xgouchet.elmyr.junit5.ForgeConfiguration import fr.xgouchet.elmyr.junit5.ForgeExtension @@ -83,4 +85,25 @@ internal class LogFileStrategyTest { .usesConsentAwareAsyncWriter() .hasConfig(fakePersistenceConfig) } + + @Test + fun `M use the DefaultFileWriter factory W instantiating the writer`(forge: Forge) { + // GIVEN + // just to avoid using the NoOpWriter factory + whenever(mockConsentProvider.getConsent()) doReturn forge.aValueFrom( + TrackingConsent::class.java, + exclude = listOf(TrackingConsent.NOT_GRANTED) + ) + testedStrategy = LogFileStrategy( + mockedContext, + dataPersistenceExecutorService = mockExecutorService, + trackingConsentProvider = mockConsentProvider, + filePersistenceConfig = fakePersistenceConfig + ) + + // THEN + PersistenceStrategyAssert + .assertThat(testedStrategy) + .hasFileInternalWriterInstanceOf(ImmediateFileWriter::class.java) + } } diff --git a/dd-sdk-android/src/test/kotlin/com/datadog/android/log/internal/domain/LogSerializerTest.kt b/dd-sdk-android/src/test/kotlin/com/datadog/android/log/internal/domain/LogSerializerTest.kt index c01090dc74..1fb98f8b7e 100644 --- a/dd-sdk-android/src/test/kotlin/com/datadog/android/log/internal/domain/LogSerializerTest.kt +++ b/dd-sdk-android/src/test/kotlin/com/datadog/android/log/internal/domain/LogSerializerTest.kt @@ -165,7 +165,7 @@ internal class LogSerializerTest { // WHEN val serializedEvent = testedSerializer.serialize(fakeLog) - JsonParser.parseString(serializedEvent).asJsonObject + val jsonObject = JsonParser.parseString(serializedEvent).asJsonObject // THEN verify(mockedDataConstrains).validateAttributes( @@ -175,6 +175,30 @@ internal class LogSerializerTest { ) } + @Test + fun `M use the simple name as error kind W serialize { canonical name is null }`( + @Forgery fakeLog: Log, + forge: Forge + ) { + + // GIVEN + class CustomThrowable : Throwable() + + val fakeThrowable = CustomThrowable() + val fakeLogWithLocalThrowable = fakeLog.copy(throwable = fakeThrowable) + + // WHEN + val serializedEvent = testedSerializer.serialize(fakeLogWithLocalThrowable) + val jsonObject = JsonParser.parseString(serializedEvent).asJsonObject + + // THEN + assertThat(jsonObject) + .hasField( + LogAttributes.ERROR_KIND, + fakeThrowable.javaClass.simpleName + ) + } + // region Internal private fun assertSerializedLogMatchesInputLog( @@ -271,8 +295,10 @@ internal class LogSerializerTest { ) { val throwable = log.throwable if (throwable != null) { + val expectedErrorKind = + throwable.javaClass.canonicalName ?: throwable.javaClass.simpleName assertThat(jsonObject) - .hasField(LogAttributes.ERROR_KIND, throwable.javaClass.simpleName) + .hasField(LogAttributes.ERROR_KIND, expectedErrorKind) .hasNullableField(LogAttributes.ERROR_MESSAGE, throwable.message) .hasField(LogAttributes.ERROR_STACK, throwable.loggableStackTrace()) } else { diff --git a/dd-sdk-android/src/test/kotlin/com/datadog/android/rum/ActivityViewTrackingStrategyTest.kt b/dd-sdk-android/src/test/kotlin/com/datadog/android/rum/ActivityViewTrackingStrategyTest.kt index e1652852f0..991f58d86f 100644 --- a/dd-sdk-android/src/test/kotlin/com/datadog/android/rum/ActivityViewTrackingStrategyTest.kt +++ b/dd-sdk-android/src/test/kotlin/com/datadog/android/rum/ActivityViewTrackingStrategyTest.kt @@ -14,12 +14,18 @@ import com.datadog.android.rum.tracking.ActivityViewTrackingStrategy import com.datadog.android.rum.tracking.ComponentPredicate import com.datadog.android.utils.forge.Configurator import com.datadog.tools.unit.setFieldValue -import com.nhaarman.mockitokotlin2.eq +import com.nhaarman.mockitokotlin2.doReturn import com.nhaarman.mockitokotlin2.inOrder import com.nhaarman.mockitokotlin2.verify import com.nhaarman.mockitokotlin2.verifyZeroInteractions import com.nhaarman.mockitokotlin2.whenever import fr.xgouchet.elmyr.Forge +import fr.xgouchet.elmyr.annotation.AdvancedForgery +import fr.xgouchet.elmyr.annotation.BoolForgery +import fr.xgouchet.elmyr.annotation.LongForgery +import fr.xgouchet.elmyr.annotation.MapForgery +import fr.xgouchet.elmyr.annotation.StringForgery +import fr.xgouchet.elmyr.annotation.StringForgeryType import fr.xgouchet.elmyr.junit5.ForgeConfiguration import fr.xgouchet.elmyr.junit5.ForgeExtension import org.junit.jupiter.api.BeforeEach @@ -42,208 +48,288 @@ internal class ActivityViewTrackingStrategyTest : ActivityLifecycleTrackingStrat @Mock lateinit var mockViewLoadingTimer: ViewLoadingTimer - // region tests + @Mock + lateinit var mockPredicate: ComponentPredicate @BeforeEach override fun `set up`(forge: Forge) { super.`set up`(forge) - testedStrategy = - ActivityViewTrackingStrategy(true) + testedStrategy = ActivityViewTrackingStrategy(true, mockPredicate) testedStrategy.setFieldValue("viewLoadingTimer", mockViewLoadingTimer) } + // region Track View Loading Time + @Test - fun `when created will notify the viewLoadingTimer`(forge: Forge) { + fun `𝕄 notify viewLoadingTimer π•Ž onActivityCreated()`() { + // Given + whenever(mockPredicate.accept(mockActivity)) doReturn true + // When testedStrategy.onActivityCreated(mockActivity, null) + // Then verify(mockViewLoadingTimer).onCreated(mockActivity) } @Test - fun `when created will do nothing if activity not whitelisted`(forge: Forge) { + fun `𝕄 notify viewLoadingTimer π•Ž onActivityStarted()`() { // Given - testedStrategy = ActivityViewTrackingStrategy( - true, - componentPredicate = object : - ComponentPredicate { - override fun accept(component: Activity): Boolean { - return false - } - } - ) + whenever(mockPredicate.accept(mockActivity)) doReturn true + + // When + testedStrategy.onActivityStarted(mockActivity) + + // Then + verify(mockViewLoadingTimer).onStartLoading(mockActivity) + } + + @Test + fun `𝕄 notify viewLoadingTimer π•Ž onActivityResumed()`() { + // Given + whenever(mockPredicate.accept(mockActivity)) doReturn true + + // When + testedStrategy.onActivityResumed(mockActivity) + + // Then + verify(mockViewLoadingTimer).onFinishedLoading(mockActivity) + } + + @Test + fun `𝕄 notify viewLoadingTimer π•Ž onActivityPostResumed()`() { + // Given + whenever(mockPredicate.accept(mockActivity)) doReturn true + + // When + testedStrategy.onActivityPostResumed(mockActivity) + + // Then + verify(mockViewLoadingTimer).onFinishedLoading(mockActivity) + } + + @Test + fun `𝕄 notify viewLoadingTimer π•Ž onActivityPaused()`() { + // Given + whenever(mockPredicate.accept(mockActivity)) doReturn true + + // When + testedStrategy.onActivityPaused(mockActivity) + + // Then + verify(mockViewLoadingTimer).onPaused(mockActivity) + } + + @Test + fun `𝕄 notify viewLoadingTimer π•Ž onActivityDestroyed()`() { + // Given + whenever(mockPredicate.accept(mockActivity)) doReturn true + + // When + testedStrategy.onActivityDestroyed(mockActivity) + + // Then + verify(mockViewLoadingTimer).onDestroyed(mockActivity) + } + + // endregion + + // region Track View Loading Time (not tracked) + + @Test + fun `𝕄 do nothing π•Ž onActivityCreated() {activity not tracked}`() { + // Given + whenever(mockPredicate.accept(mockActivity)) doReturn false + // When testedStrategy.onActivityCreated(mockActivity, null) + // Then verifyZeroInteractions(mockViewLoadingTimer) } @Test - fun `when started will notify the viewLoadingTimer for startLoading`() { - // Whenever + fun `𝕄 do nothing π•Ž onActivityStarted() {activity not tracked}`() { + // Given + whenever(mockPredicate.accept(mockActivity)) doReturn false + + // When testedStrategy.onActivityStarted(mockActivity) // Then - verify(mockViewLoadingTimer).onStartLoading(mockActivity) + verifyZeroInteractions(mockViewLoadingTimer) } @Test - fun `when started and activity not whitelisted will do nothing`() { + fun `𝕄 do nothing π•Ž onActivityResumed() {activity not tracked}`() { // Given - testedStrategy = ActivityViewTrackingStrategy( - trackExtras = false, - componentPredicate = object : - ComponentPredicate { - override fun accept(component: Activity): Boolean { - return false - } - } - ) + whenever(mockPredicate.accept(mockActivity)) doReturn false - // Whenever - testedStrategy.onActivityStarted(mockActivity) + // When + testedStrategy.onActivityResumed(mockActivity) + + // Then + verifyZeroInteractions(mockViewLoadingTimer) + } + + @Test + fun `𝕄 do nothing π•Ž onActivityPostResumed() {activity not tracked}`() { + // Given + whenever(mockPredicate.accept(mockActivity)) doReturn false + + // When + testedStrategy.onActivityPostResumed(mockActivity) // Then verifyZeroInteractions(mockViewLoadingTimer) } @Test - fun `when resumed it will start a view event`(forge: Forge) { + fun `𝕄 do nothing π•Ž onActivityPaused() {activity not tracked}`() { + // Given + whenever(mockPredicate.accept(mockActivity)) doReturn false + + // When + testedStrategy.onActivityPaused(mockActivity) + + // Then + verifyZeroInteractions(mockViewLoadingTimer) + } + + @Test + fun `𝕄 do nothing π•Ž onActivityDestroyed() {activity not tracked}`() { + // Given + whenever(mockPredicate.accept(mockActivity)) doReturn false + + // When + testedStrategy.onActivityDestroyed(mockActivity) + + // Then + verifyZeroInteractions(mockViewLoadingTimer) + } + + // endregion + + // region Track RUM View + + @Test + fun `𝕄 start a RUM View event π•Ž onActivityResumed()`() { + // Given + whenever(mockPredicate.accept(mockActivity)) doReturn true + // When testedStrategy.onActivityResumed(mockActivity) + // Then verify(mockRumMonitor).startView( - eq(mockActivity), - eq(mockActivity.resolveViewName()), - eq(emptyMap()) + mockActivity, + mockActivity.resolveViewName(), + emptyMap() ) } @Test - fun `when resumed will start a view event with intent extras as attributes`( - forge: Forge + fun `𝕄 start a RUM View event π•Ž onActivityResumed() {extra attributes}`( + @MapForgery( + key = AdvancedForgery(string = [StringForgery(StringForgeryType.ALPHABETICAL)]), + value = AdvancedForgery(string = [StringForgery(StringForgeryType.ASCII)]) + ) attributes: Map ) { // Given - val arguments = Bundle() - val expectedAttrs = mutableMapOf() - for (i in 0..10) { - val key = forge.anAlphabeticalString() - val value = forge.anAsciiString() - arguments.putString(key, value) - expectedAttrs["view.arguments.$key"] = value - } + val arguments = Bundle(attributes.size) + attributes.forEach { (k, v) -> arguments.putString(k, v) } whenever(mockIntent.extras).thenReturn(arguments) whenever(mockActivity.intent).thenReturn(mockIntent) + whenever(mockPredicate.accept(mockActivity)) doReturn true - // Whenever + // When testedStrategy.onActivityResumed(mockActivity) + // Then verify(mockRumMonitor).startView( - eq(mockActivity), - eq(mockActivity.resolveViewName()), - eq(expectedAttrs) + mockActivity, + mockActivity.resolveViewName(), + attributes.map { (k, v) -> "view.arguments.$k" to v }.toMap() ) } @Test - fun `when resumed and not tracking intent extras will send empty attributes`( - forge: Forge + fun `𝕄 start a RUM View event π•Ž onActivityResumed() {extra attributes not tracked}`( + @MapForgery( + key = AdvancedForgery(string = [StringForgery(StringForgeryType.ALPHABETICAL)]), + value = AdvancedForgery(string = [StringForgery(StringForgeryType.ASCII)]) + ) attributes: Map ) { // Given - testedStrategy = - ActivityViewTrackingStrategy(false) - val arguments = Bundle() - for (i in 0..10) { - val key = forge.anAlphabeticalString() - val value = forge.anAsciiString() - arguments.putString(key, value) - } + val arguments = Bundle(attributes.size) + attributes.forEach { (k, v) -> arguments.putString(k, v) } + testedStrategy = ActivityViewTrackingStrategy(false, mockPredicate) whenever(mockIntent.extras).thenReturn(arguments) whenever(mockActivity.intent).thenReturn(mockIntent) + whenever(mockPredicate.accept(mockActivity)) doReturn true - // Whenever + // When testedStrategy.onActivityResumed(mockActivity) verify(mockRumMonitor).startView( - eq(mockActivity), - eq(mockActivity.resolveViewName()), - eq(emptyMap()) + mockActivity, + mockActivity.resolveViewName(), + emptyMap() ) } @Test - fun `when resumed will do nothing if activity is not whitelisted`() { + fun `𝕄 start a RUM View event π•Ž onActivityResumed() {custom view name}`( + @StringForgery fakeName: String + ) { // Given - testedStrategy = ActivityViewTrackingStrategy( - trackExtras = false, - componentPredicate = object : - ComponentPredicate { - override fun accept(component: Activity): Boolean { - return false - } - } - ) + whenever(mockPredicate.accept(mockActivity)) doReturn true + whenever(mockPredicate.getViewName(mockActivity)) doReturn fakeName - // Whenever + // When testedStrategy.onActivityResumed(mockActivity) // Then - verifyZeroInteractions(mockRumMonitor) + verify(mockRumMonitor).startView( + mockActivity, + fakeName, + emptyMap() + ) } @Test - fun `when postResumed will notify the viewLoadingTimer for stopLoading`() { - // Whenever - testedStrategy.onActivityPostResumed(mockActivity) - - // Then - verify(mockViewLoadingTimer).onFinishedLoading(mockActivity) - } + fun `𝕄 start a RUM View event π•Ž onActivityResumed() {custom blank view name}`( + @StringForgery(StringForgeryType.WHITESPACE) fakeName: String + ) { + // Given + whenever(mockPredicate.accept(mockActivity)) doReturn true + whenever(mockPredicate.getViewName(mockActivity)) doReturn fakeName - @Test - fun `when resumed will notify the viewLoadingTimer for stopLoading`() { - // Whenever + // When testedStrategy.onActivityResumed(mockActivity) // Then - verify(mockViewLoadingTimer).onFinishedLoading(mockActivity) - } - - @Test - fun `when postResumed and activity not whitelisted will do nothing`() { - // Given - testedStrategy = ActivityViewTrackingStrategy( - trackExtras = false, - componentPredicate = object : - ComponentPredicate { - override fun accept(component: Activity): Boolean { - return false - } - } + verify(mockRumMonitor).startView( + mockActivity, + mockActivity.resolveViewName(), + emptyMap() ) - - // Whenever - testedStrategy.onActivityPostResumed(mockActivity) - - // Then - verifyZeroInteractions(mockViewLoadingTimer) } @Test - fun `when paused it will update the view loading time and stop it in this order`(forge: Forge) { + fun `𝕄 stop RUM View and update loading time π•Ž onActivityPaused()`( + @BoolForgery firstTimeLoading: Boolean, + @LongForgery(1L) loadingTime: Long + ) { // Given - val expectedLoadingTime = forge.aLong() - val firsTimeLoading = forge.aBool() - val expectedLoadingType = - if (firsTimeLoading) { - ViewEvent.LoadingType.ACTIVITY_DISPLAY - } else { - ViewEvent.LoadingType.ACTIVITY_REDISPLAY - } - whenever(mockViewLoadingTimer.getLoadingTime(mockActivity)) - .thenReturn(expectedLoadingTime) - whenever(mockViewLoadingTimer.isFirstTimeLoading(mockActivity)) - .thenReturn(firsTimeLoading) + whenever(mockPredicate.accept(mockActivity)) doReturn true + val expectedLoadingType = if (firstTimeLoading) { + ViewEvent.LoadingType.ACTIVITY_DISPLAY + } else { + ViewEvent.LoadingType.ACTIVITY_REDISPLAY + } + whenever(mockViewLoadingTimer.getLoadingTime(mockActivity)) doReturn loadingTime + whenever(mockViewLoadingTimer.isFirstTimeLoading(mockActivity)) doReturn firstTimeLoading // When testedStrategy.onActivityPaused(mockActivity) @@ -252,7 +338,7 @@ internal class ActivityViewTrackingStrategyTest : ActivityLifecycleTrackingStrat inOrder(mockRumMonitor, mockViewLoadingTimer) { verify(mockRumMonitor).updateViewLoadingTime( mockActivity, - expectedLoadingTime, + loadingTime, expectedLoadingType ) verify(mockRumMonitor).stopView(mockActivity, emptyMap()) @@ -260,55 +346,33 @@ internal class ActivityViewTrackingStrategyTest : ActivityLifecycleTrackingStrat } } + // endregion + + // region Track RUM View (not tracked) + @Test - fun `when paused will do nothing if activity is not whitelisted`() { + fun `𝕄 start a RUM View event π•Ž onActivityResumed() {activity not tracked}`() { // Given - testedStrategy = ActivityViewTrackingStrategy( - trackExtras = false, - componentPredicate = object : - ComponentPredicate { - override fun accept(component: Activity): Boolean { - return false - } - } - ) + whenever(mockPredicate.accept(mockActivity)) doReturn false - // Whenever - testedStrategy.onActivityPaused(mockActivity) + // When + testedStrategy.onActivityResumed(mockActivity) // Then verifyZeroInteractions(mockRumMonitor) } @Test - fun `when activity destroyed will notify the viewLoadingTimer for onDestroy`() { - // Whenever - testedStrategy.onActivityDestroyed(mockActivity) - - // Then - verify(mockViewLoadingTimer).onDestroyed(mockActivity) - } - - @Test - fun `when activity destroyed and not whitelisted will do nothing`() { + fun `𝕄 update RUM View loading time π•Ž onActivityPaused() {activity not tracked}`() { // Given - testedStrategy = ActivityViewTrackingStrategy( - trackExtras = false, - componentPredicate = object : - ComponentPredicate { - override fun accept(component: Activity): Boolean { - return false - } - } - ) + whenever(mockPredicate.accept(mockActivity)) doReturn false - // Whenever - testedStrategy.onActivityDestroyed(mockActivity) + // When + testedStrategy.onActivityPaused(mockActivity) // Then - verifyZeroInteractions(mockViewLoadingTimer) + verifyZeroInteractions(mockRumMonitor, mockViewLoadingTimer) } - // endregion // region internal diff --git a/dd-sdk-android/src/test/kotlin/com/datadog/android/rum/assertj/ErrorEventAssert.kt b/dd-sdk-android/src/test/kotlin/com/datadog/android/rum/assertj/ErrorEventAssert.kt index ec1ffd33d9..37ad48011b 100644 --- a/dd-sdk-android/src/test/kotlin/com/datadog/android/rum/assertj/ErrorEventAssert.kt +++ b/dd-sdk-android/src/test/kotlin/com/datadog/android/rum/assertj/ErrorEventAssert.kt @@ -242,6 +242,46 @@ internal class ErrorEventAssert(actual: ErrorEvent) : return this } + fun hasConnectivityStatus(expected: ErrorEvent.Status?): ErrorEventAssert { + assertThat(actual.connectivity?.status) + .overridingErrorMessage( + "Expected event data to have connectivity status: $expected" + + " but was: ${actual.connectivity?.status} " + ) + .isEqualTo(expected) + return this + } + + fun hasConnectivityInterface(expected: List?): ErrorEventAssert { + val interfaces = actual.connectivity?.interfaces + assertThat(interfaces) + .overridingErrorMessage( + "Expected event data to have connectivity interfaces: $expected" + + " but was: $interfaces " + ) + .isEqualTo(expected) + return this + } + + fun hasConnectivityCellular(expected: ErrorEvent.Cellular?): ErrorEventAssert { + assertThat(actual.connectivity?.cellular) + .overridingErrorMessage( + "Expected event data to have connectivity cellular: $expected" + + " but was: ${actual.connectivity?.cellular} " + ) + .isEqualTo(expected) + return this + } + + fun hasErrorType(expected: String?): ErrorEventAssert { + assertThat(actual.error.type) + .overridingErrorMessage( + "Expected event data to have error type $expected" + + " but was ${actual.error.type}" + ).isEqualTo(expected) + return this + } + companion object { internal fun assertThat(actual: ErrorEvent): ErrorEventAssert = diff --git a/dd-sdk-android/src/test/kotlin/com/datadog/android/rum/assertj/ResourceEventAssert.kt b/dd-sdk-android/src/test/kotlin/com/datadog/android/rum/assertj/ResourceEventAssert.kt index a75121f314..297104d12d 100644 --- a/dd-sdk-android/src/test/kotlin/com/datadog/android/rum/assertj/ResourceEventAssert.kt +++ b/dd-sdk-android/src/test/kotlin/com/datadog/android/rum/assertj/ResourceEventAssert.kt @@ -14,7 +14,6 @@ import com.datadog.android.rum.internal.domain.event.ResourceTiming import com.datadog.android.rum.internal.domain.scope.toMethod import com.datadog.android.rum.internal.domain.scope.toSchemaType import com.datadog.android.rum.model.ResourceEvent -import com.datadog.android.rum.model.ViewEvent import org.assertj.core.api.AbstractObjectAssert import org.assertj.core.api.Assertions.assertThat import org.assertj.core.data.Offset @@ -25,7 +24,7 @@ internal class ResourceEventAssert(actual: ResourceEvent) : ResourceEventAssert::class.java ) { - fun hasId(expected: String): ResourceEventAssert { + fun hasId(expected: String?): ResourceEventAssert { assertThat(actual.resource.id) .overridingErrorMessage( "Expected event data to have resource.id $expected " + @@ -398,7 +397,7 @@ internal class ResourceEventAssert(actual: ResourceEvent) : internal const val DURATION_THRESHOLD_NANOS = 1000L - internal fun assertThat(actual: ViewEvent): ViewEventAssert = - ViewEventAssert(actual) + internal fun assertThat(actual: ResourceEvent): ResourceEventAssert = + ResourceEventAssert(actual) } } diff --git a/dd-sdk-android/src/test/kotlin/com/datadog/android/rum/assertj/RumEventAssert.kt b/dd-sdk-android/src/test/kotlin/com/datadog/android/rum/assertj/RumEventAssert.kt index 78f2350331..7ff98b8bf4 100644 --- a/dd-sdk-android/src/test/kotlin/com/datadog/android/rum/assertj/RumEventAssert.kt +++ b/dd-sdk-android/src/test/kotlin/com/datadog/android/rum/assertj/RumEventAssert.kt @@ -11,10 +11,8 @@ import com.datadog.android.rum.model.ActionEvent import com.datadog.android.rum.model.ErrorEvent import com.datadog.android.rum.model.ResourceEvent import com.datadog.android.rum.model.ViewEvent -import java.util.concurrent.TimeUnit import org.assertj.core.api.AbstractObjectAssert import org.assertj.core.api.Assertions.assertThat -import org.assertj.core.data.Offset internal class RumEventAssert(actual: RumEvent) : AbstractObjectAssert( @@ -34,19 +32,6 @@ internal class RumEventAssert(actual: RumEvent) : return this } - fun hasCustomTimings(customTimings: Map): RumEventAssert { - customTimings.entries.forEach { entry -> - assertThat(actual.customTimings).hasEntrySatisfying(entry.key) { - assertThat(it).isCloseTo( - entry.value, - Offset.offset(TimeUnit.MILLISECONDS.toNanos(10)) - ) - } - } - - return this - } - fun hasViewData(assert: ViewEventAssert.() -> Unit): RumEventAssert { assertThat(actual.event) .isInstanceOf(ViewEvent::class.java) diff --git a/dd-sdk-android/src/test/kotlin/com/datadog/android/rum/assertj/ViewEventAssert.kt b/dd-sdk-android/src/test/kotlin/com/datadog/android/rum/assertj/ViewEventAssert.kt index 976ae00d55..48d6907c90 100644 --- a/dd-sdk-android/src/test/kotlin/com/datadog/android/rum/assertj/ViewEventAssert.kt +++ b/dd-sdk-android/src/test/kotlin/com/datadog/android/rum/assertj/ViewEventAssert.kt @@ -8,6 +8,7 @@ package com.datadog.android.rum.assertj import com.datadog.android.log.internal.user.UserInfo import com.datadog.android.rum.model.ViewEvent +import java.util.concurrent.TimeUnit import org.assertj.core.api.AbstractObjectAssert import org.assertj.core.api.Assertions.assertThat import org.assertj.core.data.Offset @@ -112,7 +113,7 @@ internal class ViewEventAssert(actual: ViewEvent) : return this } - fun hasCrashCount(expected: Long): ViewEventAssert { + fun hasCrashCount(expected: Long?): ViewEventAssert { assertThat(actual.view.crash?.count) .overridingErrorMessage( "Expected event data to have view.crash.count $expected " + @@ -185,6 +186,25 @@ internal class ViewEventAssert(actual: ViewEvent) : return this } + fun hasNoCustomTimings(): ViewEventAssert { + assertThat(actual.view.customTimings).isNull() + return this + } + + fun hasCustomTimings(customTimings: Map): ViewEventAssert { + customTimings.entries.forEach { entry -> + assertThat(actual.view.customTimings?.additionalProperties) + .hasEntrySatisfying(entry.key) { + assertThat(it).isCloseTo( + entry.value, + Offset.offset(TimeUnit.MILLISECONDS.toNanos(10)) + ) + } + } + + return this + } + fun hasUserInfo(expected: UserInfo?): ViewEventAssert { assertThat(actual.usr?.id) .overridingErrorMessage( @@ -207,6 +227,28 @@ internal class ViewEventAssert(actual: ViewEvent) : return this } + fun hasUserInfo(expected: ViewEvent.Usr?): ViewEventAssert { + assertThat(actual.usr?.id) + .overridingErrorMessage( + "Expected RUM event to have usr.id ${expected?.id} " + + "but was ${actual.usr?.id}" + ) + .isEqualTo(expected?.id) + assertThat(actual.usr?.name) + .overridingErrorMessage( + "Expected RUM event to have usr.name ${expected?.name} " + + "but was ${actual.usr?.name}" + ) + .isEqualTo(expected?.name) + assertThat(actual.usr?.email) + .overridingErrorMessage( + "Expected RUM event to have usr.email ${expected?.email} " + + "but was ${actual.usr?.email}" + ) + .isEqualTo(expected?.email) + return this + } + companion object { internal const val DURATION_THRESHOLD_NANOS = 1000L diff --git a/dd-sdk-android/src/test/kotlin/com/datadog/android/rum/internal/data/file/RumFileWriterTest.kt b/dd-sdk-android/src/test/kotlin/com/datadog/android/rum/internal/data/file/RumFileWriterTest.kt new file mode 100644 index 0000000000..6c69fd02a7 --- /dev/null +++ b/dd-sdk-android/src/test/kotlin/com/datadog/android/rum/internal/data/file/RumFileWriterTest.kt @@ -0,0 +1,356 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.datadog.android.rum.internal.data.file + +import android.os.Build +import com.datadog.android.core.internal.data.Orchestrator +import com.datadog.android.core.internal.domain.PayloadDecoration +import com.datadog.android.core.internal.domain.Serializer +import com.datadog.android.core.internal.threading.AndroidDeferredHandler +import com.datadog.android.rum.internal.domain.event.RumEvent +import com.datadog.android.rum.internal.domain.event.RumEventSerializer +import com.datadog.android.rum.model.ActionEvent +import com.datadog.android.rum.model.ErrorEvent +import com.datadog.android.rum.model.ResourceEvent +import com.datadog.android.rum.model.ViewEvent +import com.datadog.android.utils.forge.Configurator +import com.datadog.tools.unit.annotations.TestTargetApi +import com.google.gson.JsonParser +import com.nhaarman.mockitokotlin2.any +import com.nhaarman.mockitokotlin2.doAnswer +import com.nhaarman.mockitokotlin2.doThrow +import com.nhaarman.mockitokotlin2.mock +import com.nhaarman.mockitokotlin2.verifyZeroInteractions +import com.nhaarman.mockitokotlin2.whenever +import fr.xgouchet.elmyr.Forge +import fr.xgouchet.elmyr.annotation.Forgery +import fr.xgouchet.elmyr.annotation.StringForgery +import fr.xgouchet.elmyr.junit5.ForgeConfiguration +import fr.xgouchet.elmyr.junit5.ForgeExtension +import java.io.File +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import org.assertj.core.api.Assertions +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.junit.jupiter.api.extension.Extensions +import org.junit.jupiter.api.io.TempDir +import org.mockito.Mock +import org.mockito.junit.jupiter.MockitoExtension +import org.mockito.junit.jupiter.MockitoSettings +import org.mockito.quality.Strictness + +@Extensions( + ExtendWith(MockitoExtension::class), + ExtendWith(ForgeExtension::class) +) +@MockitoSettings(strictness = Strictness.LENIENT) +@ForgeConfiguration(Configurator::class) +internal class RumFileWriterTest { + lateinit var testedWriter: RumFileWriter + + lateinit var rumSerializer: Serializer + + @Mock + lateinit var mockOrchestrator: Orchestrator + + @Mock + lateinit var mockDeferredHandler: AndroidDeferredHandler + + @StringForgery(regex = "[a-zA-z]{3,10}") + lateinit var fakeNdkCrashDataFolderName: String + + lateinit var fakeNdkCrashDataDirectory: File + + @TempDir + lateinit var tempRootDir: File + + @BeforeEach + fun `set up`() { + fakeNdkCrashDataDirectory = File(tempRootDir, fakeNdkCrashDataFolderName) + rumSerializer = RumEventSerializer() + whenever(mockDeferredHandler.handle(any())) doAnswer { + val runnable = it.arguments[0] as Runnable + runnable.run() + } + testedWriter = RumFileWriter( + fakeNdkCrashDataDirectory, + mockOrchestrator, + rumSerializer + ) + } + + @AfterEach + fun `tear down`() { + tempRootDir.deleteRecursively() + } + + @Test + @TestTargetApi(Build.VERSION_CODES.O) + fun `𝕄 write a valid model π•Ž write(model)`(forge: Forge) { + val fakeModel: RumEvent = forge.getForgery() + val fileNameToWriteTo = forge.anAlphaNumericalString() + val file = File(tempRootDir, fileNameToWriteTo) + whenever(mockOrchestrator.getWritableFile(any())).thenReturn(file) + + testedWriter.write(fakeModel) + + Assertions.assertThat(file.readText()) + .isEqualTo(rumSerializer.serialize(fakeModel)) + } + + @Test + @TestTargetApi(Build.VERSION_CODES.O) + fun `𝕄 write a collection of models π•Ž write(list)`(forge: Forge) { + val fakeModels: List = forge.aList { forge.getForgery(RumEvent::class.java) } + val fileNameToWriteTo = forge.anAlphaNumericalString() + val file = File(tempRootDir, fileNameToWriteTo) + whenever(mockOrchestrator.getWritableFile(any())).thenReturn(file) + + testedWriter.write(fakeModels) + + Assertions.assertThat(file.readText()) + .isEqualTo( + fakeModels.map { rumSerializer.serialize(it) } + .joinToString(PayloadDecoration.JSON_ARRAY_DECORATION.separator) + ) + } + + @Test + @TestTargetApi(Build.VERSION_CODES.O) + fun `𝕄 write several models with custom separator π•Ž write()+`(forge: Forge) { + val separator = forge.anAsciiString() + testedWriter = RumFileWriter( + tempRootDir, + mockOrchestrator, + rumSerializer, + separator + ) + val fakeModels: List = forge.aList { forge.getForgery(RumEvent::class.java) } + val fileNameToWriteTo = forge.anAlphaNumericalString() + val file = File(tempRootDir, fileNameToWriteTo) + whenever(mockOrchestrator.getWritableFile(any())).thenReturn(file) + + fakeModels.forEach { + testedWriter.write(it) + } + + Assertions.assertThat(file.readText()) + .isEqualTo(fakeModels.map { rumSerializer.serialize(it) }.joinToString(separator)) + } + + @Test + @TestTargetApi(Build.VERSION_CODES.O) + fun `𝕄 do nothing π•Ž write() and serialisation fails`( + @Forgery fakeModel: RumEvent, + @StringForgery errorMessage: String + ) { + val mockSerializer: Serializer = mock() + testedWriter = RumFileWriter( + tempRootDir, + mockOrchestrator, + mockSerializer + ) + val throwable = RuntimeException(errorMessage) + doThrow(throwable).whenever(mockSerializer).serialize(fakeModel) + + testedWriter.write(fakeModel) + + verifyZeroInteractions(mockDeferredHandler) + } + + @Test + @TestTargetApi(Build.VERSION_CODES.O) + fun `𝕄 do nothing π•Ž write() with SecurityException thrown while providing a file`( + @Forgery fakeModel: RumEvent, + forge: Forge + ) { + val exception = SecurityException(forge.anAlphabeticalString()) + doThrow(exception).whenever(mockOrchestrator).getWritableFile(any()) + + testedWriter.write(fakeModel) + + verifyZeroInteractions(mockDeferredHandler) + } + + @Test + fun `𝕄 do nothing π•Ž write() and FileOrchestrator returns a null file`( + @Forgery fakeModel: RumEvent, + forge: Forge + ) { + whenever(mockOrchestrator.getWritableFile(any())).thenReturn(null) + + // When + testedWriter.write(fakeModel) + + // Then + verifyZeroInteractions(mockDeferredHandler) + } + + @Test + fun `𝕄 do nothing π•Ž write() and FileOrchestrator returns a file that doesn't exist`( + @Forgery fakeModel: RumEvent, + @StringForgery dirName: String, + @StringForgery fileName: String, + forge: Forge + ) { + val nonExistentDir = File(tempRootDir, dirName) + val file = File(nonExistentDir, fileName) + whenever(mockOrchestrator.getWritableFile(any())).thenReturn(file) + + // When + testedWriter.write(fakeModel) + + // Then + verifyZeroInteractions(mockDeferredHandler) + } + + @Test + fun `𝕄 respect file locks π•Ž write() on locked file`( + forge: Forge + ) { + val fakeModels = forge.aList { forge.getForgery(RumEvent::class.java) } + val fileNameToWriteTo = forge.anAlphaNumericalString() + val file = File(tempRootDir, fileNameToWriteTo) + file.createNewFile() + whenever(mockOrchestrator.getWritableFile(any())).thenReturn(file) + + val outputStream = file.outputStream() + val lock = outputStream.channel.lock() + try { + fakeModels.forEach { + testedWriter.write(it) + } + } finally { + lock.release() + outputStream.close() + } + + Assertions.assertThat(file.readText()) + .isEmpty() + } + + @Test + fun `𝕄 lock and release file π•Ž write() from multiple threads`(forge: Forge) { + val fakeModels = forge.aList(size = 10) { forge.getForgery(RumEvent::class.java) } + val fileNameToWriteTo = forge.anAlphaNumericalString() + val file = File(tempRootDir, fileNameToWriteTo) + file.createNewFile() + whenever(mockOrchestrator.getWritableFile(any())).thenReturn(file) + val countDownLatch = CountDownLatch(2) + + Thread { + fakeModels.take(5).forEach { + testedWriter.write(it) + } + countDownLatch.countDown() + }.start() + + Thread { + fakeModels.takeLast(5).forEach { + testedWriter.write(it) + } + countDownLatch.countDown() + }.start() + + countDownLatch.await(4, TimeUnit.SECONDS) + val rawData = file.readText() + val dataAsJsonArray = "[$rawData]" + val jsonArray = JsonParser.parseString(dataAsJsonArray).asJsonArray + Assertions.assertThat(jsonArray.size()) + .isEqualTo(fakeModels.map { rumSerializer.serialize(it) }.size) + } + + @Test + fun `M persist the event into the NDK crash folder W write() { ViewEvent }`(forge: Forge) { + // GIVEN + val fakeModel: RumEvent = forge.getForgery(RumEvent::class.java) + .copy(event = forge.getForgery(ViewEvent::class.java)) + val fileNameToWriteTo = forge.anAlphaNumericalString() + val file = File(tempRootDir, fileNameToWriteTo) + fakeNdkCrashDataDirectory.mkdirs() + file.createNewFile() + whenever(mockOrchestrator.getWritableFile(any())).thenReturn(file) + + // WHEN + testedWriter.write(fakeModel) + + // THEN + Assertions.assertThat(fakeNdkCrashDataDirectory.listFiles()?.get(0)?.readText()) + .isEqualTo(rumSerializer.serialize(fakeModel)) + } + + @Test + fun `M always cleanup the last_view_event W write() { ViewEvent }`(forge: Forge) { + // GIVEN + val fakeModel1: RumEvent = forge.getForgery(RumEvent::class.java) + .copy(event = forge.getForgery(ViewEvent::class.java)) + val fakeModel2: RumEvent = forge.getForgery(RumEvent::class.java) + .copy(event = forge.getForgery(ViewEvent::class.java)) + val fileNameToWriteTo = forge.anAlphaNumericalString() + val file = File(tempRootDir, fileNameToWriteTo) + file.createNewFile() + whenever(mockOrchestrator.getWritableFile(any())).thenReturn(file) + + // WHEN + testedWriter.write(fakeModel1) + testedWriter.write(fakeModel2) + + // THEN + Assertions.assertThat(fakeNdkCrashDataDirectory.listFiles()?.get(0)?.readText()) + .isEqualTo(rumSerializer.serialize(fakeModel2)) + } + + @Test + fun `M create the ndk crash data directory if does not exists W write() { ViewEvent }`( + forge: Forge + ) { + // GIVEN + val fakeModel: RumEvent = forge.getForgery(RumEvent::class.java) + .copy(event = forge.getForgery(ViewEvent::class.java)) + val fileNameToWriteTo = forge.anAlphaNumericalString() + val file = File(tempRootDir, fileNameToWriteTo) + file.createNewFile() + whenever(mockOrchestrator.getWritableFile(any())).thenReturn(file) + + // WHEN + testedWriter.write(fakeModel) + + // THEN + Assertions.assertThat(fakeNdkCrashDataDirectory.listFiles()?.get(0)?.readText()) + .isEqualTo(rumSerializer.serialize(fakeModel)) + } + + @Test + fun `M not persist the event into the NDK crash folder W write() { not a ViewEvent }`( + forge: Forge + ) { + // GIVEN + val fakeModel: RumEvent = forge.getForgery(RumEvent::class.java) + .copy( + event = forge.anElementFrom( + listOf( + forge.getForgery(ActionEvent::class.java), + forge.getForgery(ErrorEvent::class.java), + forge.getForgery(ResourceEvent::class.java) + ) + ) + ) + val fileNameToWriteTo = forge.anAlphaNumericalString() + val file = File(tempRootDir, fileNameToWriteTo) + file.createNewFile() + whenever(mockOrchestrator.getWritableFile(any())).thenReturn(file) + + // WHEN + testedWriter.write(fakeModel) + + // THEN + Assertions.assertThat(fakeNdkCrashDataDirectory.listFiles()).isEmpty() + } +} diff --git a/dd-sdk-android/src/test/kotlin/com/datadog/android/rum/internal/domain/RumFileStrategyTest.kt b/dd-sdk-android/src/test/kotlin/com/datadog/android/rum/internal/domain/RumFileStrategyTest.kt index 3f374ede98..7bb8e13409 100644 --- a/dd-sdk-android/src/test/kotlin/com/datadog/android/rum/internal/domain/RumFileStrategyTest.kt +++ b/dd-sdk-android/src/test/kotlin/com/datadog/android/rum/internal/domain/RumFileStrategyTest.kt @@ -11,11 +11,13 @@ import com.datadog.android.core.internal.domain.FilePersistenceConfig import com.datadog.android.core.internal.domain.assertj.PersistenceStrategyAssert import com.datadog.android.core.internal.privacy.ConsentProvider import com.datadog.android.privacy.TrackingConsent +import com.datadog.android.rum.internal.data.file.RumFileWriter import com.datadog.android.rum.internal.domain.event.RumEventMapper import com.datadog.android.utils.forge.Configurator import com.datadog.android.utils.mockContext import com.nhaarman.mockitokotlin2.doReturn import com.nhaarman.mockitokotlin2.whenever +import fr.xgouchet.elmyr.Forge import fr.xgouchet.elmyr.annotation.Forgery import fr.xgouchet.elmyr.junit5.ForgeConfiguration import fr.xgouchet.elmyr.junit5.ForgeExtension @@ -88,4 +90,26 @@ internal class RumFileStrategyTest { .usesConsentAwareAsyncWriter() .hasConfig(fakePersistenceConfig) } + + @Test + fun `M use the RumFileWriter factory W instantiating the writer`(forge: Forge) { + // GIVEN + // just to avoid using the NoOpWriter factory + whenever(mockConsentProvider.getConsent()) doReturn forge.aValueFrom( + TrackingConsent::class.java, + exclude = listOf(TrackingConsent.NOT_GRANTED) + ) + testedStrategy = RumFileStrategy( + mockedContext, + dataPersistenceExecutorService = mockExecutorService, + trackingConsentProvider = mockConsentProvider, + eventMapper = mockRumEventMapper, + filePersistenceConfig = fakePersistenceConfig + ) + + // THEN + PersistenceStrategyAssert + .assertThat(testedStrategy) + .hasFileInternalWriterInstanceOf(RumFileWriter::class.java) + } } diff --git a/dd-sdk-android/src/test/kotlin/com/datadog/android/rum/internal/domain/event/RumEventDeserializerTest.kt b/dd-sdk-android/src/test/kotlin/com/datadog/android/rum/internal/domain/event/RumEventDeserializerTest.kt new file mode 100644 index 0000000000..bd518a3985 --- /dev/null +++ b/dd-sdk-android/src/test/kotlin/com/datadog/android/rum/internal/domain/event/RumEventDeserializerTest.kt @@ -0,0 +1,232 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.datadog.android.rum.internal.domain.event + +import com.datadog.android.core.internal.utils.toJsonArray +import com.datadog.android.rum.model.ActionEvent +import com.datadog.android.rum.model.ErrorEvent +import com.datadog.android.rum.model.ResourceEvent +import com.datadog.android.rum.model.ViewEvent +import com.datadog.android.utils.forge.Configurator +import com.google.gson.JsonArray +import com.google.gson.JsonElement +import com.google.gson.JsonNull +import com.google.gson.JsonObject +import com.nhaarman.mockitokotlin2.any +import com.nhaarman.mockitokotlin2.anyOrNull +import com.nhaarman.mockitokotlin2.mock +import com.nhaarman.mockitokotlin2.whenever +import fr.xgouchet.elmyr.Forge +import fr.xgouchet.elmyr.annotation.Forgery +import fr.xgouchet.elmyr.junit5.ForgeConfiguration +import fr.xgouchet.elmyr.junit5.ForgeExtension +import java.util.Date +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.junit.jupiter.api.extension.Extensions +import org.junit.jupiter.api.fail +import org.mockito.junit.jupiter.MockitoExtension +import org.mockito.junit.jupiter.MockitoSettings +import org.mockito.quality.Strictness + +@Extensions( + ExtendWith(MockitoExtension::class), + ExtendWith(ForgeExtension::class) +) +@MockitoSettings(strictness = Strictness.LENIENT) +@ForgeConfiguration(Configurator::class) +internal class RumEventDeserializerTest { + + lateinit var testedDeserializer: RumEventDeserializer + + // we use a NoOpDataConstraints to avoid flaky tests + private val serializer: RumEventSerializer = RumEventSerializer( + mock { + whenever(it.validateAttributes(any(), anyOrNull(), anyOrNull())).thenAnswer { + it.getArgument(0) + } + whenever(it.validateTags(any())).thenAnswer { + it.getArgument(0) + } + } + ) + + @BeforeEach + fun `set up`() { + testedDeserializer = RumEventDeserializer() + } + + // region UnitTests + + @Test + fun `𝕄 deserialize a serialized RUM ViewEvent π•Ž deserialize()`( + forge: Forge + ) { + // GIVEN + val fakeViewEvent = forge.getForgery(ViewEvent::class.java) + val fakeEvent: RumEvent = forge.getForgery(RumEvent::class.java) + .copy(event = fakeViewEvent) + val serializedEvent = serializer.serialize(fakeEvent) + + // WHEN + val deserializedEvent = testedDeserializer.deserialize(serializedEvent) + + // THEN + assertThat(deserializedEvent).isNotNull() + assertAttributes(fakeEvent.globalAttributes, deserializedEvent?.globalAttributes) + assertAttributes(fakeEvent.userExtraAttributes, deserializedEvent?.userExtraAttributes) + val deserializedViewEvent = deserializedEvent!!.event as ViewEvent + assertThat(deserializedViewEvent) + .isEqualTo(fakeViewEvent) + } + + @Test + fun `𝕄 deserialize a serialized RUM ResourceEvent π•Ž deserialize()`( + forge: Forge + ) { + // GIVEN + val fakeResourceEvent = forge.getForgery(ResourceEvent::class.java) + val fakeEvent: RumEvent = forge.getForgery(RumEvent::class.java) + .copy(event = fakeResourceEvent) + val serializedEvent = serializer.serialize(fakeEvent) + + // WHEN + val deserializedEvent = testedDeserializer.deserialize(serializedEvent) + + // THEN + assertThat(deserializedEvent).isNotNull() + assertAttributes(fakeEvent.globalAttributes, deserializedEvent?.globalAttributes) + assertAttributes(fakeEvent.userExtraAttributes, deserializedEvent?.userExtraAttributes) + val deserializedResourceEvent = deserializedEvent!!.event as ResourceEvent + assertThat(deserializedResourceEvent).isEqualTo(fakeResourceEvent) + } + + @Test + fun `𝕄 deserialize a serialized RUM ActionEvent π•Ž deserialize()`( + forge: Forge + ) { + // GIVEN + val fakeActionEvent = forge.getForgery(ActionEvent::class.java) + val fakeEvent: RumEvent = forge.getForgery(RumEvent::class.java) + .copy(event = fakeActionEvent) + val serializedEvent = serializer.serialize(fakeEvent) + + // WHEN + val deserializedEvent = testedDeserializer.deserialize(serializedEvent) + + // THEN + assertThat(deserializedEvent).isNotNull() + assertAttributes(fakeEvent.globalAttributes, deserializedEvent?.globalAttributes) + assertAttributes(fakeEvent.userExtraAttributes, deserializedEvent?.userExtraAttributes) + val deserializedActionEvent = deserializedEvent!!.event as ActionEvent + assertThat(deserializedActionEvent).isEqualToIgnoringGivenFields( + fakeActionEvent, + "dd" + ) + } + + @Test + fun `𝕄 deserialize a serialized RUM ErrorEvent π•Ž deserialize()`( + forge: Forge + ) { + // GIVEN + val fakeErrorEvent = forge.getForgery(ErrorEvent::class.java) + val fakeEvent: RumEvent = forge.getForgery(RumEvent::class.java) + .copy(event = fakeErrorEvent) + val serializedEvent = serializer.serialize(fakeEvent) + + // WHEN + val deserializedEvent = testedDeserializer.deserialize(serializedEvent) + + // THEN + assertThat(deserializedEvent).isNotNull() + assertAttributes(fakeEvent.globalAttributes, deserializedEvent?.globalAttributes) + assertAttributes(fakeEvent.userExtraAttributes, deserializedEvent?.userExtraAttributes) + val deserializedErrorEvent = deserializedEvent!!.event as ErrorEvent + assertThat(deserializedErrorEvent).isEqualToIgnoringGivenFields( + fakeErrorEvent, + "dd" + ) + } + + @Test + fun `𝕄 return null W deserialize { wrong Json format }`() { + // WHEN + val deserializedEvent = testedDeserializer.deserialize("{]}") + + // THEN + assertThat(deserializedEvent).isNull() + } + + @Test + fun `𝕄 return null W deserialize { wrong bundled RUM event type }`( + @Forgery fakeEvent: RumEvent + ) { + // GIVEN + val fakeBadFormatEvent = fakeEvent.copy(event = Any()) + val serializedEvent = serializer.serialize(fakeBadFormatEvent) + + // WHEN + val deserializedEvent = testedDeserializer.deserialize(serializedEvent) + + // THEN + assertThat(deserializedEvent).isNull() + } + + // endregion + + // region Internal + + private fun assertAttributes( + originalAttributes: Map?, + deserializedAttributes: Map? + ) { + if (originalAttributes == null && deserializedAttributes == null) { + return + } + if (originalAttributes != null && deserializedAttributes != null) { + originalAttributes.filter { + it.key.isNotBlank() && !RumEventSerializer.knownAttributes.contains( + it.key + ) + } + .forEach { + val value = it.value + val deserializedValue = deserializedAttributes[it.key] as JsonElement + when (value) { + null -> assertThat(deserializedValue).isEqualTo( + JsonNull.INSTANCE + ) + is Boolean -> assertThat(deserializedValue.asBoolean).isEqualTo(value) + is Int -> assertThat(deserializedValue.asInt).isEqualTo(value) + is Long -> assertThat(deserializedValue.asLong).isEqualTo(value) + is Float -> assertThat(deserializedValue.asFloat).isEqualTo(value) + is Double -> assertThat(deserializedValue.asDouble).isEqualTo(value) + is String -> assertThat(deserializedValue.asString).isEqualTo(value) + is Date -> assertThat(deserializedValue.asLong).isEqualTo(value.time) + is JsonObject -> + assertThat(deserializedValue.asJsonObject.toString()) + .isEqualTo(value.toString()) + is JsonArray -> assertThat(deserializedValue.asJsonArray).isEqualTo(value) + is Iterable<*> -> assertThat(deserializedValue.asJsonArray).isEqualTo( + value.toJsonArray() + ) + else -> assertThat(deserializedValue.asString).isEqualTo(value.toString()) + } + } + } else { + fail( + "Original attributes:$originalAttributes are not the same " + + "as deserialized attributes: $deserializedAttributes" + ) + } + } + + // endregion +} diff --git a/dd-sdk-android/src/test/kotlin/com/datadog/android/rum/internal/domain/RumEventSerializerTest.kt b/dd-sdk-android/src/test/kotlin/com/datadog/android/rum/internal/domain/event/RumEventSerializerTest.kt similarity index 85% rename from dd-sdk-android/src/test/kotlin/com/datadog/android/rum/internal/domain/RumEventSerializerTest.kt rename to dd-sdk-android/src/test/kotlin/com/datadog/android/rum/internal/domain/event/RumEventSerializerTest.kt index 53ec7b1d7e..bef35033fb 100644 --- a/dd-sdk-android/src/test/kotlin/com/datadog/android/rum/internal/domain/RumEventSerializerTest.kt +++ b/dd-sdk-android/src/test/kotlin/com/datadog/android/rum/internal/domain/event/RumEventSerializerTest.kt @@ -4,13 +4,12 @@ * Copyright 2016-Present Datadog, Inc. */ -package com.datadog.android.rum.internal.domain +package com.datadog.android.rum.internal.domain.event import com.datadog.android.core.internal.constraints.DataConstraints import com.datadog.android.core.internal.utils.toJsonArray import com.datadog.android.log.internal.user.UserInfo -import com.datadog.android.rum.internal.domain.event.RumEvent -import com.datadog.android.rum.internal.domain.event.RumEventSerializer +import com.datadog.android.rum.RumAttributes import com.datadog.android.rum.model.ActionEvent import com.datadog.android.rum.model.ErrorEvent import com.datadog.android.rum.model.ResourceEvent @@ -294,41 +293,6 @@ internal class RumEventSerializerTest { .hasField(key, value) } - @Test - fun `M serialize W serialize() with custom timing`( - forge: Forge - ) { - // GIVEN - val fakeCustomTimings = forge.aMap { forge.anAlphabeticalString() to forge.aLong() } - val fakeEvent: RumEvent = - forge.getForgery(RumEvent::class.java).copy(customTimings = fakeCustomTimings) - - // WHEN - val serialized = testedSerializer.serialize(fakeEvent) - val jsonObject = JsonParser.parseString(serialized).asJsonObject - - // THEN - fakeCustomTimings - .filter { it.key.isNotBlank() } - .forEach { - val keyName = "${RumEventSerializer.VIEW_CUSTOM_TIMINGS_ATTRIBUTE_PREFIX}.${it.key}" - assertThat(jsonObject).hasField(keyName, it.value) - } - } - - @Test - fun `M not add custom timings group at all W serialize() with custom timings null`( - @Forgery fakeEvent: RumEvent - ) { - // WHEN - val serialized = testedSerializer.serialize(fakeEvent) - val jsonObject = JsonParser.parseString(serialized).asJsonObject - - // THEN - assertThat(jsonObject) - .doesNotHaveField(RumEventSerializer.VIEW_CUSTOM_TIMINGS_ATTRIBUTE_PREFIX) - } - @Test fun `M sanitise the custom attributes keys W level deeper than 9`(forge: Forge) { // GIVEN @@ -387,37 +351,6 @@ internal class RumEventSerializerTest { .doesNotHaveField("${RumEventSerializer.USER_ATTRIBUTE_PREFIX}.$fakeBadKey") } - @Test - fun `M sanitise the custom timings keys W level deeper than 8`(forge: Forge) { - // GIVEN - val fakeBadKey = - forge.aList(size = 9) { forge.anAlphabeticalString() }.joinToString(".") - val lastIndexOf = fakeBadKey.lastIndexOf('.') - val expectedSanitisedKey = - fakeBadKey.replaceRange(lastIndexOf..lastIndexOf, "_") - val fakeTimingValue = forge.aLong(min = 1) - val fakeEvent: RumEvent = forge.getForgery(RumEvent::class.java).copy( - customTimings = mapOf( - fakeBadKey to fakeTimingValue - ) - ) - - // WHEN - val serializedEvent = testedSerializer.serialize(fakeEvent) - val jsonObject = JsonParser.parseString(serializedEvent).asJsonObject - - // THEN - assertThat(jsonObject) - .hasField( - "${RumEventSerializer.VIEW_CUSTOM_TIMINGS_ATTRIBUTE_PREFIX}.$expectedSanitisedKey", - fakeTimingValue - ) - assertThat(jsonObject) - .doesNotHaveField( - "${RumEventSerializer.VIEW_CUSTOM_TIMINGS_ATTRIBUTE_PREFIX}.$fakeBadKey" - ) - } - @Test fun `M use the attributes group verbose name W validateAttributes { user extra info }`( @Forgery fakeEvent: RumEvent @@ -439,27 +372,61 @@ internal class RumEventSerializerTest { } @Test - fun `M use the attributes group verbose name W validateAttributes { custom timings }`( + fun `M drop the internal reserved attributes W serialize { custom global attributes }`( forge: Forge ) { - // GIVEN - val mockedDataConstrains: DataConstraints = mock() - testedSerializer = RumEventSerializer(mockedDataConstrains) - val fakeCustomTimings = forge.aMap { forge.anAlphabeticalString() to forge.aLong(min = 1) } - val fakeEvent: RumEvent = forge.getForgery(RumEvent::class.java).copy( - customTimings = fakeCustomTimings + val fakeInternalTimestamp = forge.aLong() + val fakeErrorType = forge.aString() + val fakeEvent: RumEvent = forge.getForgery() + val fakeEventWithInternalGlobalAttributes = fakeEvent.copy( + globalAttributes = fakeEvent.globalAttributes + mapOf( + RumAttributes.INTERNAL_ERROR_TYPE to fakeErrorType, + RumAttributes.INTERNAL_TIMESTAMP to fakeInternalTimestamp + ) ) - // WHEN - testedSerializer.serialize(fakeEvent) + val serializedEvent = testedSerializer.serialize(fakeEventWithInternalGlobalAttributes) + val jsonObject = JsonParser.parseString(serializedEvent).asJsonObject // THEN - verify(mockedDataConstrains).validateAttributes( - fakeCustomTimings, - RumEventSerializer.VIEW_CUSTOM_TIMINGS_ATTRIBUTE_PREFIX, - RumEventSerializer.CUSTOM_TIMINGS_GROUP_VERBOSE_NAME + assertThat(jsonObject) + .doesNotHaveField( + RumEventSerializer.GLOBAL_ATTRIBUTE_PREFIX + "." + RumAttributes.INTERNAL_TIMESTAMP + ) + assertThat(jsonObject) + .doesNotHaveField( + RumEventSerializer.GLOBAL_ATTRIBUTE_PREFIX + "." + RumAttributes.INTERNAL_ERROR_TYPE + ) + } + + @Test + fun `M drop the internal reserved attributes W serialize { custom user attributes }`( + forge: Forge + ) { + // GIVEN + val fakeInternalTimestamp = forge.aLong() + val fakeErrorType = forge.aString() + val fakeEvent: RumEvent = forge.getForgery() + val fakeEventWithInternalUserAttributes = fakeEvent.copy( + globalAttributes = fakeEvent.userExtraAttributes + mapOf( + RumAttributes.INTERNAL_ERROR_TYPE to fakeErrorType, + RumAttributes.INTERNAL_TIMESTAMP to fakeInternalTimestamp + ) ) + // WHEN + val serializedEvent = testedSerializer.serialize(fakeEvent) + val jsonObject = JsonParser.parseString(serializedEvent).asJsonObject + + // THEN + assertThat(jsonObject) + .doesNotHaveField( + RumEventSerializer.USER_ATTRIBUTE_PREFIX + "." + RumAttributes.INTERNAL_TIMESTAMP + ) + assertThat(jsonObject) + .doesNotHaveField( + RumEventSerializer.USER_ATTRIBUTE_PREFIX + "." + RumAttributes.INTERNAL_ERROR_TYPE + ) } // region Internal diff --git a/dd-sdk-android/src/test/kotlin/com/datadog/android/rum/internal/domain/scope/RumResourceScopeTest.kt b/dd-sdk-android/src/test/kotlin/com/datadog/android/rum/internal/domain/scope/RumResourceScopeTest.kt index 079d7f06ef..a10cac3fd8 100644 --- a/dd-sdk-android/src/test/kotlin/com/datadog/android/rum/internal/domain/scope/RumResourceScopeTest.kt +++ b/dd-sdk-android/src/test/kotlin/com/datadog/android/rum/internal/domain/scope/RumResourceScopeTest.kt @@ -33,7 +33,9 @@ import com.nhaarman.mockitokotlin2.doAnswer import com.nhaarman.mockitokotlin2.doReturn import com.nhaarman.mockitokotlin2.isA import com.nhaarman.mockitokotlin2.mock +import com.nhaarman.mockitokotlin2.never import com.nhaarman.mockitokotlin2.same +import com.nhaarman.mockitokotlin2.times import com.nhaarman.mockitokotlin2.verify import com.nhaarman.mockitokotlin2.verifyNoMoreInteractions import com.nhaarman.mockitokotlin2.whenever @@ -148,9 +150,10 @@ internal class RumResourceScopeTest { val result = testedScope.handleEvent(mockEvent, mockWriter) // Then + val expectedCallTimes = resolveExpectedCallTimes(statusCode) argumentCaptor { - verify(mockWriter).write(capture()) - assertThat(lastValue) + verify(mockWriter, times(expectedCallTimes)).write(capture()) + assertThat(firstValue) .hasAttributes(expectedAttributes) .hasUserExtraAttributes(fakeUserInfo.extraInfo) .hasResourceData { @@ -170,6 +173,29 @@ internal class RumResourceScopeTest { hasSpanId(null) doesNotHaveAResourceProvider() } + if (expectedCallTimes > 1) { + assertThat(lastValue) + .hasAttributes(expectedAttributes) + .hasUserExtraAttributes(fakeUserInfo.extraInfo) + .hasErrorData { + hasMessage(RumResourceScope.ERROR_MSG_FORMAT.format(fakeMethod, fakeUrl)) + hasSource(RumErrorSource.NETWORK) + hasStackTrace(null) + isCrash(false) + hasResource(fakeUrl, fakeMethod, statusCode) + hasUserInfo(fakeUserInfo) + hasConnectivityInfo(fakeNetworkInfo) + hasView(fakeParentContext.viewId, fakeParentContext.viewUrl) + hasApplicationId(fakeParentContext.applicationId) + hasSessionId(fakeParentContext.sessionId) + hasActionId(fakeParentContext.actionId) + hasErrorType( + RumResourceScope.ERROR_TYPE_BASED_ON_STATUS_CODE_FORMAT.format( + statusCode + ) + ) + } + } } verify(mockParentScope).handleEvent( isA(), @@ -199,9 +225,10 @@ internal class RumResourceScopeTest { val result = testedScope.handleEvent(mockEvent, mockWriter) // Then + val expectedCallTimes = resolveExpectedCallTimes(statusCode) argumentCaptor { - verify(mockWriter).write(capture()) - assertThat(lastValue) + verify(mockWriter, times(expectedCallTimes)).write(capture()) + assertThat(firstValue) .hasAttributes(expectedAttributes) .hasUserExtraAttributes(fakeUserInfo.extraInfo) .hasResourceData { @@ -222,6 +249,29 @@ internal class RumResourceScopeTest { hasProviderType(ResourceEvent.ProviderType.FIRST_PARTY) hasProviderDomain(URL(fakeUrl).host) } + if (expectedCallTimes > 1) { + assertThat(lastValue) + .hasAttributes(expectedAttributes) + .hasUserExtraAttributes(fakeUserInfo.extraInfo) + .hasErrorData { + hasMessage(RumResourceScope.ERROR_MSG_FORMAT.format(fakeMethod, fakeUrl)) + hasSource(RumErrorSource.NETWORK) + hasStackTrace(null) + isCrash(false) + hasResource(fakeUrl, fakeMethod, statusCode) + hasUserInfo(fakeUserInfo) + hasConnectivityInfo(fakeNetworkInfo) + hasView(fakeParentContext.viewId, fakeParentContext.viewUrl) + hasApplicationId(fakeParentContext.applicationId) + hasSessionId(fakeParentContext.sessionId) + hasActionId(fakeParentContext.actionId) + hasErrorType( + RumResourceScope.ERROR_TYPE_BASED_ON_STATUS_CODE_FORMAT.format( + statusCode + ) + ) + } + } } verify(mockParentScope).handleEvent( isA(), @@ -261,9 +311,10 @@ internal class RumResourceScopeTest { val result = testedScope.handleEvent(mockEvent, mockWriter) // Then + val expectedCallTimes = resolveExpectedCallTimes(statusCode) argumentCaptor { - verify(mockWriter).write(capture()) - assertThat(lastValue) + verify(mockWriter, times(expectedCallTimes)).write(capture()) + assertThat(firstValue) .hasAttributes(expectedAttributes) .hasUserExtraAttributes(fakeUserInfo.extraInfo) .hasResourceData { @@ -284,6 +335,29 @@ internal class RumResourceScopeTest { hasProviderType(ResourceEvent.ProviderType.FIRST_PARTY) hasProviderDomain(brokenUrl) } + if (expectedCallTimes > 1) { + assertThat(lastValue) + .hasAttributes(expectedAttributes) + .hasUserExtraAttributes(fakeUserInfo.extraInfo) + .hasErrorData { + hasMessage(RumResourceScope.ERROR_MSG_FORMAT.format(fakeMethod, brokenUrl)) + hasSource(RumErrorSource.NETWORK) + hasStackTrace(null) + isCrash(false) + hasResource(brokenUrl, fakeMethod, statusCode) + hasUserInfo(fakeUserInfo) + hasConnectivityInfo(fakeNetworkInfo) + hasView(fakeParentContext.viewId, fakeParentContext.viewUrl) + hasApplicationId(fakeParentContext.applicationId) + hasSessionId(fakeParentContext.sessionId) + hasActionId(fakeParentContext.actionId) + hasErrorType( + RumResourceScope.ERROR_TYPE_BASED_ON_STATUS_CODE_FORMAT.format( + statusCode + ) + ) + } + } } verify(mockParentScope).handleEvent( isA(), @@ -316,9 +390,10 @@ internal class RumResourceScopeTest { val result = testedScope.handleEvent(mockEvent, mockWriter) // Then + val expectedCallTimes = resolveExpectedCallTimes(statusCode) argumentCaptor { - verify(mockWriter).write(capture()) - assertThat(lastValue) + verify(mockWriter, times(expectedCallTimes)).write(capture()) + assertThat(firstValue) .hasAttributes(expectedAttributes) .hasUserExtraAttributes(fakeUserInfo.extraInfo) .hasResourceData { @@ -338,6 +413,29 @@ internal class RumResourceScopeTest { hasSpanId(fakeSpanId) doesNotHaveAResourceProvider() } + if (expectedCallTimes > 1) { + assertThat(lastValue) + .hasAttributes(expectedAttributes) + .hasUserExtraAttributes(fakeUserInfo.extraInfo) + .hasErrorData { + hasMessage(RumResourceScope.ERROR_MSG_FORMAT.format(fakeMethod, fakeUrl)) + hasSource(RumErrorSource.NETWORK) + hasStackTrace(null) + isCrash(false) + hasResource(fakeUrl, fakeMethod, statusCode) + hasUserInfo(fakeUserInfo) + hasConnectivityInfo(fakeNetworkInfo) + hasView(fakeParentContext.viewId, fakeParentContext.viewUrl) + hasApplicationId(fakeParentContext.applicationId) + hasSessionId(fakeParentContext.sessionId) + hasActionId(fakeParentContext.actionId) + hasErrorType( + RumResourceScope.ERROR_TYPE_BASED_ON_STATUS_CODE_FORMAT.format( + statusCode + ) + ) + } + } } verify(mockParentScope).handleEvent( isA(), @@ -368,9 +466,10 @@ internal class RumResourceScopeTest { val result = testedScope.handleEvent(mockEvent, mockWriter) // Then + val expectedCallTimes = resolveExpectedCallTimes(statusCode) argumentCaptor { - verify(mockWriter).write(capture()) - assertThat(lastValue) + verify(mockWriter, times(expectedCallTimes)).write(capture()) + assertThat(firstValue) .hasAttributes(expectedAttributes) .hasUserExtraAttributes(fakeUserInfo.extraInfo) .hasResourceData { @@ -390,6 +489,29 @@ internal class RumResourceScopeTest { hasSpanId(null) doesNotHaveAResourceProvider() } + if (expectedCallTimes > 1) { + assertThat(lastValue) + .hasAttributes(expectedAttributes) + .hasUserExtraAttributes(fakeUserInfo.extraInfo) + .hasErrorData { + hasMessage(RumResourceScope.ERROR_MSG_FORMAT.format(fakeMethod, fakeUrl)) + hasSource(RumErrorSource.NETWORK) + hasStackTrace(null) + isCrash(false) + hasResource(fakeUrl, fakeMethod, statusCode) + hasUserInfo(fakeUserInfo) + hasConnectivityInfo(fakeNetworkInfo) + hasView(fakeParentContext.viewId, fakeParentContext.viewUrl) + hasApplicationId(fakeParentContext.applicationId) + hasSessionId(fakeParentContext.sessionId) + hasActionId(fakeParentContext.actionId) + hasErrorType( + RumResourceScope.ERROR_TYPE_BASED_ON_STATUS_CODE_FORMAT.format( + statusCode + ) + ) + } + } } verify(mockParentScope).handleEvent( isA(), @@ -411,9 +533,10 @@ internal class RumResourceScopeTest { val result = testedScope.handleEvent(mockEvent, mockWriter) // Then + val expectedCallTimes = resolveExpectedCallTimes(statusCode) argumentCaptor { - verify(mockWriter).write(capture()) - assertThat(lastValue) + verify(mockWriter, times(expectedCallTimes)).write(capture()) + assertThat(firstValue) .hasUserExtraAttributes(fakeUserInfo.extraInfo) .hasResourceData { hasId(testedScope.resourceId) @@ -432,6 +555,29 @@ internal class RumResourceScopeTest { hasSpanId(null) doesNotHaveAResourceProvider() } + if (expectedCallTimes > 1) { + assertThat(lastValue) + .hasAttributes(fakeAttributes) + .hasUserExtraAttributes(fakeUserInfo.extraInfo) + .hasErrorData { + hasMessage(RumResourceScope.ERROR_MSG_FORMAT.format(fakeMethod, fakeUrl)) + hasSource(RumErrorSource.NETWORK) + hasStackTrace(null) + isCrash(false) + hasResource(fakeUrl, fakeMethod, statusCode) + hasUserInfo(fakeUserInfo) + hasConnectivityInfo(fakeNetworkInfo) + hasView(fakeParentContext.viewId, fakeParentContext.viewUrl) + hasApplicationId(fakeParentContext.applicationId) + hasSessionId(fakeParentContext.sessionId) + hasActionId(fakeParentContext.actionId) + hasErrorType( + RumResourceScope.ERROR_TYPE_BASED_ON_STATUS_CODE_FORMAT.format( + statusCode + ) + ) + } + } } verify(mockParentScope).handleEvent( isA(), @@ -441,6 +587,97 @@ internal class RumResourceScopeTest { assertThat(result).isEqualTo(null) } + @Test + fun `𝕄 send related error event π•Ž handleEvent(StopResource with error statusCode)`( + @Forgery kind: RumResourceKind, + @LongForgery(400, 600) statusCode: Long, + @LongForgery(0, 1024) size: Long + ) { + // When + Thread.sleep(500) + mockEvent = RumRawEvent.StopResource(fakeKey, statusCode, size, kind, emptyMap()) + val result = testedScope.handleEvent(mockEvent, mockWriter) + + // Then + argumentCaptor { + verify(mockWriter, times(2)).write(capture()) + assertThat(lastValue) + .hasAttributes(fakeAttributes) + .hasUserExtraAttributes(fakeUserInfo.extraInfo) + .hasErrorData { + hasMessage(RumResourceScope.ERROR_MSG_FORMAT.format(fakeMethod, fakeUrl)) + hasSource(RumErrorSource.NETWORK) + hasStackTrace(null) + isCrash(false) + hasResource(fakeUrl, fakeMethod, statusCode) + hasUserInfo(fakeUserInfo) + hasConnectivityInfo(fakeNetworkInfo) + hasView(fakeParentContext.viewId, fakeParentContext.viewUrl) + hasApplicationId(fakeParentContext.applicationId) + hasSessionId(fakeParentContext.sessionId) + hasActionId(fakeParentContext.actionId) + hasErrorType( + RumResourceScope.ERROR_TYPE_BASED_ON_STATUS_CODE_FORMAT.format( + statusCode + ) + ) + } + } + verify(mockParentScope).handleEvent( + isA(), + same(mockWriter) + ) + verifyNoMoreInteractions(mockWriter) + assertThat(result).isEqualTo(null) + } + + @Test + fun `𝕄 not send related error event π•Ž handleEvent(StopResource with success statusCode)`( + @Forgery kind: RumResourceKind, + @LongForgery(200, 399) statusCode: Long, + @LongForgery(0, 1024) size: Long + ) { + // When + Thread.sleep(500) + mockEvent = RumRawEvent.StopResource(fakeKey, statusCode, size, kind, emptyMap()) + val result = testedScope.handleEvent(mockEvent, mockWriter) + + // Then + argumentCaptor { + verify(mockWriter).write(capture()) + assertThat(lastValue.event).isNotInstanceOf(ErrorEvent::class.java) + } + verify(mockParentScope, never()).handleEvent( + isA(), + same(mockWriter) + ) + verifyNoMoreInteractions(mockWriter) + assertThat(result).isEqualTo(null) + } + + @Test + fun `𝕄 not send related error event π•Ž handleEvent(StopResource with missing statusCode)`( + @Forgery kind: RumResourceKind, + @LongForgery(0, 1024) size: Long + ) { + // When + Thread.sleep(500) + mockEvent = RumRawEvent.StopResource(fakeKey, null, size, kind, emptyMap()) + val result = testedScope.handleEvent(mockEvent, mockWriter) + + // Then + argumentCaptor { + verify(mockWriter).write(capture()) + assertThat(lastValue.event).isNotInstanceOf(ErrorEvent::class.java) + } + verify(mockParentScope, never()).handleEvent( + isA(), + same(mockWriter) + ) + verifyNoMoreInteractions(mockWriter) + assertThat(result).isEqualTo(null) + } + @Test fun `𝕄 send Resource with global attributes π•Ž handleEvent(StopResource)`( @Forgery kind: RumResourceKind, @@ -461,9 +698,10 @@ internal class RumResourceScopeTest { val result = testedScope.handleEvent(mockEvent, mockWriter) // Then + val expectedCallTimes = resolveExpectedCallTimes(statusCode) argumentCaptor { - verify(mockWriter).write(capture()) - assertThat(lastValue) + verify(mockWriter, times(expectedCallTimes)).write(capture()) + assertThat(firstValue) .hasAttributes(expectedAttributes) .hasUserExtraAttributes(fakeUserInfo.extraInfo) .hasResourceData { @@ -483,6 +721,29 @@ internal class RumResourceScopeTest { hasSpanId(null) doesNotHaveAResourceProvider() } + if (expectedCallTimes > 1) { + assertThat(lastValue) + .hasAttributes(expectedAttributes) + .hasUserExtraAttributes(fakeUserInfo.extraInfo) + .hasErrorData { + hasMessage(RumResourceScope.ERROR_MSG_FORMAT.format(fakeMethod, fakeUrl)) + hasSource(RumErrorSource.NETWORK) + hasStackTrace(null) + isCrash(false) + hasResource(fakeUrl, fakeMethod, statusCode) + hasUserInfo(fakeUserInfo) + hasConnectivityInfo(fakeNetworkInfo) + hasView(fakeParentContext.viewId, fakeParentContext.viewUrl) + hasApplicationId(fakeParentContext.applicationId) + hasSessionId(fakeParentContext.sessionId) + hasActionId(fakeParentContext.actionId) + hasErrorType( + RumResourceScope.ERROR_TYPE_BASED_ON_STATUS_CODE_FORMAT.format( + statusCode + ) + ) + } + } } verify(mockParentScope).handleEvent( isA(), @@ -514,9 +775,10 @@ internal class RumResourceScopeTest { val result = testedScope.handleEvent(mockEvent, mockWriter) // Then + val expectedCallTimes = resolveExpectedCallTimes(statusCode) argumentCaptor { - verify(mockWriter).write(capture()) - assertThat(lastValue) + verify(mockWriter, times(expectedCallTimes)).write(capture()) + assertThat(firstValue) .hasAttributes(expectedAttributes) .hasUserExtraAttributes(fakeUserInfo.extraInfo) .hasResourceData { @@ -537,6 +799,29 @@ internal class RumResourceScopeTest { hasSpanId(null) doesNotHaveAResourceProvider() } + if (expectedCallTimes > 1) { + assertThat(lastValue) + .hasAttributes(expectedAttributes) + .hasUserExtraAttributes(fakeUserInfo.extraInfo) + .hasErrorData { + hasMessage(RumResourceScope.ERROR_MSG_FORMAT.format(fakeMethod, fakeUrl)) + hasSource(RumErrorSource.NETWORK) + hasStackTrace(null) + isCrash(false) + hasResource(fakeUrl, fakeMethod, statusCode) + hasUserInfo(fakeUserInfo) + hasConnectivityInfo(fakeNetworkInfo) + hasView(fakeParentContext.viewId, fakeParentContext.viewUrl) + hasApplicationId(fakeParentContext.applicationId) + hasSessionId(fakeParentContext.sessionId) + hasActionId(fakeParentContext.actionId) + hasErrorType( + RumResourceScope.ERROR_TYPE_BASED_ON_STATUS_CODE_FORMAT.format( + statusCode + ) + ) + } + } } verify(mockParentScope).handleEvent( isA(), @@ -569,9 +854,10 @@ internal class RumResourceScopeTest { val result = testedScope.handleEvent(mockEvent, mockWriter) // Then + val expectedCallTimes = resolveExpectedCallTimes(statusCode) argumentCaptor { - verify(mockWriter).write(capture()) - assertThat(lastValue) + verify(mockWriter, times(expectedCallTimes)).write(capture()) + assertThat(firstValue) .hasAttributes(expectedAttributes) .hasUserExtraAttributes(fakeUserInfo.extraInfo) .hasResourceData { @@ -592,6 +878,29 @@ internal class RumResourceScopeTest { hasSpanId(null) doesNotHaveAResourceProvider() } + if (expectedCallTimes > 1) { + assertThat(lastValue) + .hasAttributes(expectedAttributes) + .hasUserExtraAttributes(fakeUserInfo.extraInfo) + .hasErrorData { + hasMessage(RumResourceScope.ERROR_MSG_FORMAT.format(fakeMethod, fakeUrl)) + hasSource(RumErrorSource.NETWORK) + hasStackTrace(null) + isCrash(false) + hasResource(fakeUrl, fakeMethod, statusCode) + hasUserInfo(fakeUserInfo) + hasConnectivityInfo(fakeNetworkInfo) + hasView(fakeParentContext.viewId, fakeParentContext.viewUrl) + hasApplicationId(fakeParentContext.applicationId) + hasSessionId(fakeParentContext.sessionId) + hasActionId(fakeParentContext.actionId) + hasErrorType( + RumResourceScope.ERROR_TYPE_BASED_ON_STATUS_CODE_FORMAT.format( + statusCode + ) + ) + } + } } verify(mockParentScope).handleEvent( isA(), @@ -636,6 +945,7 @@ internal class RumResourceScopeTest { hasApplicationId(fakeParentContext.applicationId) hasSessionId(fakeParentContext.sessionId) hasActionId(fakeParentContext.actionId) + hasErrorType(throwable.javaClass.canonicalName) } } verify(mockParentScope).handleEvent( @@ -693,6 +1003,7 @@ internal class RumResourceScopeTest { hasActionId(fakeParentContext.actionId) hasProviderDomain(brokenUrl) hasProviderType(ErrorEvent.ProviderType.FIRST_PARTY) + hasErrorType(throwable.javaClass.canonicalName) } } verify(mockParentScope).handleEvent( @@ -740,6 +1051,7 @@ internal class RumResourceScopeTest { hasActionId(fakeParentContext.actionId) hasProviderDomain(URL(fakeUrl).host) hasProviderType(ErrorEvent.ProviderType.FIRST_PARTY) + hasErrorType(throwable.javaClass.canonicalName) } } verify(mockParentScope).handleEvent( @@ -786,6 +1098,7 @@ internal class RumResourceScopeTest { hasApplicationId(fakeParentContext.applicationId) hasSessionId(fakeParentContext.sessionId) hasActionId(fakeParentContext.actionId) + hasErrorType(throwable.javaClass.canonicalName) doesNotHaveAResourceProvider() } } @@ -841,6 +1154,7 @@ internal class RumResourceScopeTest { hasApplicationId(fakeParentContext.applicationId) hasSessionId(fakeParentContext.sessionId) hasActionId(fakeParentContext.actionId) + hasErrorType(throwable.javaClass.canonicalName) doesNotHaveAResourceProvider() } } @@ -939,9 +1253,10 @@ internal class RumResourceScopeTest { mockEvent = RumRawEvent.StopResource(fakeKey, statusCode, size, kind, attributes) val resultStop = testedScope.handleEvent(mockEvent, mockWriter) + val expectedCallTimes = resolveExpectedCallTimes(statusCode) argumentCaptor { - verify(mockWriter).write(capture()) - assertThat(lastValue) + verify(mockWriter, times(expectedCallTimes)).write(capture()) + assertThat(firstValue) .hasAttributes(expectedAttributes) .hasUserExtraAttributes(fakeUserInfo.extraInfo) .hasResourceData { @@ -958,6 +1273,29 @@ internal class RumResourceScopeTest { hasActionId(fakeParentContext.actionId) doesNotHaveAResourceProvider() } + if (expectedCallTimes > 1) { + assertThat(lastValue) + .hasAttributes(expectedAttributes) + .hasUserExtraAttributes(fakeUserInfo.extraInfo) + .hasErrorData { + hasMessage(RumResourceScope.ERROR_MSG_FORMAT.format(fakeMethod, fakeUrl)) + hasSource(RumErrorSource.NETWORK) + hasStackTrace(null) + isCrash(false) + hasResource(fakeUrl, fakeMethod, statusCode) + hasUserInfo(fakeUserInfo) + hasConnectivityInfo(fakeNetworkInfo) + hasView(fakeParentContext.viewId, fakeParentContext.viewUrl) + hasApplicationId(fakeParentContext.applicationId) + hasSessionId(fakeParentContext.sessionId) + hasActionId(fakeParentContext.actionId) + hasErrorType( + RumResourceScope.ERROR_TYPE_BASED_ON_STATUS_CODE_FORMAT.format( + statusCode + ) + ) + } + } } verify(mockParentScope).handleEvent( isA(), @@ -989,9 +1327,10 @@ internal class RumResourceScopeTest { mockEvent = RumRawEvent.StopResource(fakeKey, statusCode, size, kind, attributes) val resultStop = testedScope.handleEvent(mockEvent, mockWriter) + val expectedCallTimes = resolveExpectedCallTimes(statusCode) argumentCaptor { - verify(mockWriter).write(capture()) - assertThat(lastValue) + verify(mockWriter, times(expectedCallTimes)).write(capture()) + assertThat(firstValue) .hasAttributes(expectedAttributes) .hasUserExtraAttributes(fakeUserInfo.extraInfo) .hasResourceData { @@ -1008,6 +1347,29 @@ internal class RumResourceScopeTest { hasActionId(fakeParentContext.actionId) doesNotHaveAResourceProvider() } + if (expectedCallTimes > 1) { + assertThat(lastValue) + .hasAttributes(expectedAttributes) + .hasUserExtraAttributes(fakeUserInfo.extraInfo) + .hasErrorData { + hasMessage(RumResourceScope.ERROR_MSG_FORMAT.format(fakeMethod, fakeUrl)) + hasSource(RumErrorSource.NETWORK) + hasStackTrace(null) + isCrash(false) + hasResource(fakeUrl, fakeMethod, statusCode) + hasUserInfo(fakeUserInfo) + hasConnectivityInfo(fakeNetworkInfo) + hasView(fakeParentContext.viewId, fakeParentContext.viewUrl) + hasApplicationId(fakeParentContext.applicationId) + hasSessionId(fakeParentContext.sessionId) + hasActionId(fakeParentContext.actionId) + hasErrorType( + RumResourceScope.ERROR_TYPE_BASED_ON_STATUS_CODE_FORMAT.format( + statusCode + ) + ) + } + } } verify(mockParentScope).handleEvent( isA(), @@ -1040,9 +1402,10 @@ internal class RumResourceScopeTest { mockEvent = RumRawEvent.AddResourceTiming(fakeKey, timing) val resultTiming = testedScope.handleEvent(mockEvent, mockWriter) + val expectedCallTimes = resolveExpectedCallTimes(statusCode) argumentCaptor { - verify(mockWriter).write(capture()) - assertThat(lastValue) + verify(mockWriter, times(expectedCallTimes)).write(capture()) + assertThat(firstValue) .hasAttributes(expectedAttributes) .hasUserExtraAttributes(fakeUserInfo.extraInfo) .hasResourceData { @@ -1060,6 +1423,29 @@ internal class RumResourceScopeTest { hasActionId(fakeParentContext.actionId) doesNotHaveAResourceProvider() } + if (expectedCallTimes > 1) { + assertThat(lastValue) + .hasAttributes(expectedAttributes) + .hasUserExtraAttributes(fakeUserInfo.extraInfo) + .hasErrorData { + hasMessage(RumResourceScope.ERROR_MSG_FORMAT.format(fakeMethod, fakeUrl)) + hasSource(RumErrorSource.NETWORK) + hasStackTrace(null) + isCrash(false) + hasResource(fakeUrl, fakeMethod, statusCode) + hasUserInfo(fakeUserInfo) + hasConnectivityInfo(fakeNetworkInfo) + hasView(fakeParentContext.viewId, fakeParentContext.viewUrl) + hasApplicationId(fakeParentContext.applicationId) + hasSessionId(fakeParentContext.sessionId) + hasActionId(fakeParentContext.actionId) + hasErrorType( + RumResourceScope.ERROR_TYPE_BASED_ON_STATUS_CODE_FORMAT.format( + statusCode + ) + ) + } + } } verify(mockParentScope).handleEvent( isA(), @@ -1079,5 +1465,13 @@ internal class RumResourceScopeTest { return event } + private fun resolveExpectedCallTimes(statusCode: Long): Int { + return if (statusCode < RumResourceScope.HTTP_ERROR_CODE_THRESHOLD) { + 1 + } else { + 2 + } + } + // endregion } diff --git a/dd-sdk-android/src/test/kotlin/com/datadog/android/rum/internal/domain/scope/RumSessionScopeTest.kt b/dd-sdk-android/src/test/kotlin/com/datadog/android/rum/internal/domain/scope/RumSessionScopeTest.kt index 857759ae8b..833ed30cfc 100644 --- a/dd-sdk-android/src/test/kotlin/com/datadog/android/rum/internal/domain/scope/RumSessionScopeTest.kt +++ b/dd-sdk-android/src/test/kotlin/com/datadog/android/rum/internal/domain/scope/RumSessionScopeTest.kt @@ -6,6 +6,7 @@ package com.datadog.android.rum.internal.domain.scope +import android.os.Build import android.util.Log import com.datadog.android.Datadog import com.datadog.android.core.internal.CoreFeature @@ -21,6 +22,8 @@ import com.datadog.android.utils.forge.Configurator import com.datadog.android.utils.forge.exhaustiveAttributes import com.datadog.android.utils.mockCoreFeature import com.datadog.android.utils.mockDevLogHandler +import com.datadog.tools.unit.annotations.TestTargetApi +import com.datadog.tools.unit.extensions.ApiLevelExtension import com.nhaarman.mockitokotlin2.any import com.nhaarman.mockitokotlin2.argumentCaptor import com.nhaarman.mockitokotlin2.doReturn @@ -53,7 +56,8 @@ import org.mockito.quality.Strictness @Extensions( ExtendWith(MockitoExtension::class), - ExtendWith(ForgeExtension::class) + ExtendWith(ForgeExtension::class), + ExtendWith(ApiLevelExtension::class) ) @MockitoSettings(strictness = Strictness.LENIENT) @ForgeConfiguration(Configurator::class) @@ -354,7 +358,6 @@ internal class RumSessionScopeTest { @Test fun `M remove children scope W handleEvent child returns null`() { testedScope.activeChildrenScopes.add(mockChildScope) - val newChildScope: RumScope = mock() whenever(mockChildScope.handleEvent(mockEvent, mockWriter)) doReturn null val result = testedScope.handleEvent(mockEvent, mockWriter) @@ -364,6 +367,7 @@ internal class RumSessionScopeTest { verifyZeroInteractions(mockWriter) } + @TestTargetApi(Build.VERSION_CODES.KITKAT) @Test fun `M send ApplicationStarted event W applicationDisplayed`( @StringForgery key: String, @@ -383,6 +387,27 @@ internal class RumSessionScopeTest { verifyZeroInteractions(mockWriter) } + @TestTargetApi(Build.VERSION_CODES.N) + @Test + fun `M send ApplicationStarted event W applicationDisplayed {API 24+}`( + @StringForgery key: String, + @StringForgery name: String + ) { + val childView: RumViewScope = mock() + val startViewEvent = RumRawEvent.StartView(key, name, emptyMap()) + + testedScope.onApplicationDisplayed(startViewEvent, childView, mockWriter) + + argumentCaptor { + verify(childView).handleEvent(capture(), same(mockWriter)) + + val event = firstValue as RumRawEvent.ApplicationStarted + assertThat(event.applicationStartupNanos) + .isCloseTo(System.nanoTime(), Offset.offset(TimeUnit.MILLISECONDS.toNanos(100))) + } + verifyZeroInteractions(mockWriter) + } + @Test fun `M send ApplicationStarted event only once W applicationDisplayed`( @StringForgery key: String, diff --git a/dd-sdk-android/src/test/kotlin/com/datadog/android/rum/internal/domain/scope/RumViewScopeTest.kt b/dd-sdk-android/src/test/kotlin/com/datadog/android/rum/internal/domain/scope/RumViewScopeTest.kt index 32131a1db6..2259a0160b 100644 --- a/dd-sdk-android/src/test/kotlin/com/datadog/android/rum/internal/domain/scope/RumViewScopeTest.kt +++ b/dd-sdk-android/src/test/kotlin/com/datadog/android/rum/internal/domain/scope/RumViewScopeTest.kt @@ -103,7 +103,7 @@ internal class RumViewScopeTest { @BeforeEach fun `set up`(forge: Forge) { - val fakeOffset = - forge.aLong(1000, 50000) + val fakeOffset = -forge.aLong(1000, 50000) val fakeTimestamp = System.currentTimeMillis() + fakeOffset val fakeNanos = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(fakeOffset) fakeEventTime = Time(fakeTimestamp, fakeNanos) @@ -244,6 +244,7 @@ internal class RumViewScopeTest { hasResourceCount(0) hasActionCount(0) isActive(false) + hasNoCustomTimings() hasUserInfo(fakeUserInfo) hasViewId(testedScope.viewId) hasApplicationId(fakeParentContext.applicationId) @@ -286,6 +287,7 @@ internal class RumViewScopeTest { hasResourceCount(0) hasActionCount(0) isActive(false) + hasNoCustomTimings() hasUserInfo(fakeUserInfo) hasViewId(testedScope.viewId) hasApplicationId(fakeParentContext.applicationId) @@ -328,6 +330,7 @@ internal class RumViewScopeTest { hasResourceCount(0) hasActionCount(0) isActive(false) + hasNoCustomTimings() hasUserInfo(fakeUserInfo) hasViewId(testedScope.viewId) hasApplicationId(fakeParentContext.applicationId) @@ -361,6 +364,7 @@ internal class RumViewScopeTest { hasResourceCount(0) hasActionCount(0) isActive(false) + hasNoCustomTimings() hasUserInfo(fakeUserInfo) hasViewId(testedScope.viewId) hasApplicationId(fakeParentContext.applicationId) @@ -404,6 +408,7 @@ internal class RumViewScopeTest { hasResourceCount(0) hasActionCount(0) isActive(false) + hasNoCustomTimings() hasUserInfo(fakeUserInfo) hasViewId(testedScope.viewId) hasApplicationId(fakeParentContext.applicationId) @@ -458,6 +463,7 @@ internal class RumViewScopeTest { hasResourceCount(0) hasActionCount(0) isActive(false) + hasNoCustomTimings() hasUserInfo(fakeUserInfo) hasViewId(testedScope.viewId) hasApplicationId(fakeParentContext.applicationId) @@ -514,6 +520,7 @@ internal class RumViewScopeTest { hasResourceCount(0) hasActionCount(0) isActive(false) + hasNoCustomTimings() hasUserInfo(fakeUserInfo) hasViewId(testedScope.viewId) hasApplicationId(fakeParentContext.applicationId) @@ -560,6 +567,7 @@ internal class RumViewScopeTest { hasResourceCount(0) hasActionCount(0) isActive(false) + hasNoCustomTimings() hasUserInfo(fakeUserInfo) hasViewId(testedScope.viewId) hasApplicationId(fakeParentContext.applicationId) @@ -605,6 +613,7 @@ internal class RumViewScopeTest { hasResourceCount(0) hasActionCount(0) isActive(false) + hasNoCustomTimings() hasUserInfo(fakeUserInfo) hasViewId(testedScope.viewId) hasApplicationId(fakeParentContext.applicationId) @@ -642,6 +651,7 @@ internal class RumViewScopeTest { hasResourceCount(0) hasActionCount(0) isActive(false) + hasNoCustomTimings() hasUserInfo(fakeUserInfo) hasViewId(testedScope.viewId) hasApplicationId(fakeParentContext.applicationId) @@ -721,6 +731,7 @@ internal class RumViewScopeTest { hasResourceCount(0) hasActionCount(0) isActive(true) + hasNoCustomTimings() hasUserInfo(fakeUserInfo) hasViewId(testedScope.viewId) hasApplicationId(fakeParentContext.applicationId) @@ -755,6 +766,7 @@ internal class RumViewScopeTest { hasResourceCount(1) hasActionCount(0) isActive(true) + hasNoCustomTimings() hasUserInfo(fakeUserInfo) hasViewId(testedScope.viewId) hasApplicationId(fakeParentContext.applicationId) @@ -789,6 +801,7 @@ internal class RumViewScopeTest { hasResourceCount(0) hasActionCount(1) isActive(true) + hasNoCustomTimings() hasUserInfo(fakeUserInfo) hasViewId(testedScope.viewId) hasApplicationId(fakeParentContext.applicationId) @@ -848,6 +861,7 @@ internal class RumViewScopeTest { hasResourceCount(0) hasActionCount(1) isActive(true) + hasNoCustomTimings() hasUserInfo(fakeUserInfo) hasViewId(testedScope.viewId) hasApplicationId(fakeParentContext.applicationId) @@ -883,6 +897,7 @@ internal class RumViewScopeTest { hasResourceCount(0) hasActionCount(0) isActive(false) + hasNoCustomTimings() hasUserInfo(fakeUserInfo) hasViewId(testedScope.viewId) hasApplicationId(fakeParentContext.applicationId) @@ -918,6 +933,7 @@ internal class RumViewScopeTest { hasResourceCount(1) hasActionCount(0) isActive(false) + hasNoCustomTimings() hasUserInfo(fakeUserInfo) hasViewId(testedScope.viewId) hasApplicationId(fakeParentContext.applicationId) @@ -953,6 +969,7 @@ internal class RumViewScopeTest { hasResourceCount(0) hasActionCount(1) isActive(false) + hasNoCustomTimings() hasUserInfo(fakeUserInfo) hasViewId(testedScope.viewId) hasApplicationId(fakeParentContext.applicationId) @@ -1009,6 +1026,7 @@ internal class RumViewScopeTest { hasResourceCount(0) hasActionCount(1) isActive(false) + hasNoCustomTimings() hasUserInfo(fakeUserInfo) hasViewId(testedScope.viewId) hasApplicationId(fakeParentContext.applicationId) @@ -1065,6 +1083,7 @@ internal class RumViewScopeTest { hasResourceCount(0) hasActionCount(0) isActive(true) + hasNoCustomTimings() hasUserInfo(fakeUserInfo) hasViewId(testedScope.viewId) hasApplicationId(fakeParentContext.applicationId) @@ -1371,6 +1390,7 @@ internal class RumViewScopeTest { hasResourceCount(0) hasActionCount(0) isActive(true) + hasNoCustomTimings() hasUserInfo(fakeUserInfo) hasViewId(testedScope.viewId) hasApplicationId(fakeParentContext.applicationId) @@ -1431,6 +1451,7 @@ internal class RumViewScopeTest { hasResourceCount(0) hasActionCount(0) isActive(true) + hasNoCustomTimings() hasUserInfo(fakeUserInfo) hasViewId(testedScope.viewId) hasApplicationId(fakeParentContext.applicationId) @@ -1481,6 +1502,7 @@ internal class RumViewScopeTest { hasApplicationId(fakeParentContext.applicationId) hasSessionId(fakeParentContext.sessionId) hasActionId(fakeActionId) + hasErrorType(throwable.javaClass.canonicalName) } assertThat(lastValue) @@ -1493,6 +1515,7 @@ internal class RumViewScopeTest { hasResourceCount(0) hasActionCount(0) isActive(true) + hasNoCustomTimings() hasUserInfo(fakeUserInfo) hasViewId(testedScope.viewId) hasApplicationId(fakeParentContext.applicationId) @@ -1537,6 +1560,7 @@ internal class RumViewScopeTest { hasApplicationId(fakeParentContext.applicationId) hasSessionId(fakeParentContext.sessionId) hasActionId(fakeActionId) + hasErrorType(null) } assertThat(lastValue) @@ -1549,6 +1573,7 @@ internal class RumViewScopeTest { hasResourceCount(0) hasActionCount(0) isActive(true) + hasNoCustomTimings() hasUserInfo(fakeUserInfo) hasViewId(testedScope.viewId) hasApplicationId(fakeParentContext.applicationId) @@ -1604,6 +1629,7 @@ internal class RumViewScopeTest { hasApplicationId(fakeParentContext.applicationId) hasSessionId(fakeParentContext.sessionId) hasActionId(fakeActionId) + hasErrorType(throwable.javaClass.canonicalName) } assertThat(lastValue) @@ -1616,6 +1642,7 @@ internal class RumViewScopeTest { hasResourceCount(0) hasActionCount(0) isActive(true) + hasNoCustomTimings() hasUserInfo(fakeUserInfo) hasViewId(testedScope.viewId) hasApplicationId(fakeParentContext.applicationId) @@ -1667,6 +1694,7 @@ internal class RumViewScopeTest { hasApplicationId(fakeParentContext.applicationId) hasSessionId(fakeParentContext.sessionId) hasActionId(fakeActionId) + hasErrorType(throwable.javaClass.canonicalName) } assertThat(lastValue) @@ -1689,6 +1717,73 @@ internal class RumViewScopeTest { assertThat(result).isSameAs(testedScope) } + @Test + fun `𝕄 send events π•Ž handleEvent(AddError) {custom error type}`( + @StringForgery message: String, + @Forgery source: RumErrorSource, + @Forgery throwable: Throwable, + @StringForgery errorType: String, + forge: Forge + ) { + // Given + testedScope.activeActionScope = mockActionScope + val attributes = forge.exhaustiveAttributes() + fakeEvent = RumRawEvent.AddError( + message, + source, + throwable, + null, + true, + attributes, + type = errorType + ) + + // When + val result = testedScope.handleEvent(fakeEvent, mockWriter) + + // Then + argumentCaptor { + verify(mockWriter, times(2)).write(capture()) + + assertThat(firstValue) + .hasAttributes(attributes) + .hasUserExtraAttributes(fakeUserInfo.extraInfo) + .hasErrorData { + hasTimestamp(fakeEvent.eventTime.timestamp) + hasMessage(message) + hasSource(source) + hasStackTrace(throwable.loggableStackTrace()) + isCrash(true) + hasUserInfo(fakeUserInfo) + hasConnectivityInfo(fakeNetworkInfo) + hasView(testedScope.viewId, testedScope.urlName) + hasApplicationId(fakeParentContext.applicationId) + hasSessionId(fakeParentContext.sessionId) + hasActionId(fakeActionId) + hasErrorType(errorType) + } + + assertThat(lastValue) + .hasAttributes(fakeAttributes) + .hasUserExtraAttributes(fakeUserInfo.extraInfo) + .hasViewData { + hasTimestamp(fakeEventTime.timestamp) + hasErrorCount(1) + hasCrashCount(1) + hasResourceCount(0) + hasActionCount(0) + isActive(true) + hasNoCustomTimings() + hasUserInfo(fakeUserInfo) + hasViewId(testedScope.viewId) + hasApplicationId(fakeParentContext.applicationId) + hasSessionId(fakeParentContext.sessionId) + } + } + verifyNoMoreInteractions(mockWriter) + assertThat(result).isSameAs(testedScope) + } + @Test fun `𝕄 send events with global attributes π•Ž handleEvent(AddError) {isFatal=true}`( @StringForgery message: String, @@ -1734,6 +1829,7 @@ internal class RumViewScopeTest { hasApplicationId(fakeParentContext.applicationId) hasSessionId(fakeParentContext.sessionId) hasActionId(fakeActionId) + hasErrorType(throwable.javaClass.canonicalName) } assertThat(lastValue) @@ -1746,6 +1842,7 @@ internal class RumViewScopeTest { hasResourceCount(0) hasActionCount(0) isActive(true) + hasNoCustomTimings() hasUserInfo(fakeUserInfo) hasViewId(testedScope.viewId) hasApplicationId(fakeParentContext.applicationId) @@ -1833,6 +1930,7 @@ internal class RumViewScopeTest { hasResourceCount(0) hasActionCount(0) isActive(true) + hasNoCustomTimings() hasUserInfo(fakeUserInfo) hasViewId(testedScope.viewId) hasApplicationId(fakeParentContext.applicationId) @@ -1873,6 +1971,7 @@ internal class RumViewScopeTest { hasResourceCount(0) hasActionCount(0) isActive(false) + hasNoCustomTimings() hasUserInfo(fakeUserInfo) hasViewId(testedScope.viewId) hasApplicationId(fakeParentContext.applicationId) @@ -1920,7 +2019,6 @@ internal class RumViewScopeTest { verify(mockWriter).write(capture()) assertThat(lastValue) .hasUserExtraAttributes(fakeUserInfo.extraInfo) - .hasCustomTimings(mapOf(fakeTimingKey to customTimingEstimatedDuration)) .hasViewData { hasTimestamp(fakeEventTime.timestamp) hasName(fakeName.replace('.', '/')) @@ -1932,6 +2030,7 @@ internal class RumViewScopeTest { hasResourceCount(0) hasActionCount(0) isActive(true) + hasCustomTimings(mapOf(fakeTimingKey to customTimingEstimatedDuration)) hasUserInfo(fakeUserInfo) hasViewId(testedScope.viewId) hasApplicationId(fakeParentContext.applicationId) @@ -1966,7 +2065,6 @@ internal class RumViewScopeTest { verify(mockWriter, times(2)).write(capture()) assertThat(firstValue) .hasUserExtraAttributes(fakeUserInfo.extraInfo) - .hasCustomTimings(mapOf(fakeTimingKey1 to customTiming1EstimatedDuration)) .hasViewData { hasTimestamp(fakeEventTime.timestamp) hasName(fakeName.replace('.', '/')) @@ -1978,6 +2076,7 @@ internal class RumViewScopeTest { hasResourceCount(0) hasActionCount(0) isActive(true) + hasCustomTimings(mapOf(fakeTimingKey1 to customTiming1EstimatedDuration)) hasUserInfo(fakeUserInfo) hasViewId(testedScope.viewId) hasApplicationId(fakeParentContext.applicationId) @@ -1985,12 +2084,6 @@ internal class RumViewScopeTest { } assertThat(lastValue) .hasUserExtraAttributes(fakeUserInfo.extraInfo) - .hasCustomTimings( - mapOf( - fakeTimingKey1 to customTiming1EstimatedDuration, - fakeTimingKey2 to customTiming2EstimatedDuration - ) - ) .hasViewData { hasTimestamp(fakeEventTime.timestamp) hasName(fakeName.replace('.', '/')) @@ -2002,6 +2095,12 @@ internal class RumViewScopeTest { hasResourceCount(0) hasActionCount(0) isActive(true) + hasCustomTimings( + mapOf( + fakeTimingKey1 to customTiming1EstimatedDuration, + fakeTimingKey2 to customTiming2EstimatedDuration + ) + ) hasUserInfo(fakeUserInfo) hasViewId(testedScope.viewId) hasApplicationId(fakeParentContext.applicationId) diff --git a/dd-sdk-android/src/test/kotlin/com/datadog/android/rum/internal/monitor/DatadogRumMonitorTest.kt b/dd-sdk-android/src/test/kotlin/com/datadog/android/rum/internal/monitor/DatadogRumMonitorTest.kt index 8b92d66014..beb6fc8570 100644 --- a/dd-sdk-android/src/test/kotlin/com/datadog/android/rum/internal/monitor/DatadogRumMonitorTest.kt +++ b/dd-sdk-android/src/test/kotlin/com/datadog/android/rum/internal/monitor/DatadogRumMonitorTest.kt @@ -728,6 +728,65 @@ internal class DatadogRumMonitorTest { verifyNoMoreInteractions(mockScope, mockWriter) } + @Test + fun `M delegate event to rootScope with error type W addError`( + @StringForgery message: String, + @Forgery source: RumErrorSource, + @Forgery throwable: Throwable, + @StringForgery errorType: String + ) { + val fakeAttributesWithErrorType = + fakeAttributes + (RumAttributes.INTERNAL_ERROR_TYPE to errorType) + testedMonitor.addError(message, source, throwable, fakeAttributesWithErrorType) + Thread.sleep(200) + + argumentCaptor { + verify(mockScope).handleEvent(capture(), same(mockWriter)) + + val event = firstValue as RumRawEvent.AddError + assertThat(event.message).isEqualTo(message) + assertThat(event.source).isEqualTo(source) + assertThat(event.throwable).isEqualTo(throwable) + assertThat(event.stacktrace).isNull() + assertThat(event.isFatal).isFalse() + assertThat(event.type).isEqualTo(errorType) + assertThat(event.attributes).containsAllEntriesOf(fakeAttributesWithErrorType) + } + verifyNoMoreInteractions(mockScope, mockWriter) + } + + @Test + fun `M delegate event to rootScope W error type onAddErrorWithStacktrace`( + @StringForgery message: String, + @Forgery source: RumErrorSource, + @StringForgery stacktrace: String, + @StringForgery errorType: String + ) { + val fakeAttributesWithErrorType = + fakeAttributes + (RumAttributes.INTERNAL_ERROR_TYPE to errorType) + testedMonitor.addErrorWithStacktrace( + message, + source, + stacktrace, + fakeAttributesWithErrorType + ) + Thread.sleep(200) + + argumentCaptor { + verify(mockScope).handleEvent(capture(), same(mockWriter)) + + val event = firstValue as RumRawEvent.AddError + assertThat(event.message).isEqualTo(message) + assertThat(event.source).isEqualTo(source) + assertThat(event.throwable).isNull() + assertThat(event.stacktrace).isEqualTo(stacktrace) + assertThat(event.isFatal).isFalse() + assertThat(event.attributes).containsAllEntriesOf(fakeAttributesWithErrorType) + assertThat(event.type).isEqualTo(errorType) + } + verifyNoMoreInteractions(mockScope, mockWriter) + } + @Test fun `sends keep alive event to rootScope regularly`() { argumentCaptor { diff --git a/dd-sdk-android/src/test/kotlin/com/datadog/android/rum/internal/ndk/DatadogNdkCrashHandlerTest.kt b/dd-sdk-android/src/test/kotlin/com/datadog/android/rum/internal/ndk/DatadogNdkCrashHandlerTest.kt new file mode 100644 index 0000000000..c814b9eb56 --- /dev/null +++ b/dd-sdk-android/src/test/kotlin/com/datadog/android/rum/internal/ndk/DatadogNdkCrashHandlerTest.kt @@ -0,0 +1,471 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.datadog.android.rum.internal.ndk + +import com.datadog.android.core.internal.data.Writer +import com.datadog.android.log.LogAttributes +import com.datadog.android.log.internal.domain.Log +import com.datadog.android.log.internal.domain.LogGenerator +import com.datadog.android.log.internal.logger.LogHandler +import com.datadog.android.log.internal.user.UserInfo +import com.datadog.android.rum.RumErrorSource +import com.datadog.android.rum.assertj.ErrorEventAssert +import com.datadog.android.rum.assertj.ViewEventAssert +import com.datadog.android.rum.internal.data.file.RumFileWriter +import com.datadog.android.rum.internal.domain.event.RumEvent +import com.datadog.android.rum.internal.domain.event.RumEventSerializer +import com.datadog.android.rum.model.ErrorEvent +import com.datadog.android.rum.model.ViewEvent +import com.datadog.android.utils.forge.Configurator +import com.datadog.android.utils.mockSdkLogHandler +import com.datadog.android.utils.restoreSdkLogHandler +import com.nhaarman.mockitokotlin2.any +import com.nhaarman.mockitokotlin2.anyOrNull +import com.nhaarman.mockitokotlin2.argumentCaptor +import com.nhaarman.mockitokotlin2.eq +import com.nhaarman.mockitokotlin2.mock +import com.nhaarman.mockitokotlin2.times +import com.nhaarman.mockitokotlin2.verify +import com.nhaarman.mockitokotlin2.verifyZeroInteractions +import com.nhaarman.mockitokotlin2.whenever +import fr.xgouchet.elmyr.Forge +import fr.xgouchet.elmyr.annotation.Forgery +import fr.xgouchet.elmyr.junit5.ForgeConfiguration +import fr.xgouchet.elmyr.junit5.ForgeExtension +import java.io.File +import java.util.concurrent.ExecutorService +import java.util.concurrent.Future +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.junit.jupiter.api.extension.Extensions +import org.junit.jupiter.api.io.TempDir +import org.mockito.Mock +import org.mockito.junit.jupiter.MockitoExtension +import org.mockito.junit.jupiter.MockitoSettings +import org.mockito.quality.Strictness + +@Extensions( + ExtendWith(MockitoExtension::class), + ExtendWith(ForgeExtension::class) +) +@MockitoSettings(strictness = Strictness.LENIENT) +@ForgeConfiguration(Configurator::class) +internal class DatadogNdkCrashHandlerTest { + + @Mock + lateinit var mockedExecutorService: ExecutorService + + @TempDir + lateinit var fakeNdkCrashReportsDirectory: File + + @Mock + lateinit var mockedAsyncLogWriter: Writer + + @Mock + lateinit var mockedAsyncRumWriter: Writer + + @Mock + lateinit var mockedLockGenerator: LogGenerator + + @Mock + lateinit var mockedGeneratedLog: Log + + lateinit var testedHandler: DatadogNdkCrashHandler + + @Forgery + lateinit var fakeNdkCrashLog: NdkCrashLog + + lateinit var fakeRumViewEvent: RumEvent + lateinit var fakeSerializedNdkCrashLog: String + lateinit var fakeSerializedRumViewEvent: String + + private var rumEventSerializer = RumEventSerializer() + + // region Unit Tests + + @BeforeEach + fun `set up`(forge: Forge) { + fakeRumViewEvent = forge.getForgery(RumEvent::class.java) + .copy(event = forge.getForgery(ViewEvent::class.java)) + fakeSerializedNdkCrashLog = fakeNdkCrashLog.toJson() + fakeSerializedRumViewEvent = rumEventSerializer.serialize(fakeRumViewEvent) + whenever( + mockedLockGenerator.generateLog( + eq(Log.CRASH), + eq(DatadogNdkCrashHandler.NDK_ERROR_LOG_MESSAGE.format(fakeNdkCrashLog.signalName)), + anyOrNull(), + eq( + mapOf( + LogAttributes.ERROR_STACK to fakeNdkCrashLog.stacktrace + ) + ), + anyOrNull(), + eq(fakeNdkCrashLog.timestamp), + anyOrNull(), + any(), + any() + ) + ).thenReturn(mockedGeneratedLog) + + whenever(mockedExecutorService.submit(any())).then { + val runnable = it.getArgument(0) + runnable.run() + mock>() + } + testedHandler = DatadogNdkCrashHandler( + fakeNdkCrashReportsDirectory, + mockedExecutorService, + mockedAsyncLogWriter, + mockedAsyncRumWriter, + mockedLockGenerator + ) + } + + @Test + fun `M do nothing W handleNdkCrash { NDK crash reports directory does not exist }`( + forge: Forge + ) { + // GIVEN + val fakeNonExistingDir = File(forge.aStringMatching("[a-b]{3,10}")) + testedHandler = DatadogNdkCrashHandler( + fakeNonExistingDir, + mockedExecutorService, + mockedAsyncLogWriter, + mockedAsyncRumWriter, + mockedLockGenerator + ) + + // WHEN + testedHandler.handleNdkCrash() + + // THEN + verifyZeroInteractions(mockedAsyncLogWriter) + verifyZeroInteractions(mockedAsyncRumWriter) + } + + @Test + fun `M do nothing W handleNdkCrash { there was no NDK crash report persisted }`() { + // GIVEN + testedHandler = DatadogNdkCrashHandler( + fakeNdkCrashReportsDirectory, + mockedExecutorService, + mockedAsyncLogWriter, + mockedAsyncRumWriter, + mockedLockGenerator + ) + + // WHEN + testedHandler.handleNdkCrash() + + // THEN + verifyZeroInteractions(mockedAsyncLogWriter) + verifyZeroInteractions(mockedAsyncRumWriter) + } + + @Test + fun `M do nothing and log exception W handleNdkCrash { persisted crash report is broken }`( + forge: Forge + ) { + // GIVEN + val mockLogHandler: LogHandler = mock() + val originalLogHandler: LogHandler = mockSdkLogHandler(mockLogHandler) + val fakeBrokenJson = "{]" + val crashLogFile = + File(fakeNdkCrashReportsDirectory, DatadogNdkCrashHandler.CRASH_LOG_FILE_NAME) + crashLogFile.outputStream().use { + it.write(fakeBrokenJson.toByteArray()) + } + testedHandler = DatadogNdkCrashHandler( + fakeNdkCrashReportsDirectory, + mockedExecutorService, + mockedAsyncLogWriter, + mockedAsyncRumWriter, + mockedLockGenerator + ) + + // WHEN + testedHandler.handleNdkCrash() + + // THEN + verifyZeroInteractions(mockedAsyncLogWriter) + verifyZeroInteractions(mockedAsyncRumWriter) + verify(mockLogHandler).handleLog( + eq(android.util.Log.ERROR), + eq("Malformed ndk crash error log"), + any(), + anyOrNull(), + anyOrNull(), + anyOrNull() + ) + restoreSdkLogHandler(originalLogHandler) + } + + @Test + fun `M send an error log W handleNdkCrash { found a persisted crash report }`(forge: Forge) { + // GIVEN + val crashLogFile = + File(fakeNdkCrashReportsDirectory, DatadogNdkCrashHandler.CRASH_LOG_FILE_NAME) + crashLogFile.outputStream().use { + it.write(fakeSerializedNdkCrashLog.toByteArray()) + } + + // WHEN + testedHandler.handleNdkCrash() + + // THEN + verify(mockedLockGenerator).generateLog( + eq(Log.CRASH), + eq(DatadogNdkCrashHandler.NDK_ERROR_LOG_MESSAGE.format(fakeNdkCrashLog.signalName)), + anyOrNull(), + eq( + mapOf( + LogAttributes.ERROR_STACK to fakeNdkCrashLog.stacktrace + ) + ), + eq(emptySet()), + eq(fakeNdkCrashLog.timestamp), + anyOrNull(), + eq(false), + eq(false) + ) + verify(mockedAsyncLogWriter).write(mockedGeneratedLog) + } + + @Test + fun `M send an error log W RUM context handleNdkCrash { has last view event }`(forge: Forge) { + // GIVEN + val crashLogFile = + File(fakeNdkCrashReportsDirectory, DatadogNdkCrashHandler.CRASH_LOG_FILE_NAME) + val lastViewEventFile = + File(fakeNdkCrashReportsDirectory, RumFileWriter.LAST_VIEW_EVENT_FILE_NAME) + + crashLogFile.outputStream().use { + it.write(fakeSerializedNdkCrashLog.toByteArray()) + } + lastViewEventFile.outputStream().use { + it.write(fakeSerializedRumViewEvent.toByteArray()) + } + val fakeBundledViewEvent = fakeRumViewEvent.event as ViewEvent + mockTheLogGenerator(fakeBundledViewEvent) + + // WHEN + testedHandler.handleNdkCrash() + + // THEN + verify(mockedLockGenerator).generateLog( + eq(Log.CRASH), + eq(DatadogNdkCrashHandler.NDK_ERROR_LOG_MESSAGE.format(fakeNdkCrashLog.signalName)), + anyOrNull(), + eq( + mapOf( + LogAttributes.RUM_VIEW_ID to fakeBundledViewEvent.view.id, + LogAttributes.RUM_SESSION_ID to fakeBundledViewEvent.session.id, + LogAttributes.RUM_APPLICATION_ID to fakeBundledViewEvent.application.id, + LogAttributes.ERROR_STACK to fakeNdkCrashLog.stacktrace + ) + ), + eq(emptySet()), + eq(fakeNdkCrashLog.timestamp), + anyOrNull(), + eq(false), + eq(false) + ) + verify(mockedAsyncLogWriter).write(mockedGeneratedLog) + } + + @Test + fun `M send the updated RUM ViewEvent W handleNdkCrash { has last view event }`(forge: Forge) { + // GIVEN + val crashLogFile = + File(fakeNdkCrashReportsDirectory, DatadogNdkCrashHandler.CRASH_LOG_FILE_NAME) + val lastViewEventFile = + File(fakeNdkCrashReportsDirectory, RumFileWriter.LAST_VIEW_EVENT_FILE_NAME) + + crashLogFile.outputStream().use { + it.write(fakeSerializedNdkCrashLog.toByteArray()) + } + lastViewEventFile.outputStream().use { + it.write(fakeSerializedRumViewEvent.toByteArray()) + } + val fakeBundledViewEvent = fakeRumViewEvent.event as ViewEvent + mockTheLogGenerator(fakeBundledViewEvent) + + // WHEN + testedHandler.handleNdkCrash() + + // THEN + verify(mockedAsyncLogWriter).write(mockedGeneratedLog) + val argumentCaptor = argumentCaptor() + verify(mockedAsyncRumWriter, times(2)).write(argumentCaptor.capture()) + ViewEventAssert.assertThat(argumentCaptor.firstValue.event as ViewEvent) + .isEqualTo( + fakeBundledViewEvent.copy( + view = fakeBundledViewEvent.view.copy( + error = fakeBundledViewEvent.view.error.copy( + count = fakeBundledViewEvent.view.error.count + 1 + ), + isActive = false + ), + dd = fakeBundledViewEvent.dd.copy( + documentVersion = fakeBundledViewEvent.dd.documentVersion + 1 + ) + ) + ) + } + + @Test + fun `M send the updated RUM ErrorEvent W handleNdkCrash { has last view event }`(forge: Forge) { + // GIVEN + val crashLogFile = + File(fakeNdkCrashReportsDirectory, DatadogNdkCrashHandler.CRASH_LOG_FILE_NAME) + val lastViewEventFile = + File(fakeNdkCrashReportsDirectory, RumFileWriter.LAST_VIEW_EVENT_FILE_NAME) + + crashLogFile.outputStream().use { + it.write(fakeSerializedNdkCrashLog.toByteArray()) + } + lastViewEventFile.outputStream().use { + it.write(fakeSerializedRumViewEvent.toByteArray()) + } + val fakeBundledViewEvent = fakeRumViewEvent.event as ViewEvent + mockTheLogGenerator(fakeBundledViewEvent) + + // WHEN + testedHandler.handleNdkCrash() + + // THEN + verify(mockedAsyncLogWriter).write(mockedGeneratedLog) + val argumentCaptor = argumentCaptor() + verify(mockedAsyncRumWriter, times(2)).write(argumentCaptor.capture()) + ErrorEventAssert.assertThat(argumentCaptor.secondValue.event as ErrorEvent) + .hasApplicationId(fakeBundledViewEvent.application.id) + .hasSessionId(fakeBundledViewEvent.session.id) + .hasView(fakeBundledViewEvent.view.id, fakeBundledViewEvent.view.url) + .hasMessage( + DatadogNdkCrashHandler.NDK_ERROR_LOG_MESSAGE.format(fakeNdkCrashLog.signalName) + ) + .hasStackTrace(fakeNdkCrashLog.stacktrace) + .isCrash(true) + .hasSource(RumErrorSource.SOURCE) + .hasTimestamp(fakeNdkCrashLog.timestamp) + .hasUserInfo( + UserInfo( + fakeBundledViewEvent.usr?.id, + fakeBundledViewEvent.usr?.name, + fakeBundledViewEvent.usr?.email + ) + ) + .hasConnectivityStatus( + fakeBundledViewEvent.connectivity?.status?.let { + ErrorEvent.Status.valueOf(it.name) + } + ) + .hasConnectivityCellular( + fakeBundledViewEvent.connectivity?.cellular?.let { + ErrorEvent.Cellular(it.technology, it.carrierName) + } + ) + .hasConnectivityInterface( + fakeBundledViewEvent.connectivity?.interfaces?.map { + ErrorEvent.Interface.valueOf( + it.name + ) + } + ) + .hasErrorType(fakeNdkCrashLog.signalName) + } + + @Test + fun `M only send the RUM ErrorEvent W handleNdkCrash { last view event older than 4h }`( + forge: Forge + ) { + // GIVEN + fakeNdkCrashLog = + fakeNdkCrashLog.copy( + timestamp = System.currentTimeMillis() - + DatadogNdkCrashHandler.VIEW_EVENT_AVAILABILITY_TIME_THRESHOLD + ) + fakeSerializedNdkCrashLog = fakeNdkCrashLog.toJson() + val crashLogFile = + File(fakeNdkCrashReportsDirectory, DatadogNdkCrashHandler.CRASH_LOG_FILE_NAME) + val lastViewEventFile = + File(fakeNdkCrashReportsDirectory, RumFileWriter.LAST_VIEW_EVENT_FILE_NAME) + + crashLogFile.outputStream().use { + it.write(fakeSerializedNdkCrashLog.toByteArray()) + } + lastViewEventFile.outputStream().use { + it.write(fakeSerializedRumViewEvent.toByteArray()) + } + val fakeBundledViewEvent = fakeRumViewEvent.event as ViewEvent + mockTheLogGenerator(fakeBundledViewEvent) + + // WHEN + testedHandler.handleNdkCrash() + + // THEN + verify(mockedAsyncLogWriter).write(mockedGeneratedLog) + val argumentCaptor = argumentCaptor() + verify(mockedAsyncRumWriter, times(1)).write(argumentCaptor.capture()) + assertThat(argumentCaptor.firstValue.event).isInstanceOf(ErrorEvent::class.java) + } + + @Test + fun `M clean the NDK crash reports folder`() { + // GIVEN + val crashLogFile = + File(fakeNdkCrashReportsDirectory, DatadogNdkCrashHandler.CRASH_LOG_FILE_NAME) + val lastViewEventFile = + File(fakeNdkCrashReportsDirectory, RumFileWriter.LAST_VIEW_EVENT_FILE_NAME) + + crashLogFile.outputStream().use { + it.write(fakeSerializedNdkCrashLog.toByteArray()) + } + lastViewEventFile.outputStream().use { + it.write(fakeSerializedRumViewEvent.toByteArray()) + } + val fakeBundledViewEvent = fakeRumViewEvent.event as ViewEvent + mockTheLogGenerator(fakeBundledViewEvent) + + // WHEN + testedHandler.handleNdkCrash() + + // THEN + assertThat(fakeNdkCrashReportsDirectory.listFiles()).isEmpty() + } + + // endregion + + // region Internal + + private fun mockTheLogGenerator(fakeBundledViewEvent: ViewEvent) { + whenever( + mockedLockGenerator.generateLog( + eq(Log.CRASH), + eq(DatadogNdkCrashHandler.NDK_ERROR_LOG_MESSAGE.format(fakeNdkCrashLog.signalName)), + anyOrNull(), + eq( + mapOf( + LogAttributes.RUM_VIEW_ID to fakeBundledViewEvent.view.id, + LogAttributes.RUM_SESSION_ID to fakeBundledViewEvent.session.id, + LogAttributes.RUM_APPLICATION_ID to fakeBundledViewEvent.application.id, + LogAttributes.ERROR_STACK to fakeNdkCrashLog.stacktrace + ) + ), + anyOrNull(), + eq(fakeNdkCrashLog.timestamp), + anyOrNull(), + any(), + any() + ) + ).thenReturn(mockedGeneratedLog) + } + + // endregion +} diff --git a/dd-sdk-android/src/test/kotlin/com/datadog/android/rum/internal/ndk/NdkCrashLogTest.kt b/dd-sdk-android/src/test/kotlin/com/datadog/android/rum/internal/ndk/NdkCrashLogTest.kt new file mode 100644 index 0000000000..330d3b26b0 --- /dev/null +++ b/dd-sdk-android/src/test/kotlin/com/datadog/android/rum/internal/ndk/NdkCrashLogTest.kt @@ -0,0 +1,40 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.datadog.android.rum.internal.ndk + +import com.datadog.android.utils.forge.Configurator +import fr.xgouchet.elmyr.annotation.Forgery +import fr.xgouchet.elmyr.junit5.ForgeConfiguration +import fr.xgouchet.elmyr.junit5.ForgeExtension +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.junit.jupiter.api.extension.Extensions +import org.mockito.junit.jupiter.MockitoExtension +import org.mockito.junit.jupiter.MockitoSettings +import org.mockito.quality.Strictness + +@Extensions( + ExtendWith(MockitoExtension::class), + ExtendWith(ForgeExtension::class) +) +@MockitoSettings(strictness = Strictness.LENIENT) +@ForgeConfiguration(Configurator::class) +internal class NdkCrashLogTest { + + @Test + fun `M deserialize an NdkCrashLog W required`(@Forgery fakeNdkCrashLog: NdkCrashLog) { + // GIVEN + val serializedLog = fakeNdkCrashLog.toJson() + + // WHEN + val deserializedLog: NdkCrashLog = NdkCrashLog.fromJson(serializedLog) + + // THEN + assertThat(deserializedLog).isEqualTo(fakeNdkCrashLog) + } +} diff --git a/dd-sdk-android/src/test/kotlin/com/datadog/android/rum/internal/tracking/AndroidXFragmentLifecycleCallbacksTest.kt b/dd-sdk-android/src/test/kotlin/com/datadog/android/rum/internal/tracking/AndroidXFragmentLifecycleCallbacksTest.kt index 40ef1590b7..6f11bef87a 100644 --- a/dd-sdk-android/src/test/kotlin/com/datadog/android/rum/internal/tracking/AndroidXFragmentLifecycleCallbacksTest.kt +++ b/dd-sdk-android/src/test/kotlin/com/datadog/android/rum/internal/tracking/AndroidXFragmentLifecycleCallbacksTest.kt @@ -19,15 +19,18 @@ import com.datadog.android.rum.internal.RumFeature import com.datadog.android.rum.internal.instrumentation.gestures.GesturesTracker import com.datadog.android.rum.internal.monitor.AdvancedRumMonitor import com.datadog.android.rum.model.ViewEvent -import com.datadog.android.rum.tracking.AcceptAllSupportFragments import com.datadog.android.rum.tracking.ComponentPredicate import com.nhaarman.mockitokotlin2.doReturn -import com.nhaarman.mockitokotlin2.eq +import com.nhaarman.mockitokotlin2.inOrder import com.nhaarman.mockitokotlin2.mock import com.nhaarman.mockitokotlin2.verify import com.nhaarman.mockitokotlin2.verifyZeroInteractions import com.nhaarman.mockitokotlin2.whenever import fr.xgouchet.elmyr.Forge +import fr.xgouchet.elmyr.annotation.BoolForgery +import fr.xgouchet.elmyr.annotation.LongForgery +import fr.xgouchet.elmyr.annotation.StringForgery +import fr.xgouchet.elmyr.annotation.StringForgeryType import fr.xgouchet.elmyr.junit5.ForgeExtension import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test @@ -77,6 +80,9 @@ internal class AndroidXFragmentLifecycleCallbacksTest { @Mock lateinit var mockAdvancedRumMonitor: AdvancedRumMonitor + @Mock + lateinit var mockPredicate: ComponentPredicate + lateinit var fakeAttributes: Map @BeforeEach @@ -87,233 +93,320 @@ internal class AndroidXFragmentLifecycleCallbacksTest { fakeAttributes = forge.aMap { forge.aString() to forge.aString() } testedLifecycleCallbacks = AndroidXFragmentLifecycleCallbacks( { fakeAttributes }, - AcceptAllSupportFragments(), + mockPredicate, viewLoadingTimer = mockViewLoadingTimer, rumMonitor = mockRumMonitor, advancedRumMonitor = mockAdvancedRumMonitor ) } + // region Track View Loading Time + @Test - fun `when fragment attached, it will notify the timer`( - forge: Forge - ) { - testedLifecycleCallbacks.onFragmentAttached(mock(), mockFragment, mockFragmentActivity) + fun `𝕄 notify viewLoadingTimer π•Ž onFragmentAttached()`() { + // Given + whenever(mockPredicate.accept(mockFragment)) doReturn true + // When + testedLifecycleCallbacks.onFragmentAttached( + mockFragmentManager, + mockFragment, + mockFragmentActivity + ) + + // Then verify(mockViewLoadingTimer).onCreated(mockFragment) } @Test - fun `when fragment attached, and not whitelisted will not interact with timer`( - forge: Forge - ) { - testedLifecycleCallbacks = AndroidXFragmentLifecycleCallbacks( - { fakeAttributes }, - object : ComponentPredicate { - override fun accept(component: Fragment): Boolean { - return false - } - }, - viewLoadingTimer = mockViewLoadingTimer, - rumMonitor = mockRumMonitor, - advancedRumMonitor = mockAdvancedRumMonitor - ) + fun `𝕄 notify viewLoadingTimer π•Ž onFragmentStarted()`() { + // Given + whenever(mockPredicate.accept(mockFragment)) doReturn true - testedLifecycleCallbacks.onFragmentAttached(mock(), mockFragment, mockFragmentActivity) + // When + testedLifecycleCallbacks.onFragmentStarted(mockFragmentManager, mockFragment) - verifyZeroInteractions(mockViewLoadingTimer) + // Then + verify(mockViewLoadingTimer).onStartLoading(mockFragment) } @Test - fun `when fragment started, it will notify the timer`( - forge: Forge - ) { - testedLifecycleCallbacks.onFragmentStarted(mock(), mockFragment) + fun `𝕄 notify viewLoadingTimer π•Ž onFragmentResumed()`() { + // Given + whenever(mockPredicate.accept(mockFragment)) doReturn true - verify(mockViewLoadingTimer).onStartLoading(mockFragment) + // When + testedLifecycleCallbacks.onFragmentResumed(mockFragmentManager, mockFragment) + + // Then + verify(mockViewLoadingTimer).onFinishedLoading(mockFragment) } @Test - fun `when fragment started, and not whitelisted will not interact with timer`( - forge: Forge - ) { - testedLifecycleCallbacks = AndroidXFragmentLifecycleCallbacks( - { fakeAttributes }, - object : ComponentPredicate { - override fun accept(component: Fragment): Boolean { - return false - } - }, - viewLoadingTimer = mockViewLoadingTimer, - rumMonitor = mockRumMonitor, - advancedRumMonitor = mockAdvancedRumMonitor + fun `𝕄 notify viewLoadingTimer π•Ž onActivityPaused()`() { + // Given + whenever(mockPredicate.accept(mockFragment)) doReturn true + + // When + testedLifecycleCallbacks.onFragmentPaused(mockFragmentManager, mockFragment) + + // Then + verify(mockViewLoadingTimer).onPaused(mockFragment) + } + + @Test + fun `𝕄 notify viewLoadingTimer π•Ž onActivityDestroyed()`() { + // Given + whenever(mockPredicate.accept(mockFragment)) doReturn true + + // When + testedLifecycleCallbacks.onFragmentDestroyed(mockFragmentManager, mockFragment) + + // Then + verify(mockViewLoadingTimer).onDestroyed(mockFragment) + } + + // endregion + + // region Track View Loading Time (not tracked) + + @Test + fun `𝕄 notify viewLoadingTimer π•Ž onFragmentAttached() {fragment not tracked}`() { + // Given + whenever(mockPredicate.accept(mockFragment)) doReturn false + + // When + testedLifecycleCallbacks.onFragmentAttached( + mockFragmentManager, + mockFragment, + mockFragmentActivity ) - testedLifecycleCallbacks.onFragmentStarted(mock(), mockFragment) + // Then verifyZeroInteractions(mockViewLoadingTimer) } @Test - fun `when fragment activity created on DialogFragment, it will register a Window Callback`( - forge: Forge - ) { - val mockDialogFragment: DialogFragment = mock() - whenever(mockDialogFragment.context) doReturn mockContext - whenever(mockDialogFragment.dialog) doReturn mockDialog - whenever(mockDialog.window) doReturn mockWindow + fun `𝕄 notify viewLoadingTimer π•Ž onFragmentStarted() {fragment not tracked}`() { + // Given + whenever(mockPredicate.accept(mockFragment)) doReturn false - testedLifecycleCallbacks.onFragmentActivityCreated(mock(), mockDialogFragment, null) + // When + testedLifecycleCallbacks.onFragmentStarted(mockFragmentManager, mockFragment) - verify(mockGesturesTracker).startTracking(mockWindow, mockContext) + // Then + verifyZeroInteractions(mockViewLoadingTimer) } @Test - fun `when fragment activity created on Fragment, registers nothing`(forge: Forge) { - whenever(mockFragment.context) doReturn mockContext + fun `𝕄 notify viewLoadingTimer π•Ž onFragmentResumed() {fragment not tracked}`() { + // Given + whenever(mockPredicate.accept(mockFragment)) doReturn false - testedLifecycleCallbacks.onFragmentActivityCreated(mock(), mockFragment, null) + // When + testedLifecycleCallbacks.onFragmentResumed(mockFragmentManager, mockFragment) - verifyZeroInteractions(mockGesturesTracker) + // Then + verifyZeroInteractions(mockViewLoadingTimer) + } + + @Test + fun `𝕄 notify viewLoadingTimer π•Ž onActivityPaused() {fragment not tracked}`() { + // Given + whenever(mockPredicate.accept(mockFragment)) doReturn false + + // When + testedLifecycleCallbacks.onFragmentPaused(mockFragmentManager, mockFragment) + + // Then + verifyZeroInteractions(mockViewLoadingTimer) } @Test - fun `when fragment resumed it will start a view event`(forge: Forge) { + fun `𝕄 notify viewLoadingTimer π•Ž onActivityDestroyed() {fragment not tracked}`() { + // Given + whenever(mockPredicate.accept(mockFragment)) doReturn false + // When - testedLifecycleCallbacks.onFragmentResumed(mock(), mockFragment) + testedLifecycleCallbacks.onFragmentDestroyed(mockFragmentManager, mockFragment) + + // Then + verifyZeroInteractions(mockViewLoadingTimer) + } + + // endregion + + // region Track RUM View + + @Test + fun `𝕄 start a RUM View event π•Ž onFragmentResumed()`() { + // Given + whenever(mockPredicate.accept(mockFragment)) doReturn true + + // When + testedLifecycleCallbacks.onFragmentResumed(mockFragmentManager, mockFragment) + // Then verify(mockRumMonitor).startView( - eq(mockFragment), - eq(mockFragment.resolveViewName()), - eq(fakeAttributes) + mockFragment, + mockFragment.resolveViewName(), + fakeAttributes ) } @Test - fun `when fragment resumed, it will notify the timer and update the Rum event time`( - forge: Forge + fun `𝕄 start a RUM View event π•Ž onFragmentResumed() {custom view name}`( + @StringForgery fakeName: String ) { - val expectedLoadingTime = forge.aLong() - val firsTimeLoading = forge.aBool() - val expectedLoadingType = - if (firsTimeLoading) { - ViewEvent.LoadingType.FRAGMENT_DISPLAY - } else { - ViewEvent.LoadingType.FRAGMENT_REDISPLAY - } - whenever(mockViewLoadingTimer.getLoadingTime(mockFragment)) - .thenReturn(expectedLoadingTime) - whenever(mockViewLoadingTimer.isFirstTimeLoading(mockFragment)) - .thenReturn(firsTimeLoading) - testedLifecycleCallbacks.onFragmentResumed(mock(), mockFragment) + // Given + whenever(mockPredicate.accept(mockFragment)) doReturn true + whenever(mockPredicate.getViewName(mockFragment)) doReturn fakeName - verify(mockViewLoadingTimer).onFinishedLoading(mockFragment) - verify(mockAdvancedRumMonitor).updateViewLoadingTime( + // When + testedLifecycleCallbacks.onFragmentResumed(mockFragmentManager, mockFragment) + + // Then + verify(mockRumMonitor).startView( mockFragment, - expectedLoadingTime, - expectedLoadingType + fakeName, + fakeAttributes ) } @Test - fun `when fragment resumed will do nothing if the fragment is not whitelisted`() { + fun `𝕄 start a RUM View event π•Ž onFragmentResumed() {custom blank view name}`( + @StringForgery(StringForgeryType.WHITESPACE) fakeName: String + ) { // Given - testedLifecycleCallbacks = AndroidXFragmentLifecycleCallbacks( - { fakeAttributes }, - object : ComponentPredicate { - override fun accept(component: Fragment): Boolean { - return false - } - }, - rumMonitor = mockRumMonitor, - advancedRumMonitor = mockAdvancedRumMonitor - ) + whenever(mockPredicate.accept(mockFragment)) doReturn true + whenever(mockPredicate.getViewName(mockFragment)) doReturn fakeName // When - testedLifecycleCallbacks.onFragmentResumed(mock(), mockFragment) + testedLifecycleCallbacks.onFragmentResumed(mockFragmentManager, mockFragment) // Then - verifyZeroInteractions(mockViewLoadingTimer) - verifyZeroInteractions(mockRumMonitor) - verifyZeroInteractions(mockAdvancedRumMonitor) + verify(mockRumMonitor).startView( + mockFragment, + mockFragment.resolveViewName(), + fakeAttributes + ) } @Test - fun `when fragment paused it will mark the view as hidden in the timer`(forge: Forge) { + fun `𝕄 start RUM View and update loading time π•Ž onFragmentResumed()`( + @BoolForgery firstTimeLoading: Boolean, + @LongForgery(1L) loadingTime: Long + ) { + // Given + whenever(mockPredicate.accept(mockFragment)) doReturn true + val expectedLoadingType = if (firstTimeLoading) { + ViewEvent.LoadingType.FRAGMENT_DISPLAY + } else { + ViewEvent.LoadingType.FRAGMENT_REDISPLAY + } + whenever(mockViewLoadingTimer.getLoadingTime(mockFragment)) doReturn loadingTime + whenever(mockViewLoadingTimer.isFirstTimeLoading(mockFragment)) doReturn firstTimeLoading + // When - testedLifecycleCallbacks.onFragmentPaused(mock(), mockFragment) - // Then - verify(mockRumMonitor).stopView( - eq(mockFragment), - eq(emptyMap()) - ) + testedLifecycleCallbacks.onFragmentResumed(mockFragmentManager, mockFragment) - verify(mockViewLoadingTimer).onPaused(mockFragment) + // Then + inOrder(mockRumMonitor, mockAdvancedRumMonitor, mockViewLoadingTimer) { + verify(mockViewLoadingTimer).onFinishedLoading(mockFragment) + verify(mockRumMonitor).startView( + mockFragment, + mockFragment.resolveViewName(), + fakeAttributes + ) + verify(mockAdvancedRumMonitor).updateViewLoadingTime( + mockFragment, + loadingTime, + expectedLoadingType + ) + } } @Test - fun `when fragment paused it will stop a view event`(forge: Forge) { + fun `𝕄 stop RUM View π•Ž onActivityPaused()`( + @BoolForgery firstTimeLoading: Boolean, + @LongForgery(1L) loadingTime: Long + ) { + // Given + whenever(mockPredicate.accept(mockFragment)) doReturn true + // When - testedLifecycleCallbacks.onFragmentPaused(mock(), mockFragment) + testedLifecycleCallbacks.onFragmentPaused(mockFragmentManager, mockFragment) + // Then - verify(mockRumMonitor).stopView( - eq(mockFragment), - eq(emptyMap()) - ) + verify(mockRumMonitor).stopView(mockFragment, emptyMap()) } + // endregion + + // region Track RUM View (not tracked) + @Test - fun `when fragment paused will do nothing if the fragment is not whitelisted`() { + fun `𝕄 start a RUM View event π•Ž onFragmentResumed() {activity not tracked}`() { // Given - testedLifecycleCallbacks = AndroidXFragmentLifecycleCallbacks( - { fakeAttributes }, - object : ComponentPredicate { - override fun accept(component: Fragment): Boolean { - return false - } - }, - rumMonitor = mockRumMonitor, - advancedRumMonitor = mockAdvancedRumMonitor - ) + whenever(mockPredicate.accept(mockFragment)) doReturn false // When - testedLifecycleCallbacks.onFragmentPaused(mock(), mockFragment) + testedLifecycleCallbacks.onFragmentResumed(mockFragmentManager, mockFragment) // Then - verifyZeroInteractions(mockRumMonitor) - verifyZeroInteractions(mockAdvancedRumMonitor) - verifyZeroInteractions(mockViewLoadingTimer) + verifyZeroInteractions(mockRumMonitor, mockViewLoadingTimer) } @Test - fun `when fragment destroyed will remove view entry from timer`() { + fun `𝕄 start RUM View and update loadingTime π•Ž onFragmentResumed() {activity not tracked}`() { + // Given + whenever(mockPredicate.accept(mockFragment)) doReturn false + // When - testedLifecycleCallbacks.onFragmentDestroyed(mock(), mockFragment) + testedLifecycleCallbacks.onFragmentResumed(mockFragmentManager, mockFragment) // Then - verify(mockViewLoadingTimer).onDestroyed(mockFragment) + verifyZeroInteractions(mockRumMonitor, mockViewLoadingTimer) } @Test - fun `when fragment destroyed and not whitelisted will do nothing`() { + fun `𝕄 stop RUM View π•Ž onActivityPaused() {activity not tracked}`( + @BoolForgery firstTimeLoading: Boolean, + @LongForgery(1L) loadingTime: Long + ) { // Given - testedLifecycleCallbacks = AndroidXFragmentLifecycleCallbacks( - { fakeAttributes }, - object : ComponentPredicate { - override fun accept(component: Fragment): Boolean { - return false - } - }, - rumMonitor = mockRumMonitor, - advancedRumMonitor = mockAdvancedRumMonitor - ) + whenever(mockPredicate.accept(mockFragment)) doReturn false // When - testedLifecycleCallbacks.onFragmentDestroyed(mock(), mockFragment) + testedLifecycleCallbacks.onFragmentPaused(mockFragmentManager, mockFragment) // Then - verifyZeroInteractions(mockRumMonitor) - verifyZeroInteractions(mockAdvancedRumMonitor) - verifyZeroInteractions(mockViewLoadingTimer) + verifyZeroInteractions(mockRumMonitor, mockViewLoadingTimer) + } + + // endregion + + @Test + fun `when fragment activity created on DialogFragment, it will register a Window Callback`( + forge: Forge + ) { + val mockDialogFragment: DialogFragment = mock() + whenever(mockDialogFragment.context) doReturn mockContext + whenever(mockDialogFragment.dialog) doReturn mockDialog + whenever(mockDialog.window) doReturn mockWindow + + testedLifecycleCallbacks.onFragmentActivityCreated(mock(), mockDialogFragment, null) + + verify(mockGesturesTracker).startTracking(mockWindow, mockContext) + } + + @Test + fun `when fragment activity created on Fragment, registers nothing`(forge: Forge) { + whenever(mockFragment.context) doReturn mockContext + + testedLifecycleCallbacks.onFragmentActivityCreated(mock(), mockFragment, null) + + verifyZeroInteractions(mockGesturesTracker) } @Test diff --git a/dd-sdk-android/src/test/kotlin/com/datadog/android/rum/internal/tracking/OreoFragmentLifecycleCallbacksTest.kt b/dd-sdk-android/src/test/kotlin/com/datadog/android/rum/internal/tracking/OreoFragmentLifecycleCallbacksTest.kt index a157490bc5..d6e69774ec 100644 --- a/dd-sdk-android/src/test/kotlin/com/datadog/android/rum/internal/tracking/OreoFragmentLifecycleCallbacksTest.kt +++ b/dd-sdk-android/src/test/kotlin/com/datadog/android/rum/internal/tracking/OreoFragmentLifecycleCallbacksTest.kt @@ -21,17 +21,20 @@ import com.datadog.android.rum.internal.RumFeature import com.datadog.android.rum.internal.instrumentation.gestures.GesturesTracker import com.datadog.android.rum.internal.monitor.AdvancedRumMonitor import com.datadog.android.rum.model.ViewEvent -import com.datadog.android.rum.tracking.AcceptAllDefaultFragment import com.datadog.android.rum.tracking.ComponentPredicate import com.datadog.tools.unit.annotations.TestTargetApi import com.datadog.tools.unit.extensions.ApiLevelExtension import com.nhaarman.mockitokotlin2.doReturn -import com.nhaarman.mockitokotlin2.eq +import com.nhaarman.mockitokotlin2.inOrder import com.nhaarman.mockitokotlin2.mock import com.nhaarman.mockitokotlin2.verify import com.nhaarman.mockitokotlin2.verifyZeroInteractions import com.nhaarman.mockitokotlin2.whenever import fr.xgouchet.elmyr.Forge +import fr.xgouchet.elmyr.annotation.BoolForgery +import fr.xgouchet.elmyr.annotation.LongForgery +import fr.xgouchet.elmyr.annotation.StringForgery +import fr.xgouchet.elmyr.annotation.StringForgeryType import fr.xgouchet.elmyr.junit5.ForgeExtension import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach @@ -81,6 +84,9 @@ internal class OreoFragmentLifecycleCallbacksTest { @Mock lateinit var mockAdvancedRumMonitor: AdvancedRumMonitor + @Mock + lateinit var mockPredicate: ComponentPredicate + lateinit var fakeAttributes: Map @BeforeEach @@ -93,7 +99,7 @@ internal class OreoFragmentLifecycleCallbacksTest { fakeAttributes = forge.aMap { forge.aString() to forge.aString() } testedLifecycleCallbacks = OreoFragmentLifecycleCallbacks( { fakeAttributes }, - AcceptAllDefaultFragment(), + mockPredicate, viewLoadingTimer = mockViewLoadingTimer, rumMonitor = mockRumMonitor, advancedRumMonitor = mockAdvancedRumMonitor @@ -106,228 +112,313 @@ internal class OreoFragmentLifecycleCallbacksTest { GlobalRum.monitor = NoOpRumMonitor() } + // region Track View Loading Time + @Test - fun `when fragment attached, it will notify the timer`( - forge: Forge - ) { - testedLifecycleCallbacks.onFragmentAttached(mock(), mockFragment, mockActivity) + fun `𝕄 notify viewLoadingTimer π•Ž onFragmentAttached()`() { + // Given + whenever(mockPredicate.accept(mockFragment)) doReturn true + // When + testedLifecycleCallbacks.onFragmentAttached( + mockFragmentManager, + mockFragment, + mockActivity + ) + + // Then verify(mockViewLoadingTimer).onCreated(mockFragment) } @Test - fun `when fragment attached, and not whitelisted will not interact with timer`( - forge: Forge - ) { - testedLifecycleCallbacks = OreoFragmentLifecycleCallbacks( - { fakeAttributes }, - object : ComponentPredicate { - override fun accept(component: Fragment): Boolean { - return false - } - }, - viewLoadingTimer = mockViewLoadingTimer, - rumMonitor = mockRumMonitor, - advancedRumMonitor = mockAdvancedRumMonitor + fun `𝕄 notify viewLoadingTimer π•Ž onFragmentStarted()`() { + // Given + whenever(mockPredicate.accept(mockFragment)) doReturn true + + // When + testedLifecycleCallbacks.onFragmentStarted(mockFragmentManager, mockFragment) + + // Then + verify(mockViewLoadingTimer).onStartLoading(mockFragment) + } + + @Test + fun `𝕄 notify viewLoadingTimer π•Ž onFragmentResumed()`() { + // Given + whenever(mockPredicate.accept(mockFragment)) doReturn true + + // When + testedLifecycleCallbacks.onFragmentResumed(mockFragmentManager, mockFragment) + + // Then + verify(mockViewLoadingTimer).onFinishedLoading(mockFragment) + } + + @Test + fun `𝕄 notify viewLoadingTimer π•Ž onActivityPaused()`() { + // Given + whenever(mockPredicate.accept(mockFragment)) doReturn true + + // When + testedLifecycleCallbacks.onFragmentPaused(mockFragmentManager, mockFragment) + + // Then + verify(mockViewLoadingTimer).onPaused(mockFragment) + } + + @Test + fun `𝕄 notify viewLoadingTimer π•Ž onActivityDestroyed()`() { + // Given + whenever(mockPredicate.accept(mockFragment)) doReturn true + + // When + testedLifecycleCallbacks.onFragmentDestroyed(mockFragmentManager, mockFragment) + + // Then + verify(mockViewLoadingTimer).onDestroyed(mockFragment) + } + + // endregion + + // region Track View Loading Time (not tracked) + + @Test + fun `𝕄 notify viewLoadingTimer π•Ž onFragmentAttached() {fragment not tracked}`() { + // Given + whenever(mockPredicate.accept(mockFragment)) doReturn false + + // When + testedLifecycleCallbacks.onFragmentAttached( + mockFragmentManager, + mockFragment, + mockActivity ) - testedLifecycleCallbacks.onFragmentAttached(mock(), mockFragment, mockActivity) + // Then verifyZeroInteractions(mockViewLoadingTimer) } @Test - fun `when fragment started, it will notify the timer`( - forge: Forge - ) { - testedLifecycleCallbacks.onFragmentStarted(mock(), mockFragment) + fun `𝕄 notify viewLoadingTimer π•Ž onFragmentStarted() {fragment not tracked}`() { + // Given + whenever(mockPredicate.accept(mockFragment)) doReturn false - verify(mockViewLoadingTimer).onStartLoading(mockFragment) + // When + testedLifecycleCallbacks.onFragmentStarted(mockFragmentManager, mockFragment) + + // Then + verifyZeroInteractions(mockViewLoadingTimer) } @Test - fun `when fragment started, and not whitelisted will not interact with timer`( - forge: Forge - ) { - testedLifecycleCallbacks = OreoFragmentLifecycleCallbacks( - { fakeAttributes }, - object : ComponentPredicate { - override fun accept(component: Fragment): Boolean { - return false - } - }, - viewLoadingTimer = mockViewLoadingTimer, - rumMonitor = mockRumMonitor, - advancedRumMonitor = mockAdvancedRumMonitor - ) - testedLifecycleCallbacks.onFragmentStarted(mock(), mockFragment) + fun `𝕄 notify viewLoadingTimer π•Ž onFragmentResumed() {fragment not tracked}`() { + // Given + whenever(mockPredicate.accept(mockFragment)) doReturn false + + // When + testedLifecycleCallbacks.onFragmentResumed(mockFragmentManager, mockFragment) + // Then verifyZeroInteractions(mockViewLoadingTimer) } @Test - fun `when fragment activity created on DialogFragment, it will register a Window Callback`( - forge: Forge - ) { - val mockDialogFragment: DialogFragment = mock() - whenever(mockDialogFragment.context) doReturn mockActivity - whenever(mockDialogFragment.dialog) doReturn mockDialog - whenever(mockDialog.window) doReturn mockWindow + fun `𝕄 notify viewLoadingTimer π•Ž onActivityPaused() {fragment not tracked}`() { + // Given + whenever(mockPredicate.accept(mockFragment)) doReturn false - testedLifecycleCallbacks.onFragmentActivityCreated(mock(), mockDialogFragment, null) + // When + testedLifecycleCallbacks.onFragmentPaused(mockFragmentManager, mockFragment) - verify(mockGesturesTracker).startTracking(mockWindow, mockActivity) + // Then + verifyZeroInteractions(mockViewLoadingTimer) } @Test - fun `when fragment activity created on Fragment, registers nothing`(forge: Forge) { - whenever(mockFragment.context) doReturn mockActivity + fun `𝕄 notify viewLoadingTimer π•Ž onActivityDestroyed() {fragment not tracked}`() { + // Given + whenever(mockPredicate.accept(mockFragment)) doReturn false - testedLifecycleCallbacks.onFragmentActivityCreated(mock(), mockFragment, null) + // When + testedLifecycleCallbacks.onFragmentDestroyed(mockFragmentManager, mockFragment) - verifyZeroInteractions(mockGesturesTracker) + // Then + verifyZeroInteractions(mockViewLoadingTimer) } + // endregion + + // region Track RUM View + @Test - fun `when fragment resumed it will start a view event`(forge: Forge) { + fun `𝕄 start a RUM View event π•Ž onFragmentResumed()`() { + // Given + whenever(mockPredicate.accept(mockFragment)) doReturn true + // When - testedLifecycleCallbacks.onFragmentResumed(mock(), mockFragment) + testedLifecycleCallbacks.onFragmentResumed(mockFragmentManager, mockFragment) + // Then verify(mockRumMonitor).startView( - eq(mockFragment), - eq(mockFragment.resolveViewName()), - eq(fakeAttributes) + mockFragment, + mockFragment.resolveViewName(), + fakeAttributes ) } @Test - fun `when fragment resumed, it will notify the timer and update the Rum event time`( - forge: Forge + fun `𝕄 start a RUM View event π•Ž onFragmentResumed() {custom view name}`( + @StringForgery fakeName: String ) { - val expectedLoadingTime = forge.aLong() - val firsTimeLoading = forge.aBool() - val expectedLoadingType = - if (firsTimeLoading) { - ViewEvent.LoadingType.FRAGMENT_DISPLAY - } else { - ViewEvent.LoadingType.FRAGMENT_REDISPLAY - } - whenever(mockViewLoadingTimer.getLoadingTime(mockFragment)) - .thenReturn(expectedLoadingTime) - whenever(mockViewLoadingTimer.isFirstTimeLoading(mockFragment)) - .thenReturn(firsTimeLoading) - testedLifecycleCallbacks.onFragmentResumed(mock(), mockFragment) + // Given + whenever(mockPredicate.accept(mockFragment)) doReturn true + whenever(mockPredicate.getViewName(mockFragment)) doReturn fakeName - verify(mockViewLoadingTimer).onFinishedLoading(mockFragment) - verify(mockAdvancedRumMonitor).updateViewLoadingTime( + // When + testedLifecycleCallbacks.onFragmentResumed(mockFragmentManager, mockFragment) + + // Then + verify(mockRumMonitor).startView( mockFragment, - expectedLoadingTime, - expectedLoadingType + fakeName, + fakeAttributes ) } @Test - fun `when fragment resumed will do nothing if the fragment is not whitelisted`() { + fun `𝕄 start a RUM View event π•Ž onFragmentResumed() {custom blank view name}`( + @StringForgery(StringForgeryType.WHITESPACE) fakeName: String + ) { // Given - testedLifecycleCallbacks = OreoFragmentLifecycleCallbacks( - { fakeAttributes }, - object : ComponentPredicate { - override fun accept(component: Fragment): Boolean { - return false - } - }, - viewLoadingTimer = mockViewLoadingTimer, - rumMonitor = mockRumMonitor, - advancedRumMonitor = mockAdvancedRumMonitor - ) + whenever(mockPredicate.accept(mockFragment)) doReturn true + whenever(mockPredicate.getViewName(mockFragment)) doReturn fakeName // When - testedLifecycleCallbacks.onFragmentResumed(mock(), mockFragment) + testedLifecycleCallbacks.onFragmentResumed(mockFragmentManager, mockFragment) // Then - verifyZeroInteractions(mockRumMonitor) - verifyZeroInteractions(mockAdvancedRumMonitor) - verifyZeroInteractions(mockViewLoadingTimer) + verify(mockRumMonitor).startView( + mockFragment, + mockFragment.resolveViewName(), + fakeAttributes + ) } @Test - fun `when fragment paused it will mark the view as hidden in the timer`(forge: Forge) { + fun `𝕄 start RUM View and update loading time π•Ž onFragmentResumed()`( + @BoolForgery firstTimeLoading: Boolean, + @LongForgery(1L) loadingTime: Long + ) { + // Given + whenever(mockPredicate.accept(mockFragment)) doReturn true + val expectedLoadingType = if (firstTimeLoading) { + ViewEvent.LoadingType.FRAGMENT_DISPLAY + } else { + ViewEvent.LoadingType.FRAGMENT_REDISPLAY + } + whenever(mockViewLoadingTimer.getLoadingTime(mockFragment)) doReturn loadingTime + whenever(mockViewLoadingTimer.isFirstTimeLoading(mockFragment)) doReturn firstTimeLoading + // When - testedLifecycleCallbacks.onFragmentPaused(mock(), mockFragment) - // Then - verify(mockRumMonitor).stopView( - eq(mockFragment), - eq(emptyMap()) - ) + testedLifecycleCallbacks.onFragmentResumed(mockFragmentManager, mockFragment) - verify(mockViewLoadingTimer).onPaused(mockFragment) + // Then + inOrder(mockRumMonitor, mockAdvancedRumMonitor, mockViewLoadingTimer) { + verify(mockViewLoadingTimer).onFinishedLoading(mockFragment) + verify(mockRumMonitor).startView( + mockFragment, + mockFragment.resolveViewName(), + fakeAttributes + ) + verify(mockAdvancedRumMonitor).updateViewLoadingTime( + mockFragment, + loadingTime, + expectedLoadingType + ) + } } @Test - fun `when fragment paused it will stop a view event`(forge: Forge) { + fun `𝕄 stop RUM View π•Ž onActivityPaused()`( + @BoolForgery firstTimeLoading: Boolean, + @LongForgery(1L) loadingTime: Long + ) { + // Given + whenever(mockPredicate.accept(mockFragment)) doReturn true + // When - testedLifecycleCallbacks.onFragmentPaused(mock(), mockFragment) + testedLifecycleCallbacks.onFragmentPaused(mockFragmentManager, mockFragment) + // Then - verify(mockRumMonitor).stopView( - eq(mockFragment), - eq(emptyMap()) - ) + verify(mockRumMonitor).stopView(mockFragment, emptyMap()) } + // endregion + + // region Track RUM View (not tracked) + @Test - fun `when fragment paused will do nothing if the fragment is not whitelisted`() { + fun `𝕄 start a RUM View event π•Ž onFragmentResumed() {activity not tracked}`() { // Given - testedLifecycleCallbacks = OreoFragmentLifecycleCallbacks( - { fakeAttributes }, - object : ComponentPredicate { - override fun accept(component: Fragment): Boolean { - return false - } - }, - viewLoadingTimer = mockViewLoadingTimer, - rumMonitor = mockRumMonitor, - advancedRumMonitor = mockAdvancedRumMonitor - ) + whenever(mockPredicate.accept(mockFragment)) doReturn false // When - testedLifecycleCallbacks.onFragmentPaused(mock(), mockFragment) + testedLifecycleCallbacks.onFragmentResumed(mockFragmentManager, mockFragment) // Then - verifyZeroInteractions(mockRumMonitor) - verifyZeroInteractions(mockAdvancedRumMonitor) - verifyZeroInteractions(mockViewLoadingTimer) + verifyZeroInteractions(mockRumMonitor, mockViewLoadingTimer) } @Test - fun `when fragment destroyed will remove view entry from timer`() { + fun `𝕄 start RUM View and update loadingTime π•Ž onFragmentResumed() {activity not tracked}`() { + // Given + whenever(mockPredicate.accept(mockFragment)) doReturn false + // When - testedLifecycleCallbacks.onFragmentDestroyed(mock(), mockFragment) + testedLifecycleCallbacks.onFragmentResumed(mockFragmentManager, mockFragment) // Then - verify(mockViewLoadingTimer).onDestroyed(mockFragment) + verifyZeroInteractions(mockRumMonitor, mockViewLoadingTimer) } @Test - fun `when fragment destroyed and not whitelisted will do nothing`() { + fun `𝕄 stop RUM View π•Ž onActivityPaused() {activity not tracked}`( + @BoolForgery firstTimeLoading: Boolean, + @LongForgery(1L) loadingTime: Long + ) { // Given - testedLifecycleCallbacks = OreoFragmentLifecycleCallbacks( - { fakeAttributes }, - object : ComponentPredicate { - override fun accept(component: Fragment): Boolean { - return false - } - }, - viewLoadingTimer = mockViewLoadingTimer, - rumMonitor = mockRumMonitor, - advancedRumMonitor = mockAdvancedRumMonitor - ) + whenever(mockPredicate.accept(mockFragment)) doReturn false // When - testedLifecycleCallbacks.onFragmentDestroyed(mock(), mockFragment) + testedLifecycleCallbacks.onFragmentPaused(mockFragmentManager, mockFragment) // Then - verifyZeroInteractions(mockRumMonitor) - verifyZeroInteractions(mockAdvancedRumMonitor) - verifyZeroInteractions(mockViewLoadingTimer) + verifyZeroInteractions(mockRumMonitor, mockViewLoadingTimer) + } + + // endregion + + @Test + fun `when fragment activity created on DialogFragment, it will register a Window Callback`( + forge: Forge + ) { + val mockDialogFragment: DialogFragment = mock() + whenever(mockDialogFragment.context) doReturn mockActivity + whenever(mockDialogFragment.dialog) doReturn mockDialog + whenever(mockDialog.window) doReturn mockWindow + + testedLifecycleCallbacks.onFragmentActivityCreated(mock(), mockDialogFragment, null) + + verify(mockGesturesTracker).startTracking(mockWindow, mockActivity) + } + + @Test + fun `when fragment activity created on Fragment, registers nothing`(forge: Forge) { + whenever(mockFragment.context) doReturn mockActivity + + testedLifecycleCallbacks.onFragmentActivityCreated(mock(), mockFragment, null) + + verifyZeroInteractions(mockGesturesTracker) } @Test diff --git a/dd-sdk-android/src/test/kotlin/com/datadog/android/rum/tracking/ComponentPredicateTest.kt b/dd-sdk-android/src/test/kotlin/com/datadog/android/rum/tracking/ComponentPredicateTest.kt index eb3d931da2..d486dce90d 100644 --- a/dd-sdk-android/src/test/kotlin/com/datadog/android/rum/tracking/ComponentPredicateTest.kt +++ b/dd-sdk-android/src/test/kotlin/com/datadog/android/rum/tracking/ComponentPredicateTest.kt @@ -38,6 +38,10 @@ class ComponentPredicateTest { override fun accept(component: Activity): Boolean { return component == mockValidActivity } + + override fun getViewName(component: Activity): String? { + return null + } } } diff --git a/dd-sdk-android/src/test/kotlin/com/datadog/android/rum/tracking/FragmentViewTrackingStrategyTest.kt b/dd-sdk-android/src/test/kotlin/com/datadog/android/rum/tracking/FragmentViewTrackingStrategyTest.kt index b5cf0adead..a783ca3c4e 100644 --- a/dd-sdk-android/src/test/kotlin/com/datadog/android/rum/tracking/FragmentViewTrackingStrategyTest.kt +++ b/dd-sdk-android/src/test/kotlin/com/datadog/android/rum/tracking/FragmentViewTrackingStrategyTest.kt @@ -49,6 +49,7 @@ import org.mockito.quality.Strictness ExtendWith(ApiLevelExtension::class) ) @MockitoSettings(strictness = Strictness.LENIENT) +@Suppress("DEPRECATION") internal class FragmentViewTrackingStrategyTest { lateinit var testedStrategy: FragmentViewTrackingStrategy @@ -178,6 +179,10 @@ internal class FragmentViewTrackingStrategyTest { override fun accept(component: Fragment): Boolean { return false } + + override fun getViewName(component: Fragment): String? { + return null + } } ) @@ -355,6 +360,10 @@ internal class FragmentViewTrackingStrategyTest { override fun accept(component: android.app.Fragment): Boolean { return false } + + override fun getViewName(component: android.app.Fragment): String? { + return null + } } ) val argumentCaptor = diff --git a/dd-sdk-android/src/test/kotlin/com/datadog/android/tracing/TracingInterceptorNotSendingSpanTest.kt b/dd-sdk-android/src/test/kotlin/com/datadog/android/tracing/TracingInterceptorNotSendingSpanTest.kt index 94bdc1f86d..bd20c913e2 100644 --- a/dd-sdk-android/src/test/kotlin/com/datadog/android/tracing/TracingInterceptorNotSendingSpanTest.kt +++ b/dd-sdk-android/src/test/kotlin/com/datadog/android/tracing/TracingInterceptorNotSendingSpanTest.kt @@ -579,6 +579,19 @@ internal open class TracingInterceptorNotSendingSpanTest { verify(mockSpan, never()).finish() } + @Test + fun `M do not update the hostDetector W host list provided`(forge: Forge) { + // GIVEN + val localHosts = + forge.aList { forge.aStringMatching(TracingInterceptorTest.HOSTNAME_PATTERN) } + + // WHEN + testedInterceptor = instantiateTestedInterceptor(localHosts) { mockLocalTracer } + + // THEN + verify(mockDetector, never()).addKnownHosts(localHosts) + } + @Test fun `𝕄 do nothing π•Ž intercept() for request with unknown host`( @IntForgery(min = 200, max = 300) statusCode: Int, diff --git a/dd-sdk-android/src/test/kotlin/com/datadog/android/tracing/TracingInterceptorTest.kt b/dd-sdk-android/src/test/kotlin/com/datadog/android/tracing/TracingInterceptorTest.kt index eba03631b5..3906115fbb 100644 --- a/dd-sdk-android/src/test/kotlin/com/datadog/android/tracing/TracingInterceptorTest.kt +++ b/dd-sdk-android/src/test/kotlin/com/datadog/android/tracing/TracingInterceptorTest.kt @@ -570,15 +570,15 @@ internal open class TracingInterceptorTest { } @Test - fun `M update the hostDetector W legacy host list provided`(forge: Forge) { + fun `M do not update the hostDetector W host list provided`(forge: Forge) { // GIVEN - val legacyHostList = forge.aList { forge.aStringMatching(HOSTNAME_PATTERN) } + val localHosts = forge.aList { forge.aStringMatching(HOSTNAME_PATTERN) } // WHEN - testedInterceptor = instantiateTestedInterceptor(legacyHostList) { mockLocalTracer } + testedInterceptor = instantiateTestedInterceptor(localHosts) { mockLocalTracer } // THEN - verify(mockDetector).addKnownHosts(legacyHostList) + verify(mockDetector, never()).addKnownHosts(localHosts) } @Test diff --git a/dd-sdk-android/src/test/kotlin/com/datadog/android/tracing/internal/AndroidTracerTest.kt b/dd-sdk-android/src/test/kotlin/com/datadog/android/tracing/internal/AndroidTracerTest.kt index 12770a9408..310a3f5e57 100644 --- a/dd-sdk-android/src/test/kotlin/com/datadog/android/tracing/internal/AndroidTracerTest.kt +++ b/dd-sdk-android/src/test/kotlin/com/datadog/android/tracing/internal/AndroidTracerTest.kt @@ -7,16 +7,20 @@ package com.datadog.android.tracing.internal import android.app.Application +import android.util.Log import com.datadog.android.Datadog +import com.datadog.android.core.configuration.Configuration import com.datadog.android.core.internal.CoreFeature import com.datadog.android.log.LogAttributes import com.datadog.android.rum.GlobalRum import com.datadog.android.rum.RumMonitor +import com.datadog.android.rum.internal.RumFeature import com.datadog.android.rum.internal.domain.RumContext import com.datadog.android.tracing.AndroidTracer import com.datadog.android.utils.forge.Configurator import com.datadog.android.utils.mockContext import com.datadog.android.utils.mockCoreFeature +import com.datadog.android.utils.mockDevLogHandler import com.datadog.opentracing.DDSpan import com.datadog.opentracing.LogHandler import com.datadog.opentracing.scopemanager.ContextualScopeManager @@ -73,13 +77,18 @@ internal class AndroidTracerTest { @Mock lateinit var mockLogsHandler: LogHandler + lateinit var mockDevLogsHandler: com.datadog.android.log.internal.logger.LogHandler + @BeforeEach fun `set up`(forge: Forge) { + mockDevLogsHandler = mockDevLogHandler() fakeServiceName = forge.anAlphabeticalString() fakeEnvName = forge.anAlphabeticalString() fakeToken = forge.anHexadecimalString() mockAppContext = mockContext() mockCoreFeature() + TracesFeature.initialize(mockAppContext, Configuration.DEFAULT_TRACING_CONFIG) + RumFeature.initialize(mockAppContext, Configuration.DEFAULT_RUM_CONFIG) testedTracerBuilder = AndroidTracer.Builder() testedTracerBuilder.setFieldValue("logsHandler", mockLogsHandler) } @@ -101,6 +110,42 @@ internal class AndroidTracerTest { // region Tracer + @Test + fun `M log a developer error W buildTracer { TracingFeature not enabled }`( + @StringForgery(type = StringForgeryType.ALPHA_NUMERICAL) operationName: String, + @LongForgery seed: Long + ) { + // GIVEN + TracesFeature.stop() + + // WHEN + testedTracerBuilder.build() + + // THEN + verify(mockDevLogsHandler).handleLog( + Log.ERROR, + AndroidTracer.TRACING_NOT_ENABLED_ERROR_MESSAGE + ) + } + + @Test + fun `M log a developer error W buildTracer { RumFeature not enabled and bundleWithRum true }`( + @StringForgery(type = StringForgeryType.ALPHA_NUMERICAL) operationName: String, + @LongForgery seed: Long + ) { + // GIVEN + RumFeature.stop() + + // WHEN + testedTracerBuilder.build() + + // THEN + verify(mockDevLogsHandler).handleLog( + Log.ERROR, + AndroidTracer.RUM_NOT_ENABLED_ERROR_MESSAGE + ) + } + @Test fun `buildSpan will inject a parent context`( @StringForgery(type = StringForgeryType.ALPHA_NUMERICAL) operationName: String, @@ -149,7 +194,7 @@ internal class AndroidTracerTest { } @Test - fun `buildSpan will inject RumContext if RumMonitor is Set`( + fun `M inject RumContext W buildSpan { bundleWithRum enabled and RumFeature initialized }`( @StringForgery(type = StringForgeryType.ALPHA_NUMERICAL) operationName: String, forge: Forge ) { @@ -175,6 +220,56 @@ internal class AndroidTracerTest { } } + @Test + fun `M not inject RumContext W buildSpan { RumFeature not initialized }`( + forge: Forge, + @StringForgery(type = StringForgeryType.ALPHA_NUMERICAL) operationName: String, + @LongForgery seed: Long + ) { + // GIVEN + val rumContext = forge.getForgery() + GlobalRum.registerIfAbsent(mock()) + GlobalRum.updateRumContext(rumContext) + RumFeature.stop() + val tracer = AndroidTracer.Builder() + .build() + + // WHEN + val span = tracer.buildSpan(operationName).start() as DDSpan + + // THEN + val meta = span.meta + assertThat(meta[LogAttributes.RUM_APPLICATION_ID]) + .isNull() + assertThat(meta[LogAttributes.RUM_SESSION_ID]) + .isNull() + } + + @Test + fun `M not inject RumContext W buildSpan { bundleWithRum disabled }`( + forge: Forge, + @StringForgery(type = StringForgeryType.ALPHA_NUMERICAL) operationName: String, + @LongForgery seed: Long + ) { + // GIVEN + val rumContext = forge.getForgery() + GlobalRum.registerIfAbsent(mock()) + GlobalRum.updateRumContext(rumContext) + val tracer = AndroidTracer.Builder() + .setBundleWithRumEnabled(false) + .build() + + // WHEN + val span = tracer.buildSpan(operationName).start() as DDSpan + + // THEN + val meta = span.meta + assertThat(meta[LogAttributes.RUM_APPLICATION_ID]) + .isNull() + assertThat(meta[LogAttributes.RUM_SESSION_ID]) + .isNull() + } + @Test fun `it will build a valid Tracer`(forge: Forge) { // Given diff --git a/dd-sdk-android/src/test/kotlin/com/datadog/android/tracing/internal/data/TraceWriterTest.kt b/dd-sdk-android/src/test/kotlin/com/datadog/android/tracing/internal/data/TraceWriterTest.kt index 4384bf3566..d8bf3bf791 100644 --- a/dd-sdk-android/src/test/kotlin/com/datadog/android/tracing/internal/data/TraceWriterTest.kt +++ b/dd-sdk-android/src/test/kotlin/com/datadog/android/tracing/internal/data/TraceWriterTest.kt @@ -8,14 +8,11 @@ package com.datadog.android.tracing.internal.data import com.datadog.android.core.internal.data.Writer import com.datadog.android.rum.GlobalRum -import com.datadog.android.rum.RumErrorSource import com.datadog.android.rum.RumMonitor import com.datadog.android.rum.internal.monitor.AdvancedRumMonitor import com.datadog.android.utils.forge.Configurator import com.datadog.opentracing.DDSpan import com.datadog.trace.api.DDTags -import com.nhaarman.mockitokotlin2.argumentCaptor -import com.nhaarman.mockitokotlin2.eq import com.nhaarman.mockitokotlin2.mock import com.nhaarman.mockitokotlin2.times import com.nhaarman.mockitokotlin2.verify @@ -23,8 +20,6 @@ import com.nhaarman.mockitokotlin2.verifyZeroInteractions import fr.xgouchet.elmyr.Forge import fr.xgouchet.elmyr.junit5.ForgeConfiguration import fr.xgouchet.elmyr.junit5.ForgeExtension -import java.util.Locale -import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test @@ -85,130 +80,23 @@ internal class TraceWriterTest { } @Test - fun `M send a RUM Error event W onWritingErrorSpan(`(forge: Forge) { + fun `M not send a RUM Error event W onWritingErrorSpan(`(forge: Forge) { // GIVEN val spansList = ArrayList(2).apply { add(forgeErrorSpan(forge)) - add(forgeErrorSpan(forge)) - } - - // WHEN - testedWriter.write(spansList) - - // THEN - val errorMessagesCaptor = argumentCaptor() - val stackTracesCaptor = argumentCaptor() - verify(mockAdvancedRumMonitor, times(2)).addErrorWithStacktrace( - errorMessagesCaptor.capture(), - eq(RumErrorSource.SOURCE), - stackTracesCaptor.capture(), - eq(emptyMap()) - ) - errorMessagesCaptor.allValues.forEachIndexed { index, message -> - assertThat(message).isEqualTo( - TraceWriter.SPAN_ERROR_WITH_TYPE_AND_MESSAGE_FORMAT.format( - Locale.US, - spansList[index].operationName, - spansList[index].tags[DDTags.ERROR_TYPE].toString(), - spansList[index].tags[DDTags.ERROR_MSG].toString() - ) - ) - } - stackTracesCaptor.allValues.forEachIndexed { index, stacktrace -> - assertThat(stacktrace).isEqualTo(spansList[index].tags[DDTags.ERROR_STACK].toString()) - } - spansList.forEach { - it.finish() - } - } - - @Test - fun `M send a RUM Error event W onWritingErrorSpan {no error type}(`(forge: Forge) { - // GIVEN - val spansList = ArrayList(2).apply { - add( - forgeErrorSpan(forge).apply { - this.context().setTag(DDTags.ERROR_TYPE, null) - } - ) - add( - forgeErrorSpan(forge).apply { - this.context().setTag(DDTags.ERROR_TYPE, null) - } - ) - } - - // WHEN - testedWriter.write(spansList) - - // THEN - val errorMessagesCaptor = argumentCaptor() - val stackTracesCaptor = argumentCaptor() - verify(mockAdvancedRumMonitor, times(2)).addErrorWithStacktrace( - errorMessagesCaptor.capture(), - eq(RumErrorSource.SOURCE), - stackTracesCaptor.capture(), - eq(emptyMap()) - ) - errorMessagesCaptor.allValues.forEachIndexed { index, message -> - assertThat(message).isEqualTo( - TraceWriter.SPAN_ERROR_WITH_MESSAGE_FORMAT.format( - Locale.US, - spansList[index].operationName, - spansList[index].tags[DDTags.ERROR_MSG].toString() - ) - ) - } - - stackTracesCaptor.allValues.forEachIndexed { index, stacktrace -> - assertThat(stacktrace).isEqualTo(spansList[index].tags[DDTags.ERROR_STACK].toString()) - } - spansList.forEach { - it.finish() - } - } - - @Test - fun `M send a RUM Error event W onWritingErrorSpan {no error message}(`(forge: Forge) { - // GIVEN - val spansList = ArrayList(2).apply { - add( - forgeErrorSpan(forge).apply { - this.context().setTag(DDTags.ERROR_MSG, null) - } - ) - add( - forgeErrorSpan(forge).apply { - this.context().setTag(DDTags.ERROR_MSG, null) - } - ) + forgeErrorSpan(forge).apply { + this.context().setTag(DDTags.ERROR_TYPE, null) + } + forgeErrorSpan(forge).apply { + this.context().setTag(DDTags.ERROR_MSG, null) + } } // WHEN testedWriter.write(spansList) // THEN - val errorMessagesCaptor = argumentCaptor() - val stackTracesCaptor = argumentCaptor() - verify(mockAdvancedRumMonitor, times(2)).addErrorWithStacktrace( - errorMessagesCaptor.capture(), - eq(RumErrorSource.SOURCE), - stackTracesCaptor.capture(), - eq(emptyMap()) - ) - errorMessagesCaptor.allValues.forEachIndexed { index, message -> - assertThat(message).isEqualTo( - TraceWriter.SPAN_ERROR_WITH_TYPE_FORMAT.format( - Locale.US, - spansList[index].operationName, - spansList[index].tags[DDTags.ERROR_TYPE].toString() - ) - ) - } - - stackTracesCaptor.allValues.forEachIndexed { index, stacktrace -> - assertThat(stacktrace).isEqualTo(spansList[index].tags[DDTags.ERROR_STACK].toString()) - } + verifyZeroInteractions(mockAdvancedRumMonitor) spansList.forEach { it.finish() } diff --git a/dd-sdk-android/src/test/kotlin/com/datadog/android/tracing/internal/domain/TracingFileStrategyTest.kt b/dd-sdk-android/src/test/kotlin/com/datadog/android/tracing/internal/domain/TracingFileStrategyTest.kt index 02dffa8c05..a186af4e06 100644 --- a/dd-sdk-android/src/test/kotlin/com/datadog/android/tracing/internal/domain/TracingFileStrategyTest.kt +++ b/dd-sdk-android/src/test/kotlin/com/datadog/android/tracing/internal/domain/TracingFileStrategyTest.kt @@ -7,6 +7,7 @@ package com.datadog.android.tracing.internal.domain import android.content.Context +import com.datadog.android.core.internal.data.file.ImmediateFileWriter import com.datadog.android.core.internal.domain.FilePersistenceConfig import com.datadog.android.core.internal.domain.assertj.PersistenceStrategyAssert import com.datadog.android.core.internal.net.info.NetworkInfoProvider @@ -18,6 +19,7 @@ import com.datadog.android.utils.forge.Configurator import com.datadog.android.utils.mockContext import com.nhaarman.mockitokotlin2.doReturn import com.nhaarman.mockitokotlin2.whenever +import fr.xgouchet.elmyr.Forge import fr.xgouchet.elmyr.annotation.Forgery import fr.xgouchet.elmyr.annotation.StringForgery import fr.xgouchet.elmyr.annotation.StringForgeryType @@ -104,4 +106,29 @@ internal class TracingFileStrategyTest { .usesConsentAwareAsyncWriter() .hasConfig(fakePersistenceConfig) } + + @Test + fun `M use the DefaultFileWriter factory W instantiating the writer`(forge: Forge) { + // GIVEN + // just to avoid using the NoOpWriter factory + whenever(mockConsentProvider.getConsent()) doReturn forge.aValueFrom( + TrackingConsent::class.java, + exclude = listOf(TrackingConsent.NOT_GRANTED) + ) + testedStrategy = TracingFileStrategy( + mockedContext, + timeProvider = mockedTimeProvider, + networkInfoProvider = mockedNetworkInfoProvider, + userInfoProvider = mockedUserInfoProvider, + envName = fakeEnvName, + dataPersistenceExecutorService = mockExecutorService, + trackingConsentProvider = mockConsentProvider, + filePersistenceConfig = fakePersistenceConfig + ) + + // THEN + PersistenceStrategyAssert + .assertThat(testedStrategy) + .hasFileInternalWriterInstanceOf(ImmediateFileWriter::class.java) + } } diff --git a/dd-sdk-android/src/test/kotlin/com/datadog/android/utils/forge/Configurator.kt b/dd-sdk-android/src/test/kotlin/com/datadog/android/utils/forge/Configurator.kt index 76cfbe6089..26fa4ea714 100644 --- a/dd-sdk-android/src/test/kotlin/com/datadog/android/utils/forge/Configurator.kt +++ b/dd-sdk-android/src/test/kotlin/com/datadog/android/utils/forge/Configurator.kt @@ -50,6 +50,9 @@ internal class Configurator : forge.addFactory(MotionEventForgeryFactory()) forge.addFactory(RumEventMapperFactory()) + // NDK Crash + forge.addFactory(NdkCrashLogForgeryFactory()) + // MISC forge.addFactory(BigIntegerFactory()) forge.addFactory(JsonArrayForgeryFactory()) diff --git a/dd-sdk-android/src/test/kotlin/com/datadog/android/utils/forge/NdkCrashLogForgeryFactory.kt b/dd-sdk-android/src/test/kotlin/com/datadog/android/utils/forge/NdkCrashLogForgeryFactory.kt new file mode 100644 index 0000000000..c0e921c682 --- /dev/null +++ b/dd-sdk-android/src/test/kotlin/com/datadog/android/utils/forge/NdkCrashLogForgeryFactory.kt @@ -0,0 +1,24 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.datadog.android.utils.forge + +import com.datadog.android.rum.internal.ndk.NdkCrashLog +import fr.xgouchet.elmyr.Forge +import fr.xgouchet.elmyr.ForgeryFactory + +internal class NdkCrashLogForgeryFactory : + ForgeryFactory { + override fun getForgery(forge: Forge): NdkCrashLog { + return NdkCrashLog( + forge.anInt(min = 1), + System.currentTimeMillis(), + forge.anAlphabeticalString(), + forge.anAlphabeticalString(), + forge.anAlphabeticalString() + ) + } +} diff --git a/dd-sdk-android/src/test/kotlin/com/datadog/android/utils/forge/ViewEventForgeryFactory.kt b/dd-sdk-android/src/test/kotlin/com/datadog/android/utils/forge/ViewEventForgeryFactory.kt index 54418ec5a7..7c2dcd8a98 100644 --- a/dd-sdk-android/src/test/kotlin/com/datadog/android/utils/forge/ViewEventForgeryFactory.kt +++ b/dd-sdk-android/src/test/kotlin/com/datadog/android/utils/forge/ViewEventForgeryFactory.kt @@ -32,7 +32,12 @@ internal class ViewEventForgeryFactory : ForgeryFactory { domInteractive = forge.aNullable { aPositiveLong() }, domContentLoaded = forge.aNullable { aPositiveLong() }, domComplete = forge.aNullable { aPositiveLong() }, - loadEvent = forge.aNullable { aPositiveLong() } + loadEvent = forge.aNullable { aPositiveLong() }, + customTimings = forge.aNullable { + ViewEvent.CustomTimings( + aMap { anAlphabeticalString() to aLong() } + ) + } ), usr = ViewEvent.Usr( id = forge.anHexadecimalString(), diff --git a/dogfood.py b/dogfood.py new file mode 100755 index 0000000000..f99f6e6638 --- /dev/null +++ b/dogfood.py @@ -0,0 +1,115 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +# Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. +# This product includes software developed at Datadog (https://www.datadoghq.com/). +# Copyright 2019-Present Datadog, Inc + + +import subprocess +import sys +import os +from argparse import ArgumentParser, Namespace +from tempfile import TemporaryDirectory +from typing import Tuple + +import requests +from git import Repo + +REPO_ANDROID = "datadog-android" + + +def parse_arguments(args: list) -> Namespace: + parser = ArgumentParser() + + parser.add_argument("-v", "--version", required=True, help="the version of the SDK") + + return parser.parse_args(args) + + +def github_create_pr(repository: str, branch_name: str, base_name: str, version: str, gh_token: str) -> int: + headers = { + 'authorization': "Bearer " + gh_token, + 'Accept': 'application/vnd.github.v3+json', + } + data = '{"body": "This PR has been created automatically by the CI", ' \ + '"title": "Update to version ' + version + '", ' \ + '"base":"' + base_name + '", "head":"' + branch_name + '"}' + + url = "https://api.github.com/repos/DataDog/" + repository + "/pulls" + response = requests.post(url=url, headers=headers, data=data) + if response.status_code == 201: + print("βœ” Pull Request created successfully") + return 0 + else: + print("✘ pull request failed " + str(response.status_code) + '\n' + response.text) + return 1 + + +def generate_target_code(temp_dir_path: str, version: str): + print("… Generating code with version " + version) + target_file = os.path.join(temp_dir_path, "buildSrc", "src", "main", "java", "config", "dependency", "android", + "Datadog.kt") + + with open(target_file, 'w') as target: + target.write("package config.dependency.android\n\n") + target.write("import config.dependency.Dependency\n\n") + target.write("object Datadog : Dependency {\n\n") + target.write(" override val group = \"com.datadoghq\"\n") + target.write(" override val artifact = \"dd-sdk-android\"\n") + target.write(" override val version = \"" + version + "\"\n") + target.write("}\n") + + +def git_clone_repository(repo_name: str, gh_token: str, temp_dir_path: str) -> Tuple[Repo, str]: + print("… Cloning repository " + REPO_ANDROID) + url = "https://" + gh_token + ":x-oauth-basic@github.com/DataDog/" + repo_name + repo = Repo.clone_from(url, temp_dir_path) + base_name = repo.active_branch.name + return repo, base_name + + +def git_push_changes(repo: Repo, version: str): + print("… Committing changes") + repo.git.add(update=True) + repo.index.commit("Update DD SDK to " + version) + + print("β‘Š Pushing branch") + origin = repo.remote(name="origin") + repo.git.push("--set-upstream", "--force", origin, repo.head.ref) + + +def update_dependant(version: str, gh_token: str) -> int: + branch_name = "update_sdk_" + version + temp_dir = TemporaryDirectory() + temp_dir_path = temp_dir.name + + repo, base_name = git_clone_repository(REPO_ANDROID, gh_token, temp_dir_path) + + print("… Creating branch " + branch_name) + repo.git.checkout('HEAD', b=branch_name) + + generate_target_code(temp_dir_path, version) + + if not repo.is_dirty(): + print("βˆ… Nothing to commit, all is in order…") + return 0 + + git_push_changes(repo, version) + + return github_create_pr(REPO_ANDROID, branch_name, base_name, version, gh_token) + + +def run_main() -> int: + cli_args = parse_arguments(sys.argv[1:]) + + # This script expects to have a valid Github Token in a "gh_token" text file + # The token needs the `repo` permissions, and for now is a PAT + with open('gh_token', 'r') as f: + gh_token = f.read().strip() + + return update_dependant(cli_args.version, gh_token) + + +if __name__ == "__main__": + sys.exit(run_main()) diff --git a/sample/java/src/main/java/com/datadog/android/sample/SampleApplication.java b/sample/java/src/main/java/com/datadog/android/sample/SampleApplication.java index f2fd0acadf..abdf1db54d 100644 --- a/sample/java/src/main/java/com/datadog/android/sample/SampleApplication.java +++ b/sample/java/src/main/java/com/datadog/android/sample/SampleApplication.java @@ -62,6 +62,10 @@ public boolean accept(Fragment component) { return !NavHostFragment.class.isAssignableFrom( component.getClass()); } + @Override + public String getViewName(Fragment component) { + return null; + } })) .trackInteractions(); diff --git a/settings.gradle.kts b/settings.gradle.kts index 1d86cb9f0e..63cdb1498b 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -14,8 +14,6 @@ include(":dd-sdk-android-rx") include(":dd-sdk-android-sqldelight") include(":dd-sdk-android-timber") -include(":dd-android-gradle-plugin") - include(":instrumented:benchmark") include(":instrumented:integration")