From 51ec4bcf87c433a130c60ce6cee0dcd7845d00ae Mon Sep 17 00:00:00 2001 From: Maksym Ochenashko Date: Sat, 7 Mar 2026 09:58:29 +0200 Subject: [PATCH] core-common: improve ergonomics of the attribute API --- .../AttributesScalaVersionCompanion.scala | 25 +++ .../AttributesScalaVersionCompanion.scala | 44 +++++ .../org/typelevel/otel4s/Attributes.scala | 6 +- .../typelevel/otel4s/AttributesSuite.scala | 77 +++++++++ .../otel4s/metrics/CounterMacro.scala | 12 +- .../typelevel/otel4s/metrics/GaugeMacro.scala | 6 +- .../otel4s/metrics/HistogramMacro.scala | 14 +- .../otel4s/metrics/UpDownCounterMacro.scala | 18 +- .../otel4s/metrics/MetricsMacroSuite.scala | 160 ++++++++++++++++++ .../otel4s/trace/SpanBuilderMacro.scala | 18 +- .../typelevel/otel4s/trace/SpanMacro.scala | 40 +++-- .../typelevel/otel4s/trace/TracerMacro.scala | 8 +- .../otel4s/trace/SpanMacroSuite.scala | 110 ++++++++++++ .../otel4s/trace/TracerMacroSuite.scala | 119 +++++++++++++ 14 files changed, 600 insertions(+), 57 deletions(-) create mode 100644 core/common/src/main/scala-2/org/typelevel/otel4s/AttributesScalaVersionCompanion.scala create mode 100644 core/common/src/main/scala-3/org/typelevel/otel4s/AttributesScalaVersionCompanion.scala create mode 100644 core/common/src/test/scala-3/org/typelevel/otel4s/AttributesSuite.scala create mode 100644 core/metrics/src/test/scala-3/org/typelevel/otel4s/metrics/MetricsMacroSuite.scala create mode 100644 core/trace/src/test/scala-3/org/typelevel/otel4s/trace/SpanMacroSuite.scala create mode 100644 core/trace/src/test/scala-3/org/typelevel/otel4s/trace/TracerMacroSuite.scala diff --git a/core/common/src/main/scala-2/org/typelevel/otel4s/AttributesScalaVersionCompanion.scala b/core/common/src/main/scala-2/org/typelevel/otel4s/AttributesScalaVersionCompanion.scala new file mode 100644 index 000000000..e4c498fec --- /dev/null +++ b/core/common/src/main/scala-2/org/typelevel/otel4s/AttributesScalaVersionCompanion.scala @@ -0,0 +1,25 @@ +/* + * Copyright 2022 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 + +trait AttributesScalaVersionCompanion { self: Attributes.type => + + trait MakeCompanion { self: Attributes.Make.type => + + } + +} diff --git a/core/common/src/main/scala-3/org/typelevel/otel4s/AttributesScalaVersionCompanion.scala b/core/common/src/main/scala-3/org/typelevel/otel4s/AttributesScalaVersionCompanion.scala new file mode 100644 index 000000000..313ed3877 --- /dev/null +++ b/core/common/src/main/scala-3/org/typelevel/otel4s/AttributesScalaVersionCompanion.scala @@ -0,0 +1,44 @@ +/* + * Copyright 2022 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 + +import scala.collection.immutable + +type AttributeOrOption[A] = Attribute[A] | Option[Attribute[A]] +type AttributeOrIterableOnce = Attribute[?] | IterableOnce[Attribute[?]] + +trait AttributesScalaVersionCompanion { self: Attributes.type => + + trait MakeCompanion { self: Attributes.Make.type => + + given [A]: Attributes.Make[AttributeOrOption[A]] = { + case attribute: Attribute[?] => Attributes(attribute) + case option: Option[Attribute[?]] => Attributes.fromSpecific(option) + } + + given Attributes.Make[AttributeOrIterableOnce] = { + case attribute: Attribute[?] => Attributes(attribute) + case iterable: IterableOnce[Attribute[?]] => Attributes.fromSpecific(iterable) + } + + given Attributes.Make[immutable.Iterable[AttributeOrIterableOnce]] = { iterable => + Attributes.fromSpecific(iterable.flatMap(Attributes.from)) + } + + } + +} diff --git a/core/common/src/main/scala/org/typelevel/otel4s/Attributes.scala b/core/common/src/main/scala/org/typelevel/otel4s/Attributes.scala index 2631808e7..9c1de67ab 100644 --- a/core/common/src/main/scala/org/typelevel/otel4s/Attributes.scala +++ b/core/common/src/main/scala/org/typelevel/otel4s/Attributes.scala @@ -120,7 +120,7 @@ sealed trait Attributes Show[Attributes].show(this) } -object Attributes extends SpecificIterableFactory[Attribute[_], Attributes] { +object Attributes extends SpecificIterableFactory[Attribute[_], Attributes] with AttributesScalaVersionCompanion { private val Empty = new MapAttributes(Map.empty) /** Allows creating [[Attributes]] from an arbitrary type `A`. @@ -138,10 +138,12 @@ object Attributes extends SpecificIterableFactory[Attribute[_], Attributes] { * @tparam A * the type of the value */ - trait Make[A] { + trait Make[-A] { def make(a: A): Attributes } + object Make extends MakeCompanion + /** Creates [[Attributes]] with the given `attributes`. * * @note diff --git a/core/common/src/test/scala-3/org/typelevel/otel4s/AttributesSuite.scala b/core/common/src/test/scala-3/org/typelevel/otel4s/AttributesSuite.scala new file mode 100644 index 000000000..0a3ed5896 --- /dev/null +++ b/core/common/src/test/scala-3/org/typelevel/otel4s/AttributesSuite.scala @@ -0,0 +1,77 @@ +/* + * Copyright 2022 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 + +import munit.FunSuite + +import scala.collection.immutable + +class AttributesSuite extends FunSuite { + + test("Attributes.from supports AttributeOrOption: Attribute") { + val attribute = Attribute("key", "value") + + val result = Attributes.from(attribute: AttributeOrOption[String]) + + assertEquals(result, Attributes(attribute)) + } + + test("Attributes.from supports AttributeOrOption: Some(attribute)") { + val attribute = Attribute("key", "value") + + val result = Attributes.from(Some(attribute): AttributeOrOption[String]) + + assertEquals(result, Attributes(attribute)) + } + + test("Attributes.from supports AttributeOrOption: None") { + val result = Attributes.from(None: AttributeOrOption[String]) + + assertEquals(result, Attributes.empty) + } + + test("Attributes.from supports AttributeOrIterableOnce: Attribute") { + val attribute = Attribute("key", "value") + + val result = Attributes.from(attribute: AttributeOrIterableOnce) + + assertEquals(result, Attributes(attribute)) + } + + test("Attributes.from supports AttributeOrIterableOnce: IterableOnce[Attribute]") { + val a1 = Attribute("key1", "value1") + val a2 = Attribute("key2", "value2") + + val result = Attributes.from(List(a1, a2): AttributeOrIterableOnce) + + assertEquals(result, Attributes(a1, a2)) + } + + test("Attributes.from supports Iterable[AttributeOrIterableOnce] and flattens nested collections") { + val a1 = Attribute("key1", "value1") + val a2 = Attribute("key2", "value2") + val a3 = Attribute("key3", "value3") + + val values: immutable.Iterable[AttributeOrIterableOnce] = + List(a1, List(a2, a3), Vector(a1)) + + val result = Attributes.from(values) + + assertEquals(result, Attributes(a1, a2, a3, a1)) + } + +} diff --git a/core/metrics/src/main/scala-3/org/typelevel/otel4s/metrics/CounterMacro.scala b/core/metrics/src/main/scala-3/org/typelevel/otel4s/metrics/CounterMacro.scala index 617862518..f312426ac 100644 --- a/core/metrics/src/main/scala-3/org/typelevel/otel4s/metrics/CounterMacro.scala +++ b/core/metrics/src/main/scala-3/org/typelevel/otel4s/metrics/CounterMacro.scala @@ -31,7 +31,7 @@ private[otel4s] trait CounterMacro[F[_], A] { * @param attributes * the set of attributes to associate with the value */ - inline def add(inline value: A, inline attributes: Attribute[_]*): F[Unit] = + inline def add(inline value: A, inline attributes: AttributeOrIterableOnce*): F[Unit] = ${ CounterMacro.add('backend, 'value, 'attributes) } /** Records a value with a set of attributes. @@ -53,7 +53,7 @@ private[otel4s] trait CounterMacro[F[_], A] { * @param attributes * the set of attributes to associate with the value */ - inline def inc(inline attributes: Attribute[_]*): F[Unit] = + inline def inc(inline attributes: AttributeOrIterableOnce*): F[Unit] = ${ CounterMacro.inc('backend, 'attributes) } /** Increments a counter by one. @@ -71,14 +71,14 @@ object CounterMacro { def add[F[_], A]( backend: Expr[Counter.Backend[F, A]], value: Expr[A], - attributes: Expr[immutable.Iterable[Attribute[_]]] + attributes: Expr[immutable.Iterable[AttributeOrIterableOnce]] )(using Quotes, Type[F], Type[A]) = - '{ $backend.meta.whenEnabled($backend.add($value, $attributes)) } + '{ $backend.meta.whenEnabled($backend.add($value, Attributes.from($attributes))) } def inc[F[_], A]( backend: Expr[Counter.Backend[F, A]], - attributes: Expr[immutable.Iterable[Attribute[_]]] + attributes: Expr[immutable.Iterable[AttributeOrIterableOnce]] )(using Quotes, Type[F], Type[A]) = - '{ $backend.meta.whenEnabled($backend.inc($attributes)) } + '{ $backend.meta.whenEnabled($backend.inc(Attributes.from($attributes))) } } diff --git a/core/metrics/src/main/scala-3/org/typelevel/otel4s/metrics/GaugeMacro.scala b/core/metrics/src/main/scala-3/org/typelevel/otel4s/metrics/GaugeMacro.scala index 51b922575..cf9357927 100644 --- a/core/metrics/src/main/scala-3/org/typelevel/otel4s/metrics/GaugeMacro.scala +++ b/core/metrics/src/main/scala-3/org/typelevel/otel4s/metrics/GaugeMacro.scala @@ -33,7 +33,7 @@ private[otel4s] trait GaugeMacro[F[_], A] { */ inline def record( inline value: A, - inline attributes: Attribute[_]* + inline attributes: AttributeOrIterableOnce* ): F[Unit] = ${ GaugeMacro.record('backend, 'value, 'attributes) } @@ -58,8 +58,8 @@ object GaugeMacro { def record[F[_], A]( backend: Expr[Gauge.Backend[F, A]], value: Expr[A], - attributes: Expr[immutable.Iterable[Attribute[_]]] + attributes: Expr[immutable.Iterable[AttributeOrIterableOnce]] )(using Quotes, Type[F], Type[A]) = - '{ $backend.meta.whenEnabled($backend.record($value, $attributes)) } + '{ $backend.meta.whenEnabled($backend.record($value, Attributes.from($attributes))) } } diff --git a/core/metrics/src/main/scala-3/org/typelevel/otel4s/metrics/HistogramMacro.scala b/core/metrics/src/main/scala-3/org/typelevel/otel4s/metrics/HistogramMacro.scala index 771d016db..8f473a0cf 100644 --- a/core/metrics/src/main/scala-3/org/typelevel/otel4s/metrics/HistogramMacro.scala +++ b/core/metrics/src/main/scala-3/org/typelevel/otel4s/metrics/HistogramMacro.scala @@ -36,7 +36,7 @@ private[otel4s] trait HistogramMacro[F[_], A] { */ inline def record( inline value: A, - inline attributes: Attribute[_]* + inline attributes: AttributeOrIterableOnce* ): F[Unit] = ${ HistogramMacro.record('backend, 'value, 'attributes) } @@ -75,9 +75,9 @@ private[otel4s] trait HistogramMacro[F[_], A] { */ inline def recordDuration( inline timeUnit: TimeUnit, - inline attributes: Attribute[_]* + inline attributes: AttributeOrIterableOnce* ): Resource[F, Unit] = - recordDuration(timeUnit, _ => attributes) + recordDuration(timeUnit, _ => Attributes.from(attributes)) /** Records duration of the given effect. * @@ -145,18 +145,18 @@ object HistogramMacro { def record[F[_], A]( backend: Expr[Histogram.Backend[F, A]], value: Expr[A], - attributes: Expr[immutable.Iterable[Attribute[_]]] + attributes: Expr[immutable.Iterable[AttributeOrIterableOnce]] )(using Quotes, Type[F], Type[A]) = - '{ $backend.meta.whenEnabled($backend.record($value, $attributes)) } + '{ $backend.meta.whenEnabled($backend.record($value, Attributes.from($attributes))) } def recordDuration[F[_], A]( backend: Expr[Histogram.Backend[F, A]], timeUnit: Expr[TimeUnit], - attributes: Expr[Resource.ExitCase => immutable.Iterable[Attribute[_]]] + attributes: Expr[Resource.ExitCase => immutable.Iterable[AttributeOrIterableOnce]] )(using Quotes, Type[F], Type[A]) = '{ _root_.cats.effect.kernel.Resource.eval($backend.meta.isEnabled).flatMap { isEnabled => - if (isEnabled) $backend.recordDuration($timeUnit, $attributes) + if (isEnabled) $backend.recordDuration($timeUnit, ec => Attributes.from($attributes(ec))) else _root_.cats.effect.kernel.Resource.unit } } diff --git a/core/metrics/src/main/scala-3/org/typelevel/otel4s/metrics/UpDownCounterMacro.scala b/core/metrics/src/main/scala-3/org/typelevel/otel4s/metrics/UpDownCounterMacro.scala index 87b17fa0d..8ebb8216c 100644 --- a/core/metrics/src/main/scala-3/org/typelevel/otel4s/metrics/UpDownCounterMacro.scala +++ b/core/metrics/src/main/scala-3/org/typelevel/otel4s/metrics/UpDownCounterMacro.scala @@ -31,7 +31,7 @@ private[otel4s] trait UpDownCounterMacro[F[_], A] { * @param attributes * the set of attributes to associate with the value */ - inline def add(inline value: A, inline attributes: Attribute[_]*): F[Unit] = + inline def add(inline value: A, inline attributes: AttributeOrIterableOnce*): F[Unit] = ${ UpDownCounterMacro.add('backend, 'value, 'attributes) } /** Records a value with a set of attributes. @@ -53,7 +53,7 @@ private[otel4s] trait UpDownCounterMacro[F[_], A] { * @param attributes * the set of attributes to associate with the value */ - inline def inc(inline attributes: Attribute[_]*): F[Unit] = + inline def inc(inline attributes: AttributeOrIterableOnce*): F[Unit] = ${ UpDownCounterMacro.inc('backend, 'attributes) } /** Increments a counter by one. @@ -69,7 +69,7 @@ private[otel4s] trait UpDownCounterMacro[F[_], A] { * @param attributes * the set of attributes to associate with the value */ - inline def dec(inline attributes: Attribute[_]*): F[Unit] = + inline def dec(inline attributes: AttributeOrIterableOnce*): F[Unit] = ${ UpDownCounterMacro.dec('backend, 'attributes) } /** Decrements a counter by one. @@ -87,20 +87,20 @@ object UpDownCounterMacro { def add[F[_], A]( backend: Expr[UpDownCounter.Backend[F, A]], value: Expr[A], - attributes: Expr[immutable.Iterable[Attribute[_]]] + attributes: Expr[immutable.Iterable[AttributeOrIterableOnce]] )(using Quotes, Type[F], Type[A]) = - '{ $backend.meta.whenEnabled($backend.add($value, $attributes)) } + '{ $backend.meta.whenEnabled($backend.add($value, Attributes.from($attributes))) } def inc[F[_], A]( backend: Expr[UpDownCounter.Backend[F, A]], - attributes: Expr[immutable.Iterable[Attribute[_]]] + attributes: Expr[immutable.Iterable[AttributeOrIterableOnce]] )(using Quotes, Type[F], Type[A]) = - '{ $backend.meta.whenEnabled($backend.inc($attributes)) } + '{ $backend.meta.whenEnabled($backend.inc(Attributes.from($attributes))) } def dec[F[_], A]( backend: Expr[UpDownCounter.Backend[F, A]], - attributes: Expr[immutable.Iterable[Attribute[_]]] + attributes: Expr[immutable.Iterable[AttributeOrIterableOnce]] )(using Quotes, Type[F], Type[A]) = - '{ $backend.meta.whenEnabled($backend.dec($attributes)) } + '{ $backend.meta.whenEnabled($backend.dec(Attributes.from($attributes))) } } diff --git a/core/metrics/src/test/scala-3/org/typelevel/otel4s/metrics/MetricsMacroSuite.scala b/core/metrics/src/test/scala-3/org/typelevel/otel4s/metrics/MetricsMacroSuite.scala new file mode 100644 index 000000000..8124bbe2b --- /dev/null +++ b/core/metrics/src/test/scala-3/org/typelevel/otel4s/metrics/MetricsMacroSuite.scala @@ -0,0 +1,160 @@ +/* + * Copyright 2022 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 +package metrics + +import cats.effect.IO +import cats.effect.Ref +import cats.effect.Resource +import munit.CatsEffectSuite +import org.typelevel.otel4s.metrics.meta.InstrumentMeta + +import java.util.concurrent.TimeUnit +import scala.collection.immutable + +class MetricsMacroSuite extends CatsEffectSuite { + import MetricsMacroSuite._ + + test("metrics macros accept mixed Scala 3 varargs") { + val a1 = Attribute("k1", "v1") + val a2 = Attribute("k2", "v2") + val a3 = Attribute("k3", "v3") + val mixed = List(a2, a3) + + val expectedAttributes = Attributes(a1, a2, a3) + + for { + counter <- inMemoryCounter + _ <- counter.add(10L, a1, mixed) + _ <- counter.inc(a1, mixed) + counterRecords <- counter.records + + upDown <- inMemoryUpDownCounter + _ <- upDown.add(5L, a1, mixed) + _ <- upDown.inc(a1, mixed) + _ <- upDown.dec(a1, mixed) + upDownRecords <- upDown.records + + gauge <- inMemoryGauge + _ <- gauge.gauge.record(1.0, a1, mixed) + gaugeRecords <- gauge.records + + histogram <- inMemoryHistogram + _ <- histogram.record(2.0, a1, mixed) + _ <- histogram.recordDuration(TimeUnit.MILLISECONDS, _ => List(a1, a2, a3)).use_ + histogramRecords <- histogram.records + } yield { + assertEquals( + counterRecords, + List( + CounterSuite.Record(10L, expectedAttributes), + CounterSuite.Record(1L, expectedAttributes) + ) + ) + + assertEquals( + upDownRecords, + List( + UpDownCounterSuite.Record(5L, expectedAttributes), + UpDownCounterSuite.Record(1L, expectedAttributes), + UpDownCounterSuite.Record(-1L, expectedAttributes) + ) + ) + + assertEquals( + gaugeRecords, + List( + GaugeRecord(1.0, expectedAttributes) + ) + ) + + assertEquals(histogramRecords.map(_.attributes), List(expectedAttributes, expectedAttributes)) + } + } + + test("noop metrics do not evaluate mixed Scala 3 varargs") { + val counter = Counter.noop[IO, Long] + val upDown = UpDownCounter.noop[IO, Long] + val gauge = Gauge.noop[IO, Double] + val histogram = Histogram.noop[IO, Double] + + var allocated = false + + def attribute = { + allocated = true + Attribute("k1", "v1") + } + + def attributes = { + allocated = true + List(Attribute("k2", "v2"), Attribute("k3", "v3")) + } + + def durationAttributes(ec: Resource.ExitCase): immutable.Iterable[Attribute[_]] = { + val _ = ec + attribute :: attributes + } + + for { + _ <- counter.add(1L, attribute, attributes) + _ <- counter.inc(attribute, attributes) + + _ <- upDown.add(1L, attribute, attributes) + _ <- upDown.inc(attribute, attributes) + _ <- upDown.dec(attribute, attributes) + + _ <- gauge.record(1.0, attribute, attributes) + + _ <- histogram.record(1.0, attribute, attributes) + _ <- histogram.recordDuration(TimeUnit.MILLISECONDS, attribute, attributes).use_ + _ <- histogram.recordDuration(TimeUnit.MILLISECONDS, durationAttributes).use_ + } yield assert(!allocated) + } + + private def inMemoryCounter: IO[CounterSuite.InMemoryCounter] = + IO.ref[List[CounterSuite.Record[Long]]](Nil).map(ref => new CounterSuite.InMemoryCounter(ref)) + + private def inMemoryUpDownCounter: IO[UpDownCounterSuite.InMemoryUpDownCounter] = + IO.ref[List[UpDownCounterSuite.Record[Long]]](Nil).map(ref => new UpDownCounterSuite.InMemoryUpDownCounter(ref)) + + private def inMemoryHistogram: IO[HistogramSuite.InMemoryHistogram] = + IO.ref[List[HistogramSuite.Record[Double]]](Nil).map(ref => new HistogramSuite.InMemoryHistogram(ref)) + + private def inMemoryGauge: IO[InMemoryGauge] = + IO.ref[List[GaugeRecord[Double]]](Nil).map(ref => new InMemoryGauge(ref)) +} + +object MetricsMacroSuite { + final case class GaugeRecord[A](value: A, attributes: Attributes) + + class InMemoryGauge(ref: Ref[IO, List[GaugeRecord[Double]]]) { + private val backend: Gauge.Backend[IO, Double] = + new Gauge.Backend.Unsealed[IO, Double] { + val meta: InstrumentMeta[IO] = InstrumentMeta.enabled + + def record( + value: Double, + attributes: immutable.Iterable[Attribute[_]] + ): IO[Unit] = + ref.update(_.appended(GaugeRecord(value, attributes.to(Attributes)))) + } + + val gauge: Gauge[IO, Double] = Gauge.fromBackend(backend) + + def records: IO[List[GaugeRecord[Double]]] = ref.get + } +} diff --git a/core/trace/src/main/scala-3/org/typelevel/otel4s/trace/SpanBuilderMacro.scala b/core/trace/src/main/scala-3/org/typelevel/otel4s/trace/SpanBuilderMacro.scala index 2f48f805b..45198806c 100644 --- a/core/trace/src/main/scala-3/org/typelevel/otel4s/trace/SpanBuilderMacro.scala +++ b/core/trace/src/main/scala-3/org/typelevel/otel4s/trace/SpanBuilderMacro.scala @@ -29,7 +29,7 @@ private[otel4s] trait SpanBuilderMacro[F[_]] { self: SpanBuilder[F] => * @param attribute * the attribute to associate with the span */ - inline def addAttribute[A](inline attribute: Attribute[A]): SpanBuilder[F] = + inline def addAttribute[A](inline attribute: AttributeOrOption[A]): SpanBuilder[F] = ${ SpanBuilderMacro.addAttribute('self, 'attribute) } /** Adds attributes to the [[SpanBuilder]]. If the SpanBuilder previously contained a mapping for any of the keys, the @@ -38,7 +38,7 @@ private[otel4s] trait SpanBuilderMacro[F[_]] { self: SpanBuilder[F] => * @param attributes * the set of attributes to associate with the span */ - inline def addAttributes(inline attributes: Attribute[_]*): SpanBuilder[F] = + inline def addAttributes(inline attributes: AttributeOrIterableOnce*): SpanBuilder[F] = ${ SpanBuilderMacro.addAttributes('self, 'attributes) } /** Adds attributes to the [[SpanBuilder]]. If the SpanBuilder previously contained a mapping for any of the keys, the @@ -65,7 +65,7 @@ private[otel4s] trait SpanBuilderMacro[F[_]] { self: SpanBuilder[F] => */ inline def addLink( inline spanContext: SpanContext, - inline attributes: Attribute[_]* + inline attributes: AttributeOrIterableOnce* ): SpanBuilder[F] = ${ SpanBuilderMacro.addLink('self, 'spanContext, 'attributes) } @@ -146,30 +146,30 @@ object SpanBuilderMacro { def addAttribute[F[_], A]( builder: Expr[SpanBuilder[F]], - attribute: Expr[Attribute[A]] + attribute: Expr[AttributeOrOption[A]] )(using Quotes, Type[F], Type[A]) = '{ - $builder.modifyState(_.addAttributes(List($attribute))) + $builder.modifyState(_.addAttributes(Attributes.from($attribute))) } def addAttributes[F[_]]( builder: Expr[SpanBuilder[F]], - attributes: Expr[immutable.Iterable[Attribute[_]]] + attributes: Expr[immutable.Iterable[AttributeOrIterableOnce]] )(using Quotes, Type[F]) = (attributes: @unchecked) match { case Varargs(args) if args.isEmpty => builder case other => - '{ $builder.modifyState(_.addAttributes($attributes)) } + '{ $builder.modifyState(_.addAttributes(Attributes.from($attributes))) } } def addLink[F[_]]( builder: Expr[SpanBuilder[F]], spanContext: Expr[SpanContext], - attributes: Expr[immutable.Iterable[Attribute[_]]] + attributes: Expr[immutable.Iterable[AttributeOrIterableOnce]] )(using Quotes, Type[F]) = '{ - $builder.modifyState(_.addLink($spanContext, $attributes)) + $builder.modifyState(_.addLink($spanContext, Attributes.from($attributes))) } def withFinalizationStrategy[F[_]]( diff --git a/core/trace/src/main/scala-3/org/typelevel/otel4s/trace/SpanMacro.scala b/core/trace/src/main/scala-3/org/typelevel/otel4s/trace/SpanMacro.scala index 55c07f2d5..a75e1339c 100644 --- a/core/trace/src/main/scala-3/org/typelevel/otel4s/trace/SpanMacro.scala +++ b/core/trace/src/main/scala-3/org/typelevel/otel4s/trace/SpanMacro.scala @@ -39,7 +39,7 @@ private[otel4s] trait SpanMacro[F[_]] { * @param attributes * the set of attributes to add to the span */ - inline def addAttributes(inline attributes: Attribute[_]*): F[Unit] = + inline def addAttributes(inline attributes: AttributeOrIterableOnce*): F[Unit] = ${ SpanMacro.addAttributes('self, 'attributes) } /** Adds attributes to the span. If the span previously contained a mapping for any of the keys, the old values are @@ -63,7 +63,7 @@ private[otel4s] trait SpanMacro[F[_]] { */ inline def addEvent( inline name: String, - inline attributes: Attribute[_]* + inline attributes: AttributeOrIterableOnce* ): F[Unit] = ${ SpanMacro.addEvent('self, 'name, 'attributes) } @@ -98,7 +98,7 @@ private[otel4s] trait SpanMacro[F[_]] { inline def addEvent( inline name: String, inline timestamp: FiniteDuration, - inline attributes: Attribute[_]* + inline attributes: AttributeOrIterableOnce* ): F[Unit] = ${ SpanMacro.addEvent('self, 'name, 'timestamp, 'attributes) } @@ -136,7 +136,7 @@ private[otel4s] trait SpanMacro[F[_]] { */ inline def addLink( spanContext: SpanContext, - attributes: Attribute[_]* + attributes: AttributeOrIterableOnce* ): F[Unit] = ${ SpanMacro.addLink('self, 'spanContext, 'attributes) } @@ -167,7 +167,7 @@ private[otel4s] trait SpanMacro[F[_]] { */ inline def recordException( inline exception: Throwable, - inline attributes: Attribute[_]* + inline attributes: AttributeOrIterableOnce* ): F[Unit] = ${ SpanMacro.recordException('self, 'exception, 'attributes) } @@ -227,22 +227,28 @@ object SpanMacro { def addAttributes[F[_]]( span: Expr[Span[F]], - attributes: Expr[immutable.Iterable[Attribute[_]]] + attributes: Expr[immutable.Iterable[AttributeOrIterableOnce]] )(using Quotes, Type[F]) = '{ - if ($span.backend.meta.isEnabled && $attributes.nonEmpty) - $span.backend.addAttributes($attributes) - else $span.backend.meta.unit + if ($span.backend.meta.isEnabled) { + val attrs = Attributes.from($attributes) + if (attrs.nonEmpty) + $span.backend.addAttributes(attrs) + else + $span.backend.meta.unit + } else { + $span.backend.meta.unit + } } def addEvent[F[_]]( span: Expr[Span[F]], name: Expr[String], - attributes: Expr[immutable.Iterable[Attribute[_]]] + attributes: Expr[immutable.Iterable[AttributeOrIterableOnce]] )(using Quotes, Type[F]) = '{ if ($span.backend.meta.isEnabled) - $span.backend.addEvent($name, $attributes) + $span.backend.addEvent($name, Attributes.from($attributes)) else $span.backend.meta.unit } @@ -250,33 +256,33 @@ object SpanMacro { span: Expr[Span[F]], name: Expr[String], timestamp: Expr[FiniteDuration], - attributes: Expr[immutable.Iterable[Attribute[_]]] + attributes: Expr[immutable.Iterable[AttributeOrIterableOnce]] )(using Quotes, Type[F]) = '{ if ($span.backend.meta.isEnabled) - $span.backend.addEvent($name, $timestamp, $attributes) + $span.backend.addEvent($name, $timestamp, Attributes.from($attributes)) else $span.backend.meta.unit } def addLink[F[_]]( span: Expr[Span[F]], spanContext: Expr[SpanContext], - attributes: Expr[immutable.Iterable[Attribute[_]]] + attributes: Expr[immutable.Iterable[AttributeOrIterableOnce]] )(using Quotes, Type[F]) = '{ if ($span.backend.meta.isEnabled) - $span.backend.addLink($spanContext, $attributes) + $span.backend.addLink($spanContext, Attributes.from($attributes)) else $span.backend.meta.unit } def recordException[F[_]]( span: Expr[Span[F]], exception: Expr[Throwable], - attributes: Expr[immutable.Iterable[Attribute[_]]] + attributes: Expr[immutable.Iterable[AttributeOrIterableOnce]] )(using Quotes, Type[F]) = '{ if ($span.backend.meta.isEnabled) - $span.backend.recordException($exception, $attributes) + $span.backend.recordException($exception, Attributes.from($attributes)) else $span.backend.meta.unit } diff --git a/core/trace/src/main/scala-3/org/typelevel/otel4s/trace/TracerMacro.scala b/core/trace/src/main/scala-3/org/typelevel/otel4s/trace/TracerMacro.scala index d39ba1dbe..66e0dcd44 100644 --- a/core/trace/src/main/scala-3/org/typelevel/otel4s/trace/TracerMacro.scala +++ b/core/trace/src/main/scala-3/org/typelevel/otel4s/trace/TracerMacro.scala @@ -54,9 +54,9 @@ private[otel4s] trait TracerMacro[F[_]] { */ inline def span( inline name: String, - inline attributes: Attribute[_]* + inline attributes: AttributeOrIterableOnce* ): SpanOps[F] = - spanBuilder(name).addAttributes(attributes).build + spanBuilder(name).addAttributes(attributes*).build /** Creates a new child span. The span is automatically attached to a parent span (based on the scope). * @@ -110,9 +110,9 @@ private[otel4s] trait TracerMacro[F[_]] { */ inline def rootSpan( inline name: String, - inline attributes: Attribute[_]* + inline attributes: AttributeOrIterableOnce* ): SpanOps[F] = - spanBuilder(name).addAttributes(attributes).root.build + spanBuilder(name).addAttributes(attributes*).root.build /** Creates a new root span. Even if a parent span is available in the scope, the span is created without a parent. * diff --git a/core/trace/src/test/scala-3/org/typelevel/otel4s/trace/SpanMacroSuite.scala b/core/trace/src/test/scala-3/org/typelevel/otel4s/trace/SpanMacroSuite.scala new file mode 100644 index 000000000..13530326b --- /dev/null +++ b/core/trace/src/test/scala-3/org/typelevel/otel4s/trace/SpanMacroSuite.scala @@ -0,0 +1,110 @@ +/* + * Copyright 2022 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.trace + +import cats.effect.IO +import cats.effect.Ref +import munit.CatsEffectSuite +import org.typelevel.otel4s.Attribute +import org.typelevel.otel4s.Attributes + +import scala.collection.immutable +import scala.concurrent.duration.* +import scala.concurrent.duration.FiniteDuration + +class SpanMacroSuite extends CatsEffectSuite { + import SpanMacroSuite._ + + test("Span macro accepts mixed Scala 3 varargs and normalizes attributes") { + val a1 = Attribute("k1", "v1") + val a2 = Attribute("k2", "v2") + val a3 = Attribute("k3", "v3") + val mixed = List(a2, a3) + val ctx = SpanContext.invalid + val ex = new RuntimeException("boom") + val ts = 100.millis + + val expected = Vector( + BackendOp.AddAttributes(Attributes(a1, a2, a3)), + BackendOp.AddEvent("event", None, Attributes(a1, a2, a3)), + BackendOp.AddEvent("event-ts", Some(ts), Attributes(a1, a2, a3)), + BackendOp.AddLink(ctx, Attributes(a1, a2, a3)), + BackendOp.RecordException(ex, Attributes(a1, a2, a3)) + ) + + for { + ops <- IO.ref(Vector.empty[BackendOp]) + span = Span.fromBackend(new OpsBackend(ops)) + _ <- span.addAttributes(a1, mixed) + _ <- span.addEvent("event", a1, mixed) + _ <- span.addEvent("event-ts", ts, a1, mixed) + _ <- span.addLink(ctx, a1, mixed) + _ <- span.recordException(ex, a1, mixed) + result <- ops.get + } yield assertEquals(result, expected) + } +} + +object SpanMacroSuite { + + private sealed trait BackendOp + private object BackendOp { + final case class AddAttributes(attributes: Attributes) extends BackendOp + final case class AddEvent( + name: String, + timestamp: Option[FiniteDuration], + attributes: Attributes + ) extends BackendOp + final case class AddLink( + spanContext: SpanContext, + attributes: Attributes + ) extends BackendOp + final case class RecordException( + exception: Throwable, + attributes: Attributes + ) extends BackendOp + } + + private class OpsBackend(state: Ref[IO, Vector[BackendOp]]) extends Span.Backend.Unsealed[IO] { + import BackendOp._ + + val meta: Span.Meta[IO] = Span.Meta.enabled + + def context: SpanContext = SpanContext.invalid + def isRecording: IO[Boolean] = IO.pure(true) + def updateName(name: String): IO[Unit] = IO.unit + def setStatus(status: StatusCode): IO[Unit] = IO.unit + def setStatus(status: StatusCode, description: String): IO[Unit] = IO.unit + def end: IO[Unit] = IO.unit + def end(timestamp: FiniteDuration): IO[Unit] = IO.unit + + def addAttributes(attributes: immutable.Iterable[Attribute[_]]): IO[Unit] = + state.update(_ :+ AddAttributes(attributes.to(Attributes))) + + def addEvent(name: String, attributes: immutable.Iterable[Attribute[_]]): IO[Unit] = + state.update(_ :+ AddEvent(name, None, attributes.to(Attributes))) + + def addEvent(name: String, timestamp: FiniteDuration, attributes: immutable.Iterable[Attribute[_]]): IO[Unit] = + state.update(_ :+ AddEvent(name, Some(timestamp), attributes.to(Attributes))) + + def addLink(spanContext: SpanContext, attributes: immutable.Iterable[Attribute[_]]): IO[Unit] = + state.update(_ :+ AddLink(spanContext, attributes.to(Attributes))) + + def recordException(exception: Throwable, attributes: immutable.Iterable[Attribute[_]]): IO[Unit] = + state.update(_ :+ RecordException(exception, attributes.to(Attributes))) + } +} diff --git a/core/trace/src/test/scala-3/org/typelevel/otel4s/trace/TracerMacroSuite.scala b/core/trace/src/test/scala-3/org/typelevel/otel4s/trace/TracerMacroSuite.scala new file mode 100644 index 000000000..1a9a0d397 --- /dev/null +++ b/core/trace/src/test/scala-3/org/typelevel/otel4s/trace/TracerMacroSuite.scala @@ -0,0 +1,119 @@ +/* + * Copyright 2022 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 +package trace + +import cats.Applicative +import cats.effect.IO +import munit.CatsEffectSuite +import org.typelevel.otel4s.context.propagation.TextMapGetter +import org.typelevel.otel4s.context.propagation.TextMapUpdater +import org.typelevel.otel4s.trace.meta.InstrumentMeta + +class TracerMacroSuite extends CatsEffectSuite { + import TracerMacroSuite._ + + test("span and rootSpan support mixed Scala 3 varargs") { + val a1 = Attribute("k1", "v1") + val a2 = Attribute("k2", "v2") + val a3 = Attribute("k3", "v3") + val mixed = List(a2, a3) + val tracer = new ProxyTracer(Tracer.noop[IO]) + + val expected = Vector( + Vector( + BuilderOp.Init("span"), + BuilderOp.ModifyState(SpanBuilder.State.init.addAttributes(List(a1, a2, a3))), + BuilderOp.Build + ), + Vector( + BuilderOp.Init("root"), + BuilderOp.ModifyState(SpanBuilder.State.init.addAttributes(List(a1, a2, a3))), + BuilderOp.ModifyState( + SpanBuilder.State.init.addAttributes(List(a1, a2, a3)).withParent(SpanBuilder.Parent.root) + ), + BuilderOp.Build + ) + ) + + for { + _ <- tracer.span("span", a1, mixed).use_ + _ <- tracer.rootSpan("root", a1, mixed).use_ + } yield assertEquals(tracer.builders.map(_.ops), expected) + } + +} + +object TracerMacroSuite { + + private sealed trait BuilderOp + private object BuilderOp { + final case class Init(name: String) extends BuilderOp + final case class ModifyState(state: SpanBuilder.State) extends BuilderOp + case object Build extends BuilderOp + } + + private final class ProxyBuilder[F[_]: Applicative]( + name: String, + var underlying: SpanBuilder[F] + ) extends SpanBuilder.Unsealed[F] { + private var state: SpanBuilder.State = SpanBuilder.State.init + private val builderOps = Vector.newBuilder[BuilderOp] + builderOps.addOne(BuilderOp.Init(name)) + + def ops: Vector[BuilderOp] = builderOps.result() + + def meta: InstrumentMeta[F] = InstrumentMeta.enabled[F] + + def modifyState(f: SpanBuilder.State => SpanBuilder.State): SpanBuilder[F] = { + state = f(state) + underlying = underlying.modifyState(f) + builderOps.addOne(BuilderOp.ModifyState(state)) + this + } + + def build: SpanOps[F] = { + builderOps.addOne(BuilderOp.Build) + underlying.build + } + } + + private class ProxyTracer[F[_]: Applicative](underlying: Tracer[F]) extends Tracer.Unsealed[F] { + private val proxyBuilders = Vector.newBuilder[ProxyBuilder[F]] + + def meta: InstrumentMeta[F] = InstrumentMeta.enabled[F] + def currentSpanContext: F[Option[SpanContext]] = underlying.currentSpanContext + def currentSpanOrNoop: F[Span[F]] = underlying.currentSpanOrNoop + def currentSpanOrThrow: F[Span[F]] = underlying.currentSpanOrThrow + def withCurrentSpanOrNoop[A](f: Span[F] => F[A]): F[A] = underlying.withCurrentSpanOrNoop(f) + def childScope[A](parent: SpanContext)(fa: F[A]): F[A] = underlying.childScope(parent)(fa) + def joinOrRoot[A, C: TextMapGetter](carrier: C)(fa: F[A]): F[A] = underlying.joinOrRoot(carrier)(fa) + def rootScope[A](fa: F[A]): F[A] = underlying.rootScope(fa) + def noopScope[A](fa: F[A]): F[A] = underlying.noopScope(fa) + def propagate[C: TextMapUpdater](carrier: C): F[C] = underlying.propagate(carrier) + + def spanBuilder(name: String): SpanBuilder[F] = { + val builder = new ProxyBuilder[F](name, underlying.spanBuilder(name)) + proxyBuilders.addOne(builder) + builder + } + + def builders: Vector[ProxyBuilder[F]] = + proxyBuilders.result() + } + +}