From 71a5b05e6025023906b39539ba29dbbddb8a7ce8 Mon Sep 17 00:00:00 2001 From: Vadim Korolik Date: Tue, 20 Oct 2026 16:02:18 +0000 Subject: [PATCH] fix(android): match sampling regexes as substrings and skip invalid patterns CustomSampler used Regex.matches (full-string match), unlike the other SDKs, which do substring matching. It also compiled patterns lazily during span/log processing, so an invalid pattern from the backend threw PatternSyntaxException out of the span processor / log processor. Use containsMatchIn so anchors are needed for full matches, and cache the compilation result per pattern, including failures, so an invalid pattern logs one warning and the rule never matches. Mirrors the Java plugin fix in #381. Co-Authored-By: Claude Opus 5.5 Generated-by: termag --- .../client/ObservabilityService.kt | 2 +- .../observability/sampling/CustomSampler.kt | 30 +++- .../sampling/CustomSamplerTest.kt | 136 ++++++++++++++++++ 3 files changed, 163 insertions(+), 5 deletions(-) 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 {