Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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<String, Regex>
internal typealias RegexCache = ConcurrentMap<String, CompiledRegex>

/**
* 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
Expand All @@ -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.
*/
Expand All @@ -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
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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<ObserveLogger>(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<Throwable>()) }
}

@Test
fun `should not log a warning for a valid regex`() {
val logger = mockk<ObserveLogger>(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<String>(), any<Throwable>()) }
verify(exactly = 0) { logger.warn(any<String>()) }
}
}

@Nested
@DisplayName("Default Sampler Function Tests")
inner class DefaultSamplerTests {
Expand Down
Loading