diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/client/ObservabilityService.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/client/ObservabilityService.kt index fa099f6c45..e8b04b60b7 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/client/ObservabilityService.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/client/ObservabilityService.kt @@ -119,7 +119,7 @@ class ObservabilityService( private var otelMeter: Meter private var otelLogger: Logger private var otelTracer: Tracer - private var customSampler = CustomSampler() + private var customSampler = CustomSampler(logger = logger) private val graphqlClient = GraphQLClient( endpoint = observabilityOptions.backendUrl, logger = logger diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/sampling/CustomSampler.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/sampling/CustomSampler.kt index 572a546d4f..e654b74acc 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/sampling/CustomSampler.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/sampling/CustomSampler.kt @@ -1,5 +1,6 @@ package com.launchdarkly.observability.sampling +import com.launchdarkly.observability.context.ObserveLogger import io.opentelemetry.api.common.Attributes import io.opentelemetry.sdk.logs.data.LogRecordData import io.opentelemetry.sdk.trace.data.EventData @@ -10,14 +11,26 @@ import kotlin.random.Random /* * Thread-safe regex cache. We use ConcurrentMap so multiple threads can update the cache safely without external locks. + * Each pattern is compiled at most once; a failed compilation is cached too, so an invalid pattern is reported once + * and then never matches. */ -typealias RegexCache = ConcurrentMap +internal typealias RegexCache = ConcurrentMap + +/** + * Result of compiling a sampling regex. [regex] is null when the pattern is invalid. + */ +internal class CompiledRegex(val regex: Regex?) /** * Custom sampler that uses a sampling configuration to determine if a span should be sampled. + * + * Regex match configs use substring semantics (like the other LaunchDarkly observability SDKs): a pattern matches if it + * is found anywhere in the value, so anchors (`^`, `$`) must be used to require a full match. A pattern that fails to + * compile is logged once as a warning and the match config never matches; it never throws. */ class CustomSampler( - private val sampler: (Int) -> Boolean = ::defaultSampler + private val sampler: (Int) -> Boolean = ::defaultSampler, + private val logger: ObserveLogger? = null, ) : ExportSampler { @Volatile @@ -38,6 +51,15 @@ class CustomSampler( return config?.logs?.isNotEmpty() == true || config?.spans?.isNotEmpty() == true } + private fun compileRegex(pattern: String): CompiledRegex { + return try { + CompiledRegex(Regex(pattern)) + } catch (e: IllegalArgumentException) { // PatternSyntaxException is a subclass + logger?.warn("Invalid sampling regex \"$pattern\"; rules using it will never match", e) + CompiledRegex(null) + } + } + /** * Check if a value matches a match config. */ @@ -55,8 +77,8 @@ class CustomSampler( if (value !is String) { false } else { - val regex = regexCache.computeIfAbsent(matchConfig.pattern) { Regex(it) } - regex.matches(value) + val regex = regexCache.computeIfAbsent(matchConfig.pattern, ::compileRegex).regex + regex?.containsMatchIn(value) ?: false } } } diff --git a/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/sampling/CustomSamplerTest.kt b/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/sampling/CustomSamplerTest.kt index 90be2af0d2..adaa300fbd 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/sampling/CustomSamplerTest.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/sampling/CustomSamplerTest.kt @@ -1,9 +1,11 @@ package com.launchdarkly.observability.sampling +import com.launchdarkly.observability.context.ObserveLogger import io.mockk.every import io.mockk.mockk import io.mockk.mockkObject import io.mockk.unmockkObject +import io.mockk.verify import io.opentelemetry.api.common.AttributeKey import io.opentelemetry.api.common.Attributes import io.opentelemetry.api.logs.Severity @@ -14,6 +16,7 @@ import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.assertDoesNotThrow import org.junit.jupiter.api.Test import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.DisplayName @@ -592,6 +595,139 @@ class CustomSamplerTest { } } + @Nested + @DisplayName("Regex Matching Tests") + inner class RegexMatchingTests { + + private fun spanNameConfig(pattern: String, ratio: Int = 42) = SamplingConfig( + spans = listOf(SpanSamplingConfig(name = MatchConfig.Regex(pattern), samplingRatio = ratio)) + ) + + @Test + fun `should match regex as a substring of the value`() { + every { mockSamplerFunction(42) } returns false + customSampler.setConfig(spanNameConfig("health")) + + val result = customSampler.sampleSpan(createMockSpan("GET /health-check")) + + assertFalse(result.sample) + assertEquals(42L, result.attributes?.get(AttributeKey.longKey("launchdarkly.sampling.ratio"))) + } + + @Test + fun `should respect start anchor`() { + every { mockSamplerFunction(42) } returns false + customSampler.setConfig(spanNameConfig("^health")) + + assertFalse(customSampler.sampleSpan(createMockSpan("health-check")).sample) + customSampler.sampleSpan(createMockSpan("GET /health-check")).also { + assertTrue(it.sample) + assertNull(it.attributes) + } + } + + @Test + fun `should respect end anchor`() { + every { mockSamplerFunction(42) } returns false + customSampler.setConfig(spanNameConfig("check$")) + + assertFalse(customSampler.sampleSpan(createMockSpan("GET /health-check")).sample) + customSampler.sampleSpan(createMockSpan("GET /health-check/status")).also { + assertTrue(it.sample) + assertNull(it.attributes) + } + } + + @Test + fun `should respect full anchors`() { + every { mockSamplerFunction(42) } returns false + customSampler.setConfig(spanNameConfig("^health$")) + + assertFalse(customSampler.sampleSpan(createMockSpan("health")).sample) + customSampler.sampleSpan(createMockSpan("GET /health-check")).also { + assertTrue(it.sample) + assertNull(it.attributes) + } + } + + @Test + fun `should match log message regex as a substring`() { + every { mockSamplerFunction(7) } returns false + customSampler.setConfig( + SamplingConfig( + logs = listOf(LogSamplingConfig(message = MatchConfig.Regex("timeout"), samplingRatio = 7)) + ) + ) + + val result = customSampler.sampleLog(createMockLog("Request failed: timeout after 30s")) + + assertFalse(result.sample) + assertEquals(7L, result.attributes?.get(AttributeKey.longKey("launchdarkly.sampling.ratio"))) + } + + @Test + fun `should not throw and never match for an invalid regex`() { + customSampler.setConfig(spanNameConfig("([unclosed")) + + val result = assertDoesNotThrow { customSampler.sampleSpan(createMockSpan("([unclosed")) } + + assertTrue(result.sample) + assertNull(result.attributes) + verify(exactly = 0) { mockSamplerFunction(any()) } + } + + @Test + fun `should skip invalid regex rule and still apply other rules`() { + every { mockSamplerFunction(10) } returns false + customSampler.setConfig( + SamplingConfig( + spans = listOf( + SpanSamplingConfig(name = MatchConfig.Regex("*bad"), samplingRatio = 99), + SpanSamplingConfig(name = MatchConfig.Regex("good"), samplingRatio = 10) + ), + logs = listOf( + LogSamplingConfig(message = MatchConfig.Regex("[bad"), samplingRatio = 99), + LogSamplingConfig(message = MatchConfig.Regex("good"), samplingRatio = 10) + ) + ) + ) + + customSampler.sampleSpan(createMockSpan("a good span")).also { + assertFalse(it.sample) + assertEquals(10L, it.attributes?.get(AttributeKey.longKey("launchdarkly.sampling.ratio"))) + } + customSampler.sampleLog(createMockLog("a good log")).also { + assertFalse(it.sample) + assertEquals(10L, it.attributes?.get(AttributeKey.longKey("launchdarkly.sampling.ratio"))) + } + verify(exactly = 0) { mockSamplerFunction(99) } + } + + @Test + fun `should log a warning once per invalid regex`() { + val logger = mockk(relaxed = true) + val sampler = CustomSampler(mockSamplerFunction, logger) + sampler.setConfig(spanNameConfig("([unclosed")) + + repeat(5) { sampler.sampleSpan(createMockSpan("span-$it")) } + + verify(exactly = 1) { logger.warn(match { it.contains("([unclosed") }, any()) } + } + + @Test + fun `should not log a warning for a valid regex`() { + val logger = mockk(relaxed = true) + every { mockSamplerFunction(42) } returns true + val sampler = CustomSampler(mockSamplerFunction, logger) + sampler.setConfig(spanNameConfig("span-\\d+")) + + repeat(3) { sampler.sampleSpan(createMockSpan("span-$it")) } + + verify(exactly = 0) { logger.warn(any(), any()) } + verify(exactly = 0) { logger.warn(any()) } + } + } + @Nested @DisplayName("Default Sampler Function Tests") inner class DefaultSamplerTests {