diff --git a/.github/ISSUE_TEMPLATE/bug_report_android.yml b/.github/ISSUE_TEMPLATE/bug_report_android.yml index f76c38dbe7..e83e485450 100644 --- a/.github/ISSUE_TEMPLATE/bug_report_android.yml +++ b/.github/ISSUE_TEMPLATE/bug_report_android.yml @@ -16,6 +16,7 @@ body: - sentry-apollo - sentry-apollo-3 - sentry-compose + - sentry-launchdarkly-android - sentry-okhttp - other validations: diff --git a/.github/ISSUE_TEMPLATE/bug_report_java.yml b/.github/ISSUE_TEMPLATE/bug_report_java.yml index 9059373b8f..3f2df40888 100644 --- a/.github/ISSUE_TEMPLATE/bug_report_java.yml +++ b/.github/ISSUE_TEMPLATE/bug_report_java.yml @@ -36,6 +36,7 @@ body: - sentry-quartz - sentry-openfeign - sentry-openfeature + - sentry-launchdarkly-server - sentry-apache-http-client-5 - sentry-okhttp - sentry-reactor diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b5b7a8fa0..aa101dd77c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,9 @@ - Implement OpenFeature Integration that tracks Feature Flag evaluations ([#4910](https://github.com/getsentry/sentry-java/pull/4910)) - To make use of it, add the `sentry-openfeature` dependency and register the the hook using: `openFeatureApiInstance.addHooks(new SentryOpenFeatureHook());` +- Implement LaunchDarkly Integrations that track Feature Flag evaluations ([#4917](https://github.com/getsentry/sentry-java/pull/4917)) + - For Android, please add `sentry-launchdarkly-android` as a dependency and register the `SentryLaunchDarklyAndroidHook` + - For Server / JVM, please add `sentry-launchdarkly-server` as a dependency and register the `SentryLaunchDarklyServerHook` - Detect oversized events and reduce their size ([#4903](https://github.com/getsentry/sentry-java/pull/4903)) - You can opt into this new behaviour by setting `enableEventSizeLimiting` to `true` (`sentry.enable-event-size-limiting=true` for Spring Boot `application.properties`) - You may optionally register an `onOversizedEvent` callback to implement custom logic that is executed in case an oversized event is detected diff --git a/README.md b/README.md index 8bd1d8fadd..3a92a2692d 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,8 @@ Sentry SDK for Java and Android | sentry-quartz | ![Maven Central Version](https://img.shields.io/maven-central/v/io.sentry/sentry-quartz?style=for-the-badge&logo=sentry&color=green) | | sentry-openfeign | ![Maven Central Version](https://img.shields.io/maven-central/v/io.sentry/sentry-openfeign?style=for-the-badge&logo=sentry&color=green) | | sentry-openfeature | ![Maven Central Version](https://img.shields.io/maven-central/v/io.sentry/sentry-openfeature?style=for-the-badge&logo=sentry&color=green) | +| sentry-launchdarkly-android | ![Maven Central Version](https://img.shields.io/maven-central/v/io.sentry/sentry-launchdarkly-android?style=for-the-badge&logo=sentry&color=green) | +| sentry-launchdarkly-server | ![Maven Central Version](https://img.shields.io/maven-central/v/io.sentry/sentry-launchdarkly-server?style=for-the-badge&logo=sentry&color=green) | | sentry-opentelemetry-agent | ![Maven Central Version](https://img.shields.io/maven-central/v/io.sentry/sentry-opentelemetry-agent?style=for-the-badge&logo=sentry&color=green) | | sentry-opentelemetry-agentcustomization | ![Maven Central Version](https://img.shields.io/maven-central/v/io.sentry/sentry-opentelemetry-agentcustomization?style=for-the-badge&logo=sentry&color=green) | | sentry-opentelemetry-core | ![Maven Central Version](https://img.shields.io/maven-central/v/io.sentry/sentry-opentelemetry-core?style=for-the-badge&logo=sentry&color=green) | diff --git a/buildSrc/src/main/java/Config.kt b/buildSrc/src/main/java/Config.kt index 887bf1415b..3b6a08ad26 100644 --- a/buildSrc/src/main/java/Config.kt +++ b/buildSrc/src/main/java/Config.kt @@ -78,6 +78,8 @@ object Config { val SENTRY_QUARTZ_SDK_NAME = "$SENTRY_JAVA_SDK_NAME.quartz" val SENTRY_JDBC_SDK_NAME = "$SENTRY_JAVA_SDK_NAME.jdbc" val SENTRY_OPENFEATURE_SDK_NAME = "$SENTRY_JAVA_SDK_NAME.openfeature" + val SENTRY_LAUNCHDARKLY_SERVER_SDK_NAME = "$SENTRY_JAVA_SDK_NAME.launchdarkly-server" + val SENTRY_LAUNCHDARKLY_ANDROID_SDK_NAME = "$SENTRY_ANDROID_SDK_NAME.launchdarkly" val SENTRY_SERVLET_SDK_NAME = "$SENTRY_JAVA_SDK_NAME.servlet" val SENTRY_SERVLET_JAKARTA_SDK_NAME = "$SENTRY_JAVA_SDK_NAME.servlet.jakarta" val SENTRY_COMPOSE_HELPER_SDK_NAME = "$SENTRY_JAVA_SDK_NAME.compose.helper" diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 9a60c852f6..98b5cc37bf 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -117,6 +117,8 @@ kotlinx-coroutines = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" } ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktorClient" } ktor-client-java = { module = "io.ktor:ktor-client-java", version.ref = "ktorClient" } +launchdarkly-android = { module = "com.launchdarkly:launchdarkly-android-client-sdk", version = "5.9.2" } +launchdarkly-server = { module = "com.launchdarkly:launchdarkly-java-server-sdk", version = "7.10.2" } log4j-api = { module = "org.apache.logging.log4j:log4j-api", version.ref = "log4j2" } log4j-core = { module = "org.apache.logging.log4j:log4j-core", version.ref = "log4j2" } leakcanary = { module = "com.squareup.leakcanary:leakcanary-android", version = "2.14" } diff --git a/sentry-launchdarkly-android/api/sentry-launchdarkly-android.api b/sentry-launchdarkly-android/api/sentry-launchdarkly-android.api new file mode 100644 index 0000000000..4c0f5f262a --- /dev/null +++ b/sentry-launchdarkly-android/api/sentry-launchdarkly-android.api @@ -0,0 +1,15 @@ +public final class io/sentry/launchdarkly/android/BuildConfig { + public static final field BUILD_TYPE Ljava/lang/String; + public static final field DEBUG Z + public static final field LIBRARY_PACKAGE_NAME Ljava/lang/String; + public static final field SENTRY_LAUNCHDARKLY_ANDROID_SDK_NAME Ljava/lang/String; + public static final field VERSION_NAME Ljava/lang/String; + public fun ()V +} + +public final class io/sentry/launchdarkly/android/SentryLaunchDarklyAndroidHook : com/launchdarkly/sdk/android/integrations/Hook { + public fun ()V + public fun (Lio/sentry/IScopes;)V + public fun afterEvaluation (Lcom/launchdarkly/sdk/android/integrations/EvaluationSeriesContext;Ljava/util/Map;Lcom/launchdarkly/sdk/EvaluationDetail;)Ljava/util/Map; +} + diff --git a/sentry-launchdarkly-android/build.gradle.kts b/sentry-launchdarkly-android/build.gradle.kts new file mode 100644 index 0000000000..bf59c256ed --- /dev/null +++ b/sentry-launchdarkly-android/build.gradle.kts @@ -0,0 +1,72 @@ +plugins { + id("com.android.library") + alias(libs.plugins.kotlin.android) + jacoco + alias(libs.plugins.jacoco.android) + alias(libs.plugins.gradle.versions) +} + +android { + compileSdk = libs.versions.compileSdk.get().toInt() + namespace = "io.sentry.launchdarkly.android" + + defaultConfig { + minSdk = libs.versions.minSdk.get().toInt() + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + + // for AGP 4.1 + buildConfigField("String", "VERSION_NAME", "\"${project.version}\"") + buildConfigField( + "String", + "SENTRY_LAUNCHDARKLY_ANDROID_SDK_NAME", + "\"${Config.Sentry.SENTRY_LAUNCHDARKLY_ANDROID_SDK_NAME}\"", + ) + } + + buildTypes { + getByName("debug") { consumerProguardFiles("proguard-rules.pro") } + getByName("release") { consumerProguardFiles("proguard-rules.pro") } + } + + kotlin { compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8 } + + testOptions { + animationsDisabled = true + unitTests.apply { + isReturnDefaultValues = true + isIncludeAndroidResources = true + } + } + + lint { + warningsAsErrors = true + checkDependencies = true + + // We run a full lint analysis as build part in CI, so skip vital checks for assemble tasks. + checkReleaseBuilds = false + } + + buildFeatures { buildConfig = true } + + androidComponents.beforeVariants { + it.enable = !Config.Android.shouldSkipDebugVariant(it.buildType) + } +} + +dependencies { + api(projects.sentry) + + compileOnly(libs.launchdarkly.android) + compileOnly(libs.jetbrains.annotations) + + // tests + testImplementation(projects.sentry) + testImplementation(projects.sentryTestSupport) + testImplementation(kotlin(Config.kotlinStdLib, Config.kotlinStdLibVersionAndroid)) + testImplementation(libs.androidx.test.ext.junit) + testImplementation(libs.kotlin.test.junit) + testImplementation(libs.mockito.kotlin) + testImplementation(libs.mockito.inline) + testImplementation(libs.launchdarkly.android) +} diff --git a/sentry-launchdarkly-android/proguard-rules.pro b/sentry-launchdarkly-android/proguard-rules.pro new file mode 100644 index 0000000000..fbb30d1d08 --- /dev/null +++ b/sentry-launchdarkly-android/proguard-rules.pro @@ -0,0 +1,9 @@ +##---------------Begin: proguard configuration for LaunchDarkly Android ---------- + +# To ensure that stack traces is unambiguous +# https://developer.android.com/studio/build/shrink-code#decode-stack-trace +-keepattributes LineNumberTable,SourceFile + +##---------------End: proguard configuration for LaunchDarkly Android ---------- + + diff --git a/sentry-launchdarkly-android/src/main/java/io/sentry/launchdarkly/android/SentryLaunchDarklyAndroidHook.java b/sentry-launchdarkly-android/src/main/java/io/sentry/launchdarkly/android/SentryLaunchDarklyAndroidHook.java new file mode 100644 index 0000000000..0d4f5192d1 --- /dev/null +++ b/sentry-launchdarkly-android/src/main/java/io/sentry/launchdarkly/android/SentryLaunchDarklyAndroidHook.java @@ -0,0 +1,70 @@ +package io.sentry.launchdarkly.android; + +import com.launchdarkly.sdk.EvaluationDetail; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.LDValueType; +import com.launchdarkly.sdk.android.integrations.EvaluationSeriesContext; +import com.launchdarkly.sdk.android.integrations.Hook; +import io.sentry.IScopes; +import io.sentry.ScopesAdapter; +import io.sentry.SentryIntegrationPackageStorage; +import io.sentry.SentryLevel; +import io.sentry.util.IntegrationUtils; +import java.util.Map; +import java.util.Objects; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +public final class SentryLaunchDarklyAndroidHook extends Hook { + private final IScopes scopes; + + static { + SentryIntegrationPackageStorage.getInstance() + .addPackage("maven:io.sentry:sentry-launchdarkly-android", BuildConfig.VERSION_NAME); + } + + public SentryLaunchDarklyAndroidHook() { + this(ScopesAdapter.getInstance()); + } + + public SentryLaunchDarklyAndroidHook(final @NotNull IScopes scopes) { + super("SentryLaunchDarklyAndroidHook"); + this.scopes = Objects.requireNonNull(scopes, "Scopes are required"); + addPackageAndIntegrationInfo(); + } + + private void addPackageAndIntegrationInfo() { + IntegrationUtils.addIntegrationToSdkVersion("LaunchDarkly-Android"); + } + + @Override + public Map afterEvaluation( + final EvaluationSeriesContext seriesContext, + final Map seriesData, + final EvaluationDetail evaluationDetail) { + if (evaluationDetail == null || seriesContext == null) { + return seriesData; + } + + try { + final @Nullable String flagKey = seriesContext.flagKey; + final @Nullable LDValue value = evaluationDetail.getValue(); + + if (flagKey == null || value == null) { + return seriesData; + } + + if (LDValueType.BOOLEAN.equals(value.getType())) { + final boolean flagValue = value.booleanValue(); + scopes.addFeatureFlag(flagKey, flagValue); + } + } catch (final Exception e) { + scopes + .getOptions() + .getLogger() + .log(SentryLevel.ERROR, "Failed to capture feature flag evaluation", e); + } + + return seriesData; + } +} diff --git a/sentry-launchdarkly-android/src/test/java/io/sentry/launchdarkly/android/SentryLaunchDarklyAndroidHookTest.kt b/sentry-launchdarkly-android/src/test/java/io/sentry/launchdarkly/android/SentryLaunchDarklyAndroidHookTest.kt new file mode 100644 index 0000000000..3e7174af43 --- /dev/null +++ b/sentry-launchdarkly-android/src/test/java/io/sentry/launchdarkly/android/SentryLaunchDarklyAndroidHookTest.kt @@ -0,0 +1,232 @@ +package io.sentry.launchdarkly.android + +import com.launchdarkly.sdk.EvaluationDetail +import com.launchdarkly.sdk.LDValue +import com.launchdarkly.sdk.LDValueType +import com.launchdarkly.sdk.android.integrations.EvaluationSeriesContext +import io.sentry.ILogger +import io.sentry.IScopes +import io.sentry.SentryLevel +import io.sentry.SentryOptions +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import org.mockito.kotlin.any +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +class SentryLaunchDarklyAndroidHookTest { + + private lateinit var mockScopes: IScopes + private lateinit var mockOptions: SentryOptions + private lateinit var mockLogger: ILogger + private lateinit var hook: SentryLaunchDarklyAndroidHook + + @BeforeTest + fun setUp() { + mockScopes = mock() + mockOptions = mock() + mockLogger = mock() + whenever(mockScopes.options).thenReturn(mockOptions) + whenever(mockOptions.logger).thenReturn(mockLogger) + hook = SentryLaunchDarklyAndroidHook(mockScopes) + } + + @Test + fun `afterEvaluation with boolean value calls addFeatureFlag`() { + val flagKey = "test-flag" + val flagValue = true + + val seriesContext = createSeriesContext(flagKey) + + val ldValue = mock() + whenever(ldValue.getType()).thenReturn(LDValueType.BOOLEAN) + whenever(ldValue.booleanValue()).thenReturn(flagValue) + + val evaluationDetail = mock>() + whenever(evaluationDetail.getValue()).thenReturn(ldValue) + + val seriesData = mutableMapOf() + seriesData["existingKey"] = "existingValue" + + val result = hook.afterEvaluation(seriesContext, seriesData, evaluationDetail) + + verify(mockScopes).addFeatureFlag(eq(flagKey), eq(flagValue)) + assertEquals(seriesData, result) + assertEquals("existingValue", result["existingKey"]) + } + + @Test + fun `afterEvaluation with false boolean value calls addFeatureFlag`() { + val flagKey = "test-flag" + val flagValue = false + + val seriesContext = createSeriesContext(flagKey) + + val ldValue = mock() + whenever(ldValue.getType()).thenReturn(LDValueType.BOOLEAN) + whenever(ldValue.booleanValue()).thenReturn(flagValue) + + val evaluationDetail = mock>() + whenever(evaluationDetail.getValue()).thenReturn(ldValue) + + val seriesData = mutableMapOf() + seriesData["existingKey"] = "existingValue" + + val result = hook.afterEvaluation(seriesContext, seriesData, evaluationDetail) + + verify(mockScopes).addFeatureFlag(eq(flagKey), eq(flagValue)) + assertEquals(seriesData, result) + assertEquals("existingValue", result["existingKey"]) + } + + @Test + fun `afterEvaluation with non-boolean value does not call addFeatureFlag`() { + val flagKey = "test-flag" + + val seriesContext = createSeriesContext(flagKey) + + val ldValue = mock() + whenever(ldValue.getType()).thenReturn(LDValueType.STRING) + + val evaluationDetail = mock>() + whenever(evaluationDetail.getValue()).thenReturn(ldValue) + + val seriesData = mutableMapOf() + seriesData["existingKey"] = "existingValue" + + val result = hook.afterEvaluation(seriesContext, seriesData, evaluationDetail) + + verify(mockScopes, never()).addFeatureFlag(any(), any()) + assertEquals(seriesData, result) + assertEquals("existingValue", result["existingKey"]) + } + + @Test + fun `afterEvaluation with null seriesContext returns seriesData`() { + val evaluationDetail = mock>() + val seriesData = mutableMapOf() + seriesData["existingKey"] = "existingValue" + + val result = hook.afterEvaluation(null, seriesData, evaluationDetail) + + verify(mockScopes, never()).addFeatureFlag(any(), any()) + assertEquals(seriesData, result) + assertEquals("existingValue", result["existingKey"]) + } + + @Test + fun `afterEvaluation with null evaluationDetail returns seriesData`() { + val seriesContext = mock() + val seriesData = mutableMapOf() + seriesData["existingKey"] = "existingValue" + + val result = hook.afterEvaluation(seriesContext, seriesData, null) + + verify(mockScopes, never()).addFeatureFlag(any(), any()) + assertEquals(seriesData, result) + assertEquals("existingValue", result["existingKey"]) + } + + @Test + fun `afterEvaluation with null flagKey returns seriesData`() { + val seriesContext = createSeriesContext(null) + + val ldValue = mock() + whenever(ldValue.getType()).thenReturn(LDValueType.BOOLEAN) + + val evaluationDetail = mock>() + whenever(evaluationDetail.getValue()).thenReturn(ldValue) + + val seriesData = mutableMapOf() + seriesData["existingKey"] = "existingValue" + + val result = hook.afterEvaluation(seriesContext, seriesData, evaluationDetail) + + verify(mockScopes, never()).addFeatureFlag(any(), any()) + assertEquals(seriesData, result) + assertEquals("existingValue", result["existingKey"]) + } + + @Test + fun `afterEvaluation with null value returns seriesData`() { + val flagKey = "test-flag" + + val seriesContext = createSeriesContext(flagKey) + + val evaluationDetail = mock>() + whenever(evaluationDetail.getValue()).thenReturn(null) + + val seriesData = mutableMapOf() + seriesData["existingKey"] = "existingValue" + + val result = hook.afterEvaluation(seriesContext, seriesData, evaluationDetail) + + verify(mockScopes, never()).addFeatureFlag(any(), any()) + assertEquals(seriesData, result) + assertEquals("existingValue", result["existingKey"]) + } + + @Test + fun `afterEvaluation with exception logs error`() { + val flagKey = "test-flag" + + val seriesContext = createSeriesContext(flagKey) + + val ldValue = mock() + whenever(ldValue.getType()).thenThrow(RuntimeException("Test exception")) + + val evaluationDetail = mock>() + whenever(evaluationDetail.getValue()).thenReturn(ldValue) + + val seriesData = mutableMapOf() + seriesData["existingKey"] = "existingValue" + + val result = hook.afterEvaluation(seriesContext, seriesData, evaluationDetail) + + verify(mockLogger) + .log(eq(SentryLevel.ERROR), eq("Failed to capture feature flag evaluation"), any()) + verify(mockScopes, never()).addFeatureFlag(any(), any()) + assertEquals(seriesData, result) + assertEquals("existingValue", result["existingKey"]) + } + + @Test + fun `afterEvaluation returns original seriesData`() { + val flagKey = "test-flag" + val flagValue = true + + val seriesContext = createSeriesContext(flagKey) + + val ldValue = mock() + whenever(ldValue.getType()).thenReturn(LDValueType.BOOLEAN) + whenever(ldValue.booleanValue()).thenReturn(flagValue) + + val evaluationDetail = mock>() + whenever(evaluationDetail.getValue()).thenReturn(ldValue) + + val seriesData = mutableMapOf() + seriesData["key"] = "value" + + val result = hook.afterEvaluation(seriesContext, seriesData, evaluationDetail) + + verify(mockScopes).addFeatureFlag(eq(flagKey), eq(flagValue)) + assertEquals(seriesData, result) + assertEquals("value", result["key"]) + } + + private fun createSeriesContext(flagKey: String?): EvaluationSeriesContext { + val seriesContext = mock() + try { + val field = EvaluationSeriesContext::class.java.getField("flagKey") + field.isAccessible = true + field.set(seriesContext, flagKey) + } catch (e: Exception) { + throw RuntimeException("Failed to set flagKey field", e) + } + return seriesContext + } +} diff --git a/sentry-launchdarkly-server/api/sentry-launchdarkly-server.api b/sentry-launchdarkly-server/api/sentry-launchdarkly-server.api new file mode 100644 index 0000000000..8a42a12d9e --- /dev/null +++ b/sentry-launchdarkly-server/api/sentry-launchdarkly-server.api @@ -0,0 +1,10 @@ +public final class io/sentry/launchdarkly/server/BuildConfig { + public static final field SENTRY_LAUNCHDARKLY_SERVER_SDK_NAME Ljava/lang/String; + public static final field VERSION_NAME Ljava/lang/String; +} + +public final class io/sentry/launchdarkly/server/SentryLaunchDarklyServerHook : com/launchdarkly/sdk/server/integrations/Hook { + public fun ()V + public fun afterEvaluation (Lcom/launchdarkly/sdk/server/integrations/EvaluationSeriesContext;Ljava/util/Map;Lcom/launchdarkly/sdk/EvaluationDetail;)Ljava/util/Map; +} + diff --git a/sentry-launchdarkly-server/build.gradle.kts b/sentry-launchdarkly-server/build.gradle.kts new file mode 100644 index 0000000000..ee273fa5a9 --- /dev/null +++ b/sentry-launchdarkly-server/build.gradle.kts @@ -0,0 +1,91 @@ +import net.ltgt.gradle.errorprone.errorprone +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + +plugins { + `java-library` + id("io.sentry.javadoc") + alias(libs.plugins.kotlin.jvm) + jacoco + alias(libs.plugins.errorprone) + alias(libs.plugins.gradle.versions) + alias(libs.plugins.buildconfig) +} + +tasks.withType().configureEach { + compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8 + compilerOptions.languageVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 + compilerOptions.apiVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 +} + +dependencies { + api(projects.sentry) + + compileOnly(libs.launchdarkly.server) + + compileOnly(libs.jetbrains.annotations) + compileOnly(libs.nopen.annotations) + errorprone(libs.errorprone.core) + errorprone(libs.nopen.checker) + errorprone(libs.nullaway) + + // tests + testImplementation(projects.sentry) + testImplementation(projects.sentryTestSupport) + testImplementation(kotlin(Config.kotlinStdLib)) + testImplementation(libs.kotlin.test.junit) + testImplementation(libs.mockito.kotlin) + testImplementation(libs.mockito.inline) + testImplementation(libs.launchdarkly.server) +} + +configure { test { java.srcDir("src/test/java") } } + +jacoco { toolVersion = libs.versions.jacoco.get() } + +tasks.jacocoTestReport { + reports { + xml.required.set(true) + html.required.set(false) + } +} + +tasks { + jacocoTestCoverageVerification { + violationRules { rule { limit { minimum = Config.QualityPlugins.Jacoco.minimumCoverage } } } + } + check { + dependsOn(jacocoTestCoverageVerification) + dependsOn(jacocoTestReport) + } +} + +tasks.withType().configureEach { + options.errorprone { + check("NullAway", net.ltgt.gradle.errorprone.CheckSeverity.ERROR) + option("NullAway:AnnotatedPackages", "io.sentry") + } +} + +buildConfig { + useJavaOutput() + packageName("io.sentry.launchdarkly.server") + buildConfigField( + "String", + "SENTRY_LAUNCHDARKLY_SERVER_SDK_NAME", + "\"${Config.Sentry.SENTRY_LAUNCHDARKLY_SERVER_SDK_NAME}\"", + ) + buildConfigField("String", "VERSION_NAME", "\"${project.version}\"") +} + +tasks.jar { + manifest { + attributes( + "Sentry-Version-Name" to project.version, + "Sentry-SDK-Name" to Config.Sentry.SENTRY_LAUNCHDARKLY_SERVER_SDK_NAME, + "Sentry-SDK-Package-Name" to "maven:io.sentry:sentry-launchdarkly-server", + "Implementation-Vendor" to "Sentry", + "Implementation-Title" to project.name, + "Implementation-Version" to project.version, + ) + } +} diff --git a/sentry-launchdarkly-server/src/main/java/io/sentry/launchdarkly/server/SentryLaunchDarklyServerHook.java b/sentry-launchdarkly-server/src/main/java/io/sentry/launchdarkly/server/SentryLaunchDarklyServerHook.java new file mode 100644 index 0000000000..daa4940fc7 --- /dev/null +++ b/sentry-launchdarkly-server/src/main/java/io/sentry/launchdarkly/server/SentryLaunchDarklyServerHook.java @@ -0,0 +1,73 @@ +package io.sentry.launchdarkly.server; + +import static io.sentry.util.IntegrationUtils.addIntegrationToSdkVersion; + +import com.launchdarkly.sdk.EvaluationDetail; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.LDValueType; +import com.launchdarkly.sdk.server.integrations.EvaluationSeriesContext; +import com.launchdarkly.sdk.server.integrations.Hook; +import io.sentry.IScopes; +import io.sentry.ScopesAdapter; +import io.sentry.SentryIntegrationPackageStorage; +import io.sentry.SentryLevel; +import java.util.Map; +import java.util.Objects; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.jetbrains.annotations.VisibleForTesting; + +public final class SentryLaunchDarklyServerHook extends Hook { + private final IScopes scopes; + + static { + SentryIntegrationPackageStorage.getInstance() + .addPackage("maven:io.sentry:sentry-launchdarkly-server", BuildConfig.VERSION_NAME); + } + + public SentryLaunchDarklyServerHook() { + this(ScopesAdapter.getInstance()); + } + + @VisibleForTesting + SentryLaunchDarklyServerHook(@NotNull IScopes scopes) { + super("SentryLaunchDarklyServerHook"); + this.scopes = Objects.requireNonNull(scopes, "Scopes are required"); + addPackageAndIntegrationInfo(); + } + + private void addPackageAndIntegrationInfo() { + addIntegrationToSdkVersion("LaunchDarkly-Server"); + } + + @Override + public Map afterEvaluation( + EvaluationSeriesContext seriesContext, + Map seriesData, + EvaluationDetail evaluationDetail) { + if (evaluationDetail == null || seriesContext == null) { + return seriesData; + } + + try { + final @Nullable String flagKey = seriesContext.flagKey; + final @Nullable LDValue value = evaluationDetail.getValue(); + + if (flagKey == null || value == null) { + return seriesData; + } + + if (LDValueType.BOOLEAN.equals(value.getType())) { + final boolean flagValue = value.booleanValue(); + scopes.addFeatureFlag(flagKey, flagValue); + } + } catch (Exception e) { + scopes + .getOptions() + .getLogger() + .log(SentryLevel.ERROR, "Failed to capture feature flag evaluation", e); + } + + return seriesData; + } +} diff --git a/sentry-launchdarkly-server/src/test/java/io/sentry/launchdarkly/server/SentryLaunchDarklyServerHookTest.kt b/sentry-launchdarkly-server/src/test/java/io/sentry/launchdarkly/server/SentryLaunchDarklyServerHookTest.kt new file mode 100644 index 0000000000..b82f75c75f --- /dev/null +++ b/sentry-launchdarkly-server/src/test/java/io/sentry/launchdarkly/server/SentryLaunchDarklyServerHookTest.kt @@ -0,0 +1,238 @@ +package io.sentry.launchdarkly.server + +import com.launchdarkly.sdk.EvaluationDetail +import com.launchdarkly.sdk.LDValue +import com.launchdarkly.sdk.LDValueType +import com.launchdarkly.sdk.server.integrations.EvaluationSeriesContext +import io.sentry.ILogger +import io.sentry.IScopes +import io.sentry.SentryLevel +import io.sentry.SentryOptions +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import org.mockito.kotlin.any +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +class SentryLaunchDarklyServerHookTest { + + private lateinit var mockScopes: IScopes + private lateinit var mockOptions: SentryOptions + private lateinit var mockLogger: ILogger + private lateinit var hook: SentryLaunchDarklyServerHook + + @BeforeTest + fun setUp() { + mockScopes = mock() + mockOptions = mock() + mockLogger = mock() + whenever(mockScopes.options).thenReturn(mockOptions) + whenever(mockOptions.logger).thenReturn(mockLogger) + hook = SentryLaunchDarklyServerHook(mockScopes) + } + + @AfterTest + fun tearDown() { + // Cleanup if needed + } + + private fun createSeriesContext(flagKey: String?): EvaluationSeriesContext { + val seriesContext = mock() + try { + val field = EvaluationSeriesContext::class.java.getField("flagKey") + field.isAccessible = true + field.set(seriesContext, flagKey) + } catch (e: Exception) { + throw RuntimeException("Failed to set flagKey field", e) + } + return seriesContext + } + + @Test + fun `afterEvaluation with boolean value calls addFeatureFlag`() { + val flagKey = "test-flag" + val flagValue = true + + val seriesContext = createSeriesContext(flagKey) + + val ldValue = mock() + whenever(ldValue.getType()).thenReturn(LDValueType.BOOLEAN) + whenever(ldValue.booleanValue()).thenReturn(flagValue) + + val evaluationDetail = mock>() + whenever(evaluationDetail.getValue()).thenReturn(ldValue) + + val seriesData = mutableMapOf() + seriesData["existingKey"] = "existingValue" + + val result = hook.afterEvaluation(seriesContext, seriesData, evaluationDetail) + + verify(mockScopes).addFeatureFlag(eq(flagKey), eq(flagValue)) + assertEquals(seriesData, result) + assertEquals("existingValue", result["existingKey"]) + } + + @Test + fun `afterEvaluation with false boolean value calls addFeatureFlag`() { + val flagKey = "test-flag" + val flagValue = false + + val seriesContext = createSeriesContext(flagKey) + + val ldValue = mock() + whenever(ldValue.getType()).thenReturn(LDValueType.BOOLEAN) + whenever(ldValue.booleanValue()).thenReturn(flagValue) + + val evaluationDetail = mock>() + whenever(evaluationDetail.getValue()).thenReturn(ldValue) + + val seriesData = mutableMapOf() + seriesData["existingKey"] = "existingValue" + + val result = hook.afterEvaluation(seriesContext, seriesData, evaluationDetail) + + verify(mockScopes).addFeatureFlag(eq(flagKey), eq(flagValue)) + assertEquals(seriesData, result) + assertEquals("existingValue", result["existingKey"]) + } + + @Test + fun `afterEvaluation with non-boolean value does not call addFeatureFlag`() { + val flagKey = "test-flag" + + val seriesContext = createSeriesContext(flagKey) + + val ldValue = mock() + whenever(ldValue.getType()).thenReturn(LDValueType.STRING) + + val evaluationDetail = mock>() + whenever(evaluationDetail.getValue()).thenReturn(ldValue) + + val seriesData = mutableMapOf() + seriesData["existingKey"] = "existingValue" + + val result = hook.afterEvaluation(seriesContext, seriesData, evaluationDetail) + + verify(mockScopes, never()).addFeatureFlag(any(), any()) + assertEquals(seriesData, result) + assertEquals("existingValue", result["existingKey"]) + } + + @Test + fun `afterEvaluation with null seriesContext returns seriesData`() { + val evaluationDetail = mock>() + val seriesData = mutableMapOf() + seriesData["existingKey"] = "existingValue" + + val result = hook.afterEvaluation(null, seriesData, evaluationDetail) + + verify(mockScopes, never()).addFeatureFlag(any(), any()) + assertEquals(seriesData, result) + assertEquals("existingValue", result["existingKey"]) + } + + @Test + fun `afterEvaluation with null evaluationDetail returns seriesData`() { + val seriesContext = mock() + val seriesData = mutableMapOf() + seriesData["existingKey"] = "existingValue" + + val result = hook.afterEvaluation(seriesContext, seriesData, null) + + verify(mockScopes, never()).addFeatureFlag(any(), any()) + assertEquals(seriesData, result) + assertEquals("existingValue", result["existingKey"]) + } + + @Test + fun `afterEvaluation with null flagKey returns seriesData`() { + val seriesContext = createSeriesContext(null) + + val ldValue = mock() + whenever(ldValue.getType()).thenReturn(LDValueType.BOOLEAN) + + val evaluationDetail = mock>() + whenever(evaluationDetail.getValue()).thenReturn(ldValue) + + val seriesData = mutableMapOf() + seriesData["existingKey"] = "existingValue" + + val result = hook.afterEvaluation(seriesContext, seriesData, evaluationDetail) + + verify(mockScopes, never()).addFeatureFlag(any(), any()) + assertEquals(seriesData, result) + assertEquals("existingValue", result["existingKey"]) + } + + @Test + fun `afterEvaluation with null value returns seriesData`() { + val flagKey = "test-flag" + + val seriesContext = createSeriesContext(flagKey) + + val evaluationDetail = mock>() + whenever(evaluationDetail.getValue()).thenReturn(null) + + val seriesData = mutableMapOf() + seriesData["existingKey"] = "existingValue" + + val result = hook.afterEvaluation(seriesContext, seriesData, evaluationDetail) + + verify(mockScopes, never()).addFeatureFlag(any(), any()) + assertEquals(seriesData, result) + assertEquals("existingValue", result["existingKey"]) + } + + @Test + fun `afterEvaluation with exception logs error`() { + val flagKey = "test-flag" + + val seriesContext = createSeriesContext(flagKey) + + val ldValue = mock() + whenever(ldValue.getType()).thenThrow(RuntimeException("Test exception")) + + val evaluationDetail = mock>() + whenever(evaluationDetail.getValue()).thenReturn(ldValue) + + val seriesData = mutableMapOf() + seriesData["existingKey"] = "existingValue" + + val result = hook.afterEvaluation(seriesContext, seriesData, evaluationDetail) + + verify(mockLogger) + .log(eq(SentryLevel.ERROR), eq("Failed to capture feature flag evaluation"), any()) + verify(mockScopes, never()).addFeatureFlag(any(), any()) + assertEquals(seriesData, result) + assertEquals("existingValue", result["existingKey"]) + } + + @Test + fun `afterEvaluation returns original seriesData`() { + val flagKey = "test-flag" + val flagValue = true + + val seriesContext = createSeriesContext(flagKey) + + val ldValue = mock() + whenever(ldValue.getType()).thenReturn(LDValueType.BOOLEAN) + whenever(ldValue.booleanValue()).thenReturn(flagValue) + + val evaluationDetail = mock>() + whenever(evaluationDetail.getValue()).thenReturn(ldValue) + + val seriesData = mutableMapOf() + seriesData["key"] = "value" + + val result = hook.afterEvaluation(seriesContext, seriesData, evaluationDetail) + + verify(mockScopes).addFeatureFlag(eq(flagKey), eq(flagValue)) + assertEquals(seriesData, result) + assertEquals("value", result["key"]) + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 3b2c7a3d5a..e9297876f2 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -66,6 +66,8 @@ include( "sentry-quartz", "sentry-okhttp", "sentry-openfeature", + "sentry-launchdarkly-server", + "sentry-launchdarkly-android", "sentry-reactor", "sentry-async-profiler", "sentry-ktor-client",