From 2faa5f58a01636cb91cb3ba8dfaafc83c8a585a3 Mon Sep 17 00:00:00 2001 From: Maksym Ochenashko Date: Sat, 16 Dec 2023 12:19:07 +0200 Subject: [PATCH] sdk-trace: add samplers: `Sampler`, `ParentBasedSampler`, `TraceIdRatioBasedSampler` --- .../trace/samplers/ParentBasedSampler.scala | 164 +++++++++++++++ .../otel4s/sdk/trace/samplers/Sampler.scala | 149 +++++++++++++ .../sdk/trace/samplers/SamplingDecision.scala | 12 +- .../sdk/trace/samplers/SamplingResult.scala | 197 ++++++++++++++++++ .../samplers/TraceIdRatioBasedSampler.scala | 96 +++++++++ .../samplers/ParentBasedSamplerSuite.scala | 175 ++++++++++++++++ .../sdk/trace/samplers/SamplerSuite.scala | 67 ++++++ .../samplers/SamplingDecisionSuite.scala | 34 +-- .../trace/samplers/SamplingResultSuite.scala | 97 +++++++++ .../trace/samplers/ShouldSampleInput.scala | 55 +++++ .../TraceIdRatioBasedSamplerSuite.scala | 85 ++++++++ 11 files changed, 1110 insertions(+), 21 deletions(-) create mode 100644 sdk/trace/src/main/scala/org/typelevel/otel4s/sdk/trace/samplers/ParentBasedSampler.scala create mode 100644 sdk/trace/src/main/scala/org/typelevel/otel4s/sdk/trace/samplers/Sampler.scala create mode 100644 sdk/trace/src/main/scala/org/typelevel/otel4s/sdk/trace/samplers/SamplingResult.scala create mode 100644 sdk/trace/src/main/scala/org/typelevel/otel4s/sdk/trace/samplers/TraceIdRatioBasedSampler.scala create mode 100644 sdk/trace/src/test/scala/org/typelevel/otel4s/sdk/trace/samplers/ParentBasedSamplerSuite.scala create mode 100644 sdk/trace/src/test/scala/org/typelevel/otel4s/sdk/trace/samplers/SamplerSuite.scala create mode 100644 sdk/trace/src/test/scala/org/typelevel/otel4s/sdk/trace/samplers/SamplingResultSuite.scala create mode 100644 sdk/trace/src/test/scala/org/typelevel/otel4s/sdk/trace/samplers/ShouldSampleInput.scala create mode 100644 sdk/trace/src/test/scala/org/typelevel/otel4s/sdk/trace/samplers/TraceIdRatioBasedSamplerSuite.scala diff --git a/sdk/trace/src/main/scala/org/typelevel/otel4s/sdk/trace/samplers/ParentBasedSampler.scala b/sdk/trace/src/main/scala/org/typelevel/otel4s/sdk/trace/samplers/ParentBasedSampler.scala new file mode 100644 index 000000000..977832cd8 --- /dev/null +++ b/sdk/trace/src/main/scala/org/typelevel/otel4s/sdk/trace/samplers/ParentBasedSampler.scala @@ -0,0 +1,164 @@ +/* + * Copyright 2023 Typelevel + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.typelevel.otel4s.sdk.trace.samplers + +import org.typelevel.otel4s.sdk.Attributes +import org.typelevel.otel4s.sdk.trace.data.LinkData +import org.typelevel.otel4s.trace.SpanContext +import org.typelevel.otel4s.trace.SpanKind +import scodec.bits.ByteVector + +/** Sampler that uses the sampled flag of the parent Span, if present. + * + * If the span has no parent, this Sampler will use the "root" sampler that it + * is built with. + * + * @see + * [[https://opentelemetry.io/docs/specs/otel/trace/sdk/#parentbased]] + */ +private[samplers] final class ParentBasedSampler private ( + root: Sampler, + remoteParentSampled: Sampler, + remoteParentNotSampled: Sampler, + localParentSampled: Sampler, + localParentNotSampled: Sampler +) extends Sampler { + + def shouldSample( + parentContext: Option[SpanContext], + traceId: ByteVector, + name: String, + spanKind: SpanKind, + attributes: Attributes, + parentLinks: List[LinkData] + ): SamplingResult = { + val sampler = parentContext.filter(_.isValid) match { + case Some(ctx) if ctx.isRemote => + if (ctx.isSampled) remoteParentSampled else remoteParentNotSampled + + case Some(ctx) => + if (ctx.isSampled) localParentSampled else localParentNotSampled + + case None => + root + } + + sampler.shouldSample( + parentContext, + traceId, + name, + spanKind, + attributes, + parentLinks + ) + } + + val description: String = + s"ParentBased{root=$root, " + + s"remoteParentSampled=$remoteParentSampled, " + + s"remoteParentNotSampled=$remoteParentNotSampled, " + + s"localParentSampled=$localParentSampled, " + + s"localParentNotSampled=$localParentNotSampled}" +} + +object ParentBasedSampler { + + /** Creates a [[Builder]] for the parent-based sampler that enables + * configuration of the parent-based sampling strategy. + * + * The parent's sampling decision is used if a parent span exists, otherwise + * this strategy uses the root sampler's decision. + * + * There are a several options available on the builder to control the + * precise behavior of how the decision will be made. + * + * @param root + * the [[Sampler]] which is used to make the sampling decisions if the + * parent does not exist + */ + def builder(root: Sampler): Builder = + BuilderImpl(root, None, None, None, None) + + /** A builder for creating parent-based sampler. + */ + sealed trait Builder { + + /** Assigns the [[Sampler]] to use when there is a remote parent that was + * sampled. + * + * If not set, defaults to always sampling if the remote parent was + * sampled. + */ + def withRemoteParentSampled(sampler: Sampler): Builder + + /** Assigns the [[Sampler]] to use when there is a remote parent that was + * not sampled. + * + * If not set, defaults to never sampling when the remote parent isn't + * sampled. + */ + def withRemoteParentNotSampled(sampler: Sampler): Builder + + /** Assigns the [[Sampler]] to use when there is a local parent that was + * sampled. + * + * If not set, defaults to always sampling if the local parent was sampled. + */ + def withLocalParentSampled(sampler: Sampler): Builder + + /** Assigns the [[Sampler]] to use when there is a local parent that was not + * sampled. + * + * If not set, defaults to never sampling when the local parent isn't + * sampled. + */ + def withLocalParentNotSampled(sampler: Sampler): Builder + + /** Creates a parent-based sampler using the configuration of this builder. + */ + def build: Sampler + } + + private final case class BuilderImpl( + root: Sampler, + remoteParentSampled: Option[Sampler], + remoteParentNotSampled: Option[Sampler], + localParentSampled: Option[Sampler], + localParentNotSampled: Option[Sampler] + ) extends Builder { + def withRemoteParentSampled(sampler: Sampler): Builder = + copy(remoteParentSampled = Some(sampler)) + + def withRemoteParentNotSampled(sampler: Sampler): Builder = + copy(remoteParentNotSampled = Some(sampler)) + + def withLocalParentSampled(sampler: Sampler): Builder = + copy(localParentSampled = Some(sampler)) + + def withLocalParentNotSampled(sampler: Sampler): Builder = + copy(localParentNotSampled = Some(sampler)) + + def build: Sampler = + new ParentBasedSampler( + root, + remoteParentSampled.getOrElse(Sampler.AlwaysOn), + remoteParentNotSampled.getOrElse(Sampler.AlwaysOff), + localParentSampled.getOrElse(Sampler.AlwaysOn), + localParentNotSampled.getOrElse(Sampler.AlwaysOff) + ) + } +} diff --git a/sdk/trace/src/main/scala/org/typelevel/otel4s/sdk/trace/samplers/Sampler.scala b/sdk/trace/src/main/scala/org/typelevel/otel4s/sdk/trace/samplers/Sampler.scala new file mode 100644 index 000000000..d0abc59b9 --- /dev/null +++ b/sdk/trace/src/main/scala/org/typelevel/otel4s/sdk/trace/samplers/Sampler.scala @@ -0,0 +1,149 @@ +/* + * Copyright 2023 Typelevel + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.typelevel.otel4s.sdk +package trace +package samplers + +import org.typelevel.otel4s.sdk.trace.data.LinkData +import org.typelevel.otel4s.trace.SpanContext +import org.typelevel.otel4s.trace.SpanKind +import scodec.bits.ByteVector + +/** A Sampler is used to make decisions on Span sampling. + * + * @see + * [[https://opentelemetry.io/docs/specs/otel/trace/sdk/#sampler]] + */ +trait Sampler { + + /** Called during span creation to make a sampling result. + * + * @param parentContext + * the parent's span context. `None` means there is no parent + * + * @param traceId + * the trace id of the new span + * + * @param name + * the name of the new span + * + * @param spanKind + * the [[org.typelevel.otel4s.trace.SpanKind SpanKind]] of the new span + * + * @param attributes + * the [[Attributes]] associated with the new span + * + * @param parentLinks + * the list of parent links associated with the span + */ + def shouldSample( + parentContext: Option[SpanContext], + traceId: ByteVector, + name: String, + spanKind: SpanKind, + attributes: Attributes, + parentLinks: List[LinkData] + ): SamplingResult + + /** The description of the [[Sampler]]. This may be displayed on debug pages + * or in the logs. + */ + def description: String + + override final def toString: String = description +} + +object Sampler { + + /** Always returns the [[SamplingResult.RecordAndSample]]. + * + * @see + * [[https://opentelemetry.io/docs/specs/otel/trace/sdk/#alwayson]] + */ + val AlwaysOn: Sampler = + new Const(SamplingResult.RecordAndSample, "AlwaysOnSampler") + + /** Always returns the [[SamplingResult.Drop]]. + * + * @see + * [[https://opentelemetry.io/docs/specs/otel/trace/sdk/#alwaysoff]] + */ + val AlwaysOff: Sampler = + new Const(SamplingResult.Drop, "AlwaysOffSampler") + + /** Returns a [[Sampler]] that always makes the same decision as the parent + * Span to whether or not to sample. + * + * If there is no parent, the sampler uses the provided root [[Sampler]] to + * determine the sampling decision. + * + * @param root + * the [[Sampler]] which is used to make the sampling decisions if the + * parent does not exist + */ + def parentBased(root: Sampler): Sampler = + parentBasedBuilder(root).build + + /** Creates a [[ParentBasedSampler.Builder]] for parent-based sampler that + * enables configuration of the parent-based sampling strategy. + * + * The parent's sampling decision is used if a parent span exists, otherwise + * this strategy uses the root sampler's decision. + * + * There are a several options available on the builder to control the + * precise behavior of how the decision will be made. + * + * @param root + * the [[Sampler]] which is used to make the sampling decisions if the + * parent does not exist + */ + def parentBasedBuilder(root: Sampler): ParentBasedSampler.Builder = + ParentBasedSampler.builder(root) + + /** Creates a new ratio-based sampler. + * + * The ratio of sampling a trace is equal to that of the specified ratio. + * + * The algorithm used by the Sampler is undefined, notably it may or may not + * use parts of the trace ID when generating a sampling decision. + * + * Currently, only the ratio of traces that are sampled can be relied on, not + * how the sampled traces are determined. As such, it is recommended to only + * use this [[Sampler]] for root spans using [[parentBased]]. + * + * @param ratio + * the desired ratio of sampling. Must be >= 0 and <= 1.0. + */ + def traceIdRatioBased(ratio: Double): Sampler = + TraceIdRatioBasedSampler.create(ratio) + + private final class Const( + result: SamplingResult, + val description: String + ) extends Sampler { + def shouldSample( + parentContext: Option[SpanContext], + traceId: ByteVector, + name: String, + spanKind: SpanKind, + attributes: Attributes, + parentLinks: List[LinkData] + ): SamplingResult = + result + } + +} diff --git a/sdk/trace/src/main/scala/org/typelevel/otel4s/sdk/trace/samplers/SamplingDecision.scala b/sdk/trace/src/main/scala/org/typelevel/otel4s/sdk/trace/samplers/SamplingDecision.scala index c9b6be038..d53a71bbe 100644 --- a/sdk/trace/src/main/scala/org/typelevel/otel4s/sdk/trace/samplers/SamplingDecision.scala +++ b/sdk/trace/src/main/scala/org/typelevel/otel4s/sdk/trace/samplers/SamplingDecision.scala @@ -21,23 +21,25 @@ import cats.Show /** A decision on whether a span should be recorded, sampled, or dropped. */ -sealed abstract class SamplingDecision(val isSampled: Boolean) - extends Product +sealed abstract class SamplingDecision( + val isSampled: Boolean, + val isRecording: Boolean +) extends Product with Serializable object SamplingDecision { /** The span is not recorded, and all events and attributes will be dropped. */ - case object Drop extends SamplingDecision(false) + case object Drop extends SamplingDecision(false, false) /** The span is recorded, but the Sampled flag will not be set. */ - case object RecordOnly extends SamplingDecision(false) + case object RecordOnly extends SamplingDecision(false, true) /** The span is recorded, and the Sampled flag will be set. */ - case object RecordAndSample extends SamplingDecision(true) + case object RecordAndSample extends SamplingDecision(true, true) implicit val samplingDecisionHash: Hash[SamplingDecision] = Hash.fromUniversalHashCode diff --git a/sdk/trace/src/main/scala/org/typelevel/otel4s/sdk/trace/samplers/SamplingResult.scala b/sdk/trace/src/main/scala/org/typelevel/otel4s/sdk/trace/samplers/SamplingResult.scala new file mode 100644 index 000000000..ace931718 --- /dev/null +++ b/sdk/trace/src/main/scala/org/typelevel/otel4s/sdk/trace/samplers/SamplingResult.scala @@ -0,0 +1,197 @@ +/* + * Copyright 2023 Typelevel + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.typelevel.otel4s.sdk.trace.samplers + +import cats.Hash +import cats.Show +import cats.syntax.show._ +import org.typelevel.otel4s.sdk.Attributes +import org.typelevel.otel4s.trace.TraceState + +/** Sampling result returned by [[Sampler.shouldSample]]. + * + * @see + * [[https://opentelemetry.io/docs/specs/otel/trace/sdk/#shouldsample]] + */ +sealed trait SamplingResult { + + /** The decision on whether a span should be recorded, recorded and sampled or + * not recorded. + */ + def decision: SamplingDecision + + /** The attributes that will be attached to the span. + */ + def attributes: Attributes + + /** A modifier of the parent's TraceState. + * + * It may return the same trace state that was provided originally, or an + * updated one. + * + * @note + * if updated returns an empty trace state, the trace state will be + * cleared, so samplers should normally use + * [[SamplingResult.TraceStateUpdater.Identity]] to return the passed-in + * trace state if it's not intended to be changed. + */ + def traceStateUpdater: SamplingResult.TraceStateUpdater + + override final def hashCode(): Int = + Hash[SamplingResult].hash(this) + + override final def equals(obj: Any): Boolean = + obj match { + case other: SamplingResult => Hash[SamplingResult].eqv(this, other) + case _ => false + } + + override final def toString: String = + Show[SamplingResult].show(this) +} + +object SamplingResult { + + /** A modifier of the parent's TraceState. + * + * It may return the same trace state that was provided originally, or an + * updated one. + * + * @note + * if an empty trace state is returned, the trace state will be cleared, so + * the updater should normally return the passed-in trace state (via + * [[TraceStateUpdater.Identity]]) if it's intended to be changed. + */ + sealed trait TraceStateUpdater { + def update(state: TraceState): TraceState + } + + object TraceStateUpdater { + + /** Always returns the given trace state without modifications. */ + case object Identity extends TraceStateUpdater { + def update(state: TraceState): TraceState = state + } + + /** Returns the given trace state modified by the `modify` function. */ + final case class Modifier(modify: TraceState => TraceState) + extends TraceStateUpdater { + def update(state: TraceState): TraceState = + modify(state) + } + + /** Always returns the `const` state. No matter the input. */ + final case class Const(const: TraceState) extends TraceStateUpdater { + def update(state: TraceState): TraceState = const + } + + implicit val traceStateUpdaterHash: Hash[TraceStateUpdater] = + Hash.fromUniversalHashCode + + implicit val traceStateUpdaterShow: Show[TraceStateUpdater] = + Show.show { + case Identity => "Identity" + case Modifier(_) => "Modifier(f)" + case Const(const) => show"Const($const)" + } + } + + /** The [[SamplingResult]] with the [[SamplingDecision.RecordAndSample]] + * decision, no attributes, and [[TraceStateUpdater.Identity]] updater. + */ + val RecordAndSample: SamplingResult = + fromDecision(SamplingDecision.RecordAndSample) + + /** The [[SamplingResult]] with the [[SamplingDecision.RecordOnly]] decision, + * no attributes, and [[TraceStateUpdater.Identity]] updater. + */ + val RecordOnly: SamplingResult = + fromDecision(SamplingDecision.RecordOnly) + + /** The [[SamplingResult]] with the [[SamplingDecision.Drop]] decision, no + * attributes, and [[TraceStateUpdater.Identity]] updater. + */ + val Drop: SamplingResult = + fromDecision(SamplingDecision.Drop) + + /** Creates a [[SamplingResult]] with the given `decision`. + * + * @param decision + * the [[SamplingDecision]] to associate with the result + */ + def apply(decision: SamplingDecision): SamplingResult = + decision match { + case SamplingDecision.RecordAndSample => RecordAndSample + case SamplingDecision.RecordOnly => RecordOnly + case SamplingDecision.Drop => Drop + } + + /** Creates a [[SamplingResult]] with the given `decision` and `attributes`. + * + * @param decision + * the [[SamplingDecision]] to associate with the result + * + * @param attributes + * the [[Attributes]] to associate with the result + */ + def apply( + decision: SamplingDecision, + attributes: Attributes + ): SamplingResult = + if (attributes.isEmpty) apply(decision) + else Impl(decision, attributes, TraceStateUpdater.Identity) + + /** Creates a [[SamplingResult]] with the given `decision`, `attributes`, and + * `traceStateUpdater`. + * + * @param decision + * the [[SamplingDecision]] to associate with the result + * + * @param attributes + * the [[Attributes]] to associate with the result + * + * @param traceStateUpdater + * the [[TraceStateUpdater]] to associate with the result + */ + def apply( + decision: SamplingDecision, + attributes: Attributes, + traceStateUpdater: TraceStateUpdater + ): SamplingResult = + if (traceStateUpdater == TraceStateUpdater.Identity) + apply(decision, attributes) + else + Impl(decision, attributes, traceStateUpdater) + + implicit val samplingResultHash: Hash[SamplingResult] = + Hash.by(r => (r.decision, r.attributes, r.traceStateUpdater)) + + implicit val samplingResultShow: Show[SamplingResult] = + Show.show { r => + show"SamplingResult{decision=${r.decision}, attributes=${r.attributes}, traceStateUpdater=${r.traceStateUpdater}}" + } + + private def fromDecision(decision: SamplingDecision): SamplingResult = + Impl(decision, Attributes.empty, TraceStateUpdater.Identity) + + private final case class Impl( + decision: SamplingDecision, + attributes: Attributes, + traceStateUpdater: TraceStateUpdater + ) extends SamplingResult + +} diff --git a/sdk/trace/src/main/scala/org/typelevel/otel4s/sdk/trace/samplers/TraceIdRatioBasedSampler.scala b/sdk/trace/src/main/scala/org/typelevel/otel4s/sdk/trace/samplers/TraceIdRatioBasedSampler.scala new file mode 100644 index 000000000..3e993b773 --- /dev/null +++ b/sdk/trace/src/main/scala/org/typelevel/otel4s/sdk/trace/samplers/TraceIdRatioBasedSampler.scala @@ -0,0 +1,96 @@ +/* + * Copyright 2023 Typelevel + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.typelevel.otel4s.sdk +package trace +package samplers + +import org.typelevel.otel4s.sdk.trace.data.LinkData +import org.typelevel.otel4s.trace.SpanContext +import org.typelevel.otel4s.trace.SpanKind +import scodec.bits.ByteVector + +/** The input `ratio` must be between 0 and 1.0. + * + * 0.1 means only 10% of the incoming spans will be sampled, and 1.0 stands for + * 100%. + * + * The ratio-based sampler must be deterministic, so it utilizes the Long value + * extracted from the trace id. + * + * The logic is the following: the sampling result will be + * [[SamplingResult.RecordAndSample]] if the extracted long value is lower than + * the upper bound limit (`ratio` * Long.MaxValue). + * + * @see + * [[https://opentelemetry.io/docs/specs/otel/trace/sdk/#traceidratiobased]] + */ +private[samplers] final class TraceIdRatioBasedSampler private ( + ratio: Double, + idUpperBound: Long +) extends Sampler { + + def shouldSample( + parentContext: Option[SpanContext], + traceId: ByteVector, + name: String, + spanKind: SpanKind, + attributes: Attributes, + parentLinks: List[LinkData] + ): SamplingResult = + if (math.abs(traceIdRandomPart(traceId)) < idUpperBound) + SamplingResult.RecordAndSample + else + SamplingResult.Drop + + private def traceIdRandomPart(traceId: ByteVector): Long = + traceId.drop(8).toLong() + + // format as: 0.000000 + val description: String = f"TraceIdRatioBased{$ratio%.6f}".replace(",", ".") +} + +private[samplers] object TraceIdRatioBasedSampler { + + /** Creates a new [[TraceIdRatioBasedSampler]] Sampler. + * + * The ratio of sampling a trace is equal to that of the specified `ratio`. + * + * 0.1 means only 10% of the incoming spans will be sampled, and 1.0 stands + * for 100%. + * + * The ratio-based sampler must be deterministic, so it utilizes the Long + * value extracted from the trace id. + * + * The logic is the following: the sampling result will be + * [[SamplingResult.RecordAndSample]] if the extracted long value is lower + * than the upper bound limit (`ratio` * Long.MaxValue). + * + * @param ratio + * the desired ratio of sampling. Must be >= 0 and <= 1.0. + */ + def create(ratio: Double): Sampler = { + require(ratio >= 0 && ratio <= 1.0, "ratio must be >= 0 and <= 1.0") + + val idUpperBound = + if (ratio == 0.0) Long.MinValue + else if (ratio == 1.0) Long.MaxValue + else (ratio * Long.MaxValue).toLong + + new TraceIdRatioBasedSampler(ratio, idUpperBound) + } + +} diff --git a/sdk/trace/src/test/scala/org/typelevel/otel4s/sdk/trace/samplers/ParentBasedSamplerSuite.scala b/sdk/trace/src/test/scala/org/typelevel/otel4s/sdk/trace/samplers/ParentBasedSamplerSuite.scala new file mode 100644 index 000000000..fcc51b881 --- /dev/null +++ b/sdk/trace/src/test/scala/org/typelevel/otel4s/sdk/trace/samplers/ParentBasedSamplerSuite.scala @@ -0,0 +1,175 @@ +/* + * Copyright 2023 Typelevel + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.typelevel.otel4s.sdk.trace +package samplers + +import munit._ +import org.scalacheck.Gen +import org.scalacheck.Prop + +class ParentBasedSamplerSuite extends ScalaCheckSuite { + + private val samplerGen: Gen[Sampler] = + Gen.oneOf(Sampler.AlwaysOn, Sampler.AlwaysOff) + + private val samplerChoiceGen: Gen[SamplerChoice] = + Gen.oneOf(SamplerChoice.AlwaysOn, SamplerChoice.AlwaysOff) + + test("created with root sampler - has correct description and toString") { + Prop.forAll(samplerGen) { root => + val sampler = Sampler.parentBased(root) + + val expected = + s"ParentBased{root=$root, " + + "remoteParentSampled=AlwaysOnSampler, " + + "remoteParentNotSampled=AlwaysOffSampler, " + + "localParentSampled=AlwaysOnSampler, " + + "localParentNotSampled=AlwaysOffSampler}" + + assertEquals(sampler.description, expected) + assertEquals(sampler.toString, expected) + } + } + + test("created with root sampler - returns correct sampling result") { + Prop.forAll(samplerChoiceGen, ShouldSampleInput.shouldSampleInputGen) { + (choice, input) => + val sampler = Sampler.parentBased(choice.sampler) + + val expected = input.parentContext match { + // valid remote parent + case Some(parent) if parent.isValid && parent.isRemote => + if (parent.isSampled) SamplingResult.RecordAndSample + else SamplingResult.Drop + + // valid local parent + case Some(parent) if parent.isValid => + if (parent.isSampled) SamplingResult.RecordAndSample + else SamplingResult.Drop + + case _ => + choice.result + } + + val result = sampler.shouldSample( + input.parentContext, + input.traceId, + input.name, + input.spanKind, + input.attributes, + input.parentLinks + ) + + assertEquals(result, expected) + } + } + + test("created with all samplers - has correct description and toString") { + Prop.forAll(samplerGen, samplerGen, samplerGen, samplerGen, samplerGen) { + ( + root, + remoteParentSampled, + remoteParentNotSampled, + localParentSampled, + localParentNotSampled + ) => + val sampler = Sampler + .parentBasedBuilder(root) + .withRemoteParentSampled(remoteParentSampled) + .withRemoteParentNotSampled(remoteParentNotSampled) + .withLocalParentSampled(localParentSampled) + .withLocalParentNotSampled(localParentNotSampled) + .build + + val expected = + s"ParentBased{root=$root, " + + s"remoteParentSampled=$remoteParentSampled, " + + s"remoteParentNotSampled=$remoteParentNotSampled, " + + s"localParentSampled=$localParentSampled, " + + s"localParentNotSampled=$localParentNotSampled}" + + assertEquals(sampler.description, expected) + assertEquals(sampler.toString, expected) + } + } + + test("created with all samplers - returns correct sampling result") { + Prop.forAll( + samplerChoiceGen, + samplerChoiceGen, + samplerChoiceGen, + samplerChoiceGen, + samplerChoiceGen, + ShouldSampleInput.shouldSampleInputGen + ) { + ( + root, + remoteParentSampled, + remoteParentNotSampled, + localParentSampled, + localParentNotSampled, + input + ) => + val sampler = Sampler + .parentBasedBuilder(root.sampler) + .withRemoteParentSampled(remoteParentSampled.sampler) + .withRemoteParentNotSampled(remoteParentNotSampled.sampler) + .withLocalParentSampled(localParentSampled.sampler) + .withLocalParentNotSampled(localParentNotSampled.sampler) + .build + + val expected = input.parentContext match { + // valid remote parent + case Some(parent) if parent.isValid && parent.isRemote => + if (parent.isSampled) remoteParentSampled.result + else remoteParentNotSampled.result + + // valid local parent + case Some(parent) if parent.isValid => + if (parent.isSampled) localParentSampled.result + else localParentNotSampled.result + + case _ => + root.result + } + + val result = sampler.shouldSample( + input.parentContext, + input.traceId, + input.name, + input.spanKind, + input.attributes, + input.parentLinks + ) + + assertEquals(result, expected) + } + } + + sealed abstract class SamplerChoice( + val sampler: Sampler, + val result: SamplingResult + ) + + object SamplerChoice { + case object AlwaysOn + extends SamplerChoice(Sampler.AlwaysOn, SamplingResult.RecordAndSample) + + case object AlwaysOff + extends SamplerChoice(Sampler.AlwaysOff, SamplingResult.Drop) + } +} diff --git a/sdk/trace/src/test/scala/org/typelevel/otel4s/sdk/trace/samplers/SamplerSuite.scala b/sdk/trace/src/test/scala/org/typelevel/otel4s/sdk/trace/samplers/SamplerSuite.scala new file mode 100644 index 000000000..4e9e187dc --- /dev/null +++ b/sdk/trace/src/test/scala/org/typelevel/otel4s/sdk/trace/samplers/SamplerSuite.scala @@ -0,0 +1,67 @@ +/* + * Copyright 2023 Typelevel + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.typelevel.otel4s.sdk.trace +package samplers + +import munit._ +import org.scalacheck.Prop + +class SamplerSuite extends ScalaCheckSuite { + + test("AlwaysOn - correct description and toString") { + assertEquals(Sampler.AlwaysOn.toString, "AlwaysOnSampler") + assertEquals(Sampler.AlwaysOn.description, "AlwaysOnSampler") + } + + test("AlwaysOn - always return 'RecordAndSample'") { + Prop.forAll(ShouldSampleInput.shouldSampleInputGen) { input => + val expected = SamplingResult.RecordAndSample + val result = Sampler.AlwaysOn.shouldSample( + input.parentContext, + input.traceId, + input.name, + input.spanKind, + input.attributes, + input.parentLinks + ) + + assertEquals(result, expected) + } + } + + test("AlwaysOff - correct description and toString") { + assertEquals(Sampler.AlwaysOff.toString, "AlwaysOffSampler") + assertEquals(Sampler.AlwaysOff.description, "AlwaysOffSampler") + } + + test("AlwaysOff - always return 'Drop'") { + Prop.forAll(ShouldSampleInput.shouldSampleInputGen) { input => + val expected = SamplingResult.Drop + val result = Sampler.AlwaysOff.shouldSample( + input.parentContext, + input.traceId, + input.name, + input.spanKind, + input.attributes, + input.parentLinks + ) + + assertEquals(result, expected) + } + } + +} diff --git a/sdk/trace/src/test/scala/org/typelevel/otel4s/sdk/trace/samplers/SamplingDecisionSuite.scala b/sdk/trace/src/test/scala/org/typelevel/otel4s/sdk/trace/samplers/SamplingDecisionSuite.scala index d3b178302..0f49c0458 100644 --- a/sdk/trace/src/test/scala/org/typelevel/otel4s/sdk/trace/samplers/SamplingDecisionSuite.scala +++ b/sdk/trace/src/test/scala/org/typelevel/otel4s/sdk/trace/samplers/SamplingDecisionSuite.scala @@ -14,35 +14,25 @@ * limitations under the License. */ -package org.typelevel.otel4s.sdk.trace.samplers +package org.typelevel.otel4s.sdk.trace +package samplers import cats.Show import cats.kernel.laws.discipline.HashTests import munit._ import org.scalacheck.Arbitrary -import org.scalacheck.Cogen -import org.scalacheck.Gen import org.scalacheck.Prop class SamplingDecisionSuite extends DisciplineSuite { - - private val samplingDecisionGen: Gen[SamplingDecision] = - Gen.oneOf( - SamplingDecision.Drop, - SamplingDecision.RecordOnly, - SamplingDecision.RecordAndSample - ) + import Cogens.samplingDecisionCogen private implicit val samplingDecisionArbitrary: Arbitrary[SamplingDecision] = - Arbitrary(samplingDecisionGen) - - private implicit val samplingDecisionCogen: Cogen[SamplingDecision] = - Cogen[String].contramap(_.toString) + Arbitrary(Gens.samplingDecision) checkAll("SamplingDecision.HashLaws", HashTests[SamplingDecision].hash) property("is sampled") { - Prop.forAll(samplingDecisionGen) { decision => + Prop.forAll(Gens.samplingDecision) { decision => val expected = decision match { case SamplingDecision.Drop => false case SamplingDecision.RecordOnly => false @@ -53,8 +43,20 @@ class SamplingDecisionSuite extends DisciplineSuite { } } + property("is recording") { + Prop.forAll(Gens.samplingDecision) { decision => + val expected = decision match { + case SamplingDecision.Drop => false + case SamplingDecision.RecordOnly => true + case SamplingDecision.RecordAndSample => true + } + + assertEquals(decision.isRecording, expected) + } + } + property("Show[SamplingDecision]") { - Prop.forAll(samplingDecisionGen) { decision => + Prop.forAll(Gens.samplingDecision) { decision => val expected = decision match { case SamplingDecision.Drop => "Drop" case SamplingDecision.RecordOnly => "RecordOnly" diff --git a/sdk/trace/src/test/scala/org/typelevel/otel4s/sdk/trace/samplers/SamplingResultSuite.scala b/sdk/trace/src/test/scala/org/typelevel/otel4s/sdk/trace/samplers/SamplingResultSuite.scala new file mode 100644 index 000000000..078a46394 --- /dev/null +++ b/sdk/trace/src/test/scala/org/typelevel/otel4s/sdk/trace/samplers/SamplingResultSuite.scala @@ -0,0 +1,97 @@ +/* + * Copyright 2023 Typelevel + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.typelevel.otel4s.sdk +package trace +package samplers + +import cats.Show +import cats.kernel.laws.discipline.HashTests +import cats.syntax.show._ +import munit.DisciplineSuite +import org.scalacheck.Arbitrary +import org.scalacheck.Cogen +import org.scalacheck.Gen +import org.scalacheck.Prop + +class SamplingResultSuite extends DisciplineSuite { + import SamplingResult.TraceStateUpdater + import Cogens.attributesCogen + import Cogens.samplingDecisionCogen + + private val samplingResultGen: Gen[SamplingResult] = + for { + decision <- Gens.samplingDecision + attributes <- Gens.attributes + } yield SamplingResult(decision, attributes) + + private implicit val samplingResultArbitrary: Arbitrary[SamplingResult] = + Arbitrary(samplingResultGen) + + private implicit val samplingResultCogen: Cogen[SamplingResult] = + Cogen[(SamplingDecision, Attributes)].contramap { result => + (result.decision, result.attributes) + } + + checkAll("SamplingResult.HashLaws", HashTests[SamplingResult].hash) + + property("Show[SamplingResult]") { + Prop.forAll(samplingResultGen) { result => + val expected = + show"SamplingResult{decision=${result.decision}, attributes=${result.attributes}, traceStateUpdater=${result.traceStateUpdater}}" + + assertEquals(Show[SamplingResult].show(result), expected) + } + } + + property("use const instances when given attributes are empty") { + Prop.forAll(Gens.samplingDecision) { decision => + val expected = decision match { + case SamplingDecision.Drop => SamplingResult.Drop + case SamplingDecision.RecordOnly => SamplingResult.RecordOnly + case SamplingDecision.RecordAndSample => SamplingResult.RecordAndSample + } + + assertEquals(SamplingResult(decision), expected) + assertEquals(SamplingResult(decision, Attributes.empty), expected) + assertEquals( + SamplingResult(decision, Attributes.empty, TraceStateUpdater.Identity), + expected + ) + } + } + + property("create an instance") { + Prop.forAll(Gens.samplingDecision, Gens.attributes) { (decision, attrs) => + val result = SamplingResult(decision, attrs) + assertEquals(result.decision, decision) + assertEquals(result.attributes, attrs) + } + } + + test("defaults have empty attributes and identity modifier") { + val all = Seq( + SamplingResult.Drop, + SamplingResult.RecordOnly, + SamplingResult.RecordAndSample + ) + + all.foreach { result => + assertEquals(result.attributes, Attributes.empty) + assertEquals(result.traceStateUpdater, TraceStateUpdater.Identity) + } + } +} diff --git a/sdk/trace/src/test/scala/org/typelevel/otel4s/sdk/trace/samplers/ShouldSampleInput.scala b/sdk/trace/src/test/scala/org/typelevel/otel4s/sdk/trace/samplers/ShouldSampleInput.scala new file mode 100644 index 000000000..effbdddd1 --- /dev/null +++ b/sdk/trace/src/test/scala/org/typelevel/otel4s/sdk/trace/samplers/ShouldSampleInput.scala @@ -0,0 +1,55 @@ +/* + * Copyright 2023 Typelevel + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.typelevel.otel4s.sdk.trace +package samplers + +import org.scalacheck.Gen +import org.typelevel.otel4s.sdk.Attributes +import org.typelevel.otel4s.sdk.trace.data.LinkData +import org.typelevel.otel4s.trace.SpanContext +import org.typelevel.otel4s.trace.SpanKind +import scodec.bits.ByteVector + +final case class ShouldSampleInput( + parentContext: Option[SpanContext], + traceId: ByteVector, + name: String, + spanKind: SpanKind, + attributes: Attributes, + parentLinks: List[LinkData] +) + +object ShouldSampleInput { + + val shouldSampleInputGen: Gen[ShouldSampleInput] = + for { + parentContext <- Gen.option(Gens.spanContext) + traceId <- Gens.traceId + name <- Gen.alphaNumStr + kind <- Gens.spanKind + attributes <- Gens.attributes + parentLinks <- Gen.listOf(Gens.linkData) + } yield ShouldSampleInput( + parentContext, + traceId, + name, + kind, + attributes, + parentLinks + ) + +} diff --git a/sdk/trace/src/test/scala/org/typelevel/otel4s/sdk/trace/samplers/TraceIdRatioBasedSamplerSuite.scala b/sdk/trace/src/test/scala/org/typelevel/otel4s/sdk/trace/samplers/TraceIdRatioBasedSamplerSuite.scala new file mode 100644 index 000000000..f472bdb27 --- /dev/null +++ b/sdk/trace/src/test/scala/org/typelevel/otel4s/sdk/trace/samplers/TraceIdRatioBasedSamplerSuite.scala @@ -0,0 +1,85 @@ +/* + * Copyright 2023 Typelevel + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.typelevel.otel4s.sdk.trace +package samplers + +import munit._ +import org.scalacheck.Gen +import org.scalacheck.Prop + +class TraceIdRatioBasedSamplerSuite extends ScalaCheckSuite { + + test("has correct description and toString") { + Prop.forAll(Gen.double) { ratio => + val sampler = Sampler.traceIdRatioBased(ratio) + val expected = f"TraceIdRatioBased{$ratio%.6f}".replace(",", ".") + + assertEquals(sampler.description, expected) + assertEquals(sampler.toString, expected) + } + } + + test("return 'RecordAndSample' when ratio = 1.0") { + Prop.forAll(ShouldSampleInput.shouldSampleInputGen) { input => + val sampler = Sampler.traceIdRatioBased(1.0) + val expected = SamplingResult.RecordAndSample + + val result = sampler.shouldSample( + input.parentContext, + input.traceId, + input.name, + input.spanKind, + input.attributes, + input.parentLinks + ) + + assertEquals(result, expected) + } + } + + test("return 'Drop' when ratio = 0.0") { + Prop.forAll(ShouldSampleInput.shouldSampleInputGen) { input => + val sampler = Sampler.traceIdRatioBased(0.0) + val expected = SamplingResult.Drop + + val result = sampler.shouldSample( + input.parentContext, + input.traceId, + input.name, + input.spanKind, + input.attributes, + input.parentLinks + ) + + assertEquals(result, expected) + } + } + + test("throw an error when ratio is out of range") { + val negative = Gen.negNum[Double] + val positive = Gen.posNum[Double].suchThat(_ > 1.0) + + Prop.forAll(Gen.oneOf(negative, positive)) { ratio => + val _ = interceptMessage[Throwable]( + "requirement failed: ratio must be >= 0 and <= 1.0" + )( + Sampler.traceIdRatioBased(ratio) + ) + } + } + +}