From 37764f41fd5ebee32d41ffc9f62d763cd273e97c Mon Sep 17 00:00:00 2001 From: Maksym Ochenashko Date: Wed, 15 Nov 2023 20:22:05 +0200 Subject: [PATCH] sdk-trace: add `SpanExporter` --- .../sdk/trace/exporter/SpanExporter.scala | 118 ++++++++++++++++++ .../trace/exporter/InMemorySpanExporter.scala | 52 ++++++++ .../trace/exporter/SpanExporterSuite.scala | 102 +++++++++++++++ 3 files changed, 272 insertions(+) create mode 100644 sdk/trace/src/main/scala/org/typelevel/otel4s/sdk/trace/exporter/SpanExporter.scala create mode 100644 sdk/trace/src/test/scala/org/typelevel/otel4s/sdk/trace/exporter/InMemorySpanExporter.scala create mode 100644 sdk/trace/src/test/scala/org/typelevel/otel4s/sdk/trace/exporter/SpanExporterSuite.scala diff --git a/sdk/trace/src/main/scala/org/typelevel/otel4s/sdk/trace/exporter/SpanExporter.scala b/sdk/trace/src/main/scala/org/typelevel/otel4s/sdk/trace/exporter/SpanExporter.scala new file mode 100644 index 000000000..8e195678e --- /dev/null +++ b/sdk/trace/src/main/scala/org/typelevel/otel4s/sdk/trace/exporter/SpanExporter.scala @@ -0,0 +1,118 @@ +/* + * 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 exporter + +import cats.Applicative +import cats.Monoid +import cats.syntax.foldable._ +import org.typelevel.otel4s.sdk.trace.data.SpanData + +/** An interface that allows different tracing services to export recorded data + * for sampled spans in their own format. + * + * @see + * [[https://opentelemetry.io/docs/specs/otel/trace/sdk/#span-exporter]] + * + * @tparam F + * the higher-kinded type of a polymorphic effect + */ +trait SpanExporter[F[_]] { + + /** Called to export sampled + * [[org.typelevel.otel4s.sdk.trace.data.SpanData SpanData]]. + * + * '''Note''': the export operations can be performed simultaneously + * depending on the type of span processor being used. However, the batch + * span exporter will ensure that only one export can occur at a time. + * + * @param span + * the collection of sampled Spans to be exported + */ + def exportSpans(span: List[SpanData]): F[Unit] + + /** Exports the collection of sampled + * [[org.typelevel.otel4s.sdk.trace.data.SpanData SpanData]] that have not + * yet been exported. + * + * '''Note''': the export operations can be performed simultaneously + * depending on the type of span processor being used. However, the batch + * span exporter will ensure that only one export can occur at a time. + */ + def flush: F[Unit] +} + +object SpanExporter { + + /** Creates a [[SpanExporter]] which delegates all exports to the exporters in + * order. + */ + def of[F[_]: Applicative]( + exporters: SpanExporter[F]* + ): SpanExporter[F] = + exporters.combineAll + + /** Creates a no-op implementation of the [[SpanExporter]]. + * + * All export operations are no-op. + */ + def noop[F[_]: Applicative]: SpanExporter[F] = + new Noop + + implicit def spanExporterMonoid[F[_]: Applicative]: Monoid[SpanExporter[F]] = + new Monoid[SpanExporter[F]] { + val empty: SpanExporter[F] = + noop[F] + + def combine(x: SpanExporter[F], y: SpanExporter[F]): SpanExporter[F] = + (x, y) match { + case (that, _: Noop[F]) => + that + case (_: Noop[F], other) => + other + case (that: Multi[F], other: Multi[F]) => + Multi(that.exporters ++ other.exporters) + case (that: Multi[F], other) => + Multi(that.exporters :+ other) + case (that, other: Multi[F]) => + Multi(that +: other.exporters) + case (that, other) => + Multi(List(that, other)) + } + } + + private final class Noop[F[_]: Applicative] extends SpanExporter[F] { + def exportSpans(span: List[SpanData]): F[Unit] = Applicative[F].unit + def flush: F[Unit] = Applicative[F].unit + + override def toString: String = "SpanExporter.Noop" + } + + private final case class Multi[F[_]: Applicative]( + exporters: List[SpanExporter[F]] + ) extends SpanExporter[F] { + def exportSpans(span: List[SpanData]): F[Unit] = + exporters.traverse_(_.exportSpans(span)) + + def flush: F[Unit] = + exporters.traverse_(_.flush) + + override def toString: String = + s"SpanExporter.Multi(${exporters.map(_.toString).mkString(", ")})" + } + +} diff --git a/sdk/trace/src/test/scala/org/typelevel/otel4s/sdk/trace/exporter/InMemorySpanExporter.scala b/sdk/trace/src/test/scala/org/typelevel/otel4s/sdk/trace/exporter/InMemorySpanExporter.scala new file mode 100644 index 000000000..37d609644 --- /dev/null +++ b/sdk/trace/src/test/scala/org/typelevel/otel4s/sdk/trace/exporter/InMemorySpanExporter.scala @@ -0,0 +1,52 @@ +/* + * 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.exporter + +import cats.Applicative +import cats.data.Chain +import cats.effect.Concurrent +import cats.effect.Ref +import cats.syntax.functor._ +import org.typelevel.otel4s.sdk.trace.data.SpanData + +// todo: should be in the testkit package +final class InMemorySpanExporter[F[_]: Applicative] private ( + storage: Ref[F, Chain[SpanData]] +) extends SpanExporter[F] { + def exportSpans(span: List[SpanData]): F[Unit] = + storage.update(_.concat(Chain.fromSeq(span))) + + def flush: F[Unit] = Applicative[F].unit + + def finishedSpans: F[Chain[SpanData]] = + storage.get + + def reset: F[Unit] = + storage.set(Chain.empty) + + override def toString: String = + "InMemorySpanExporter" +} + +object InMemorySpanExporter { + + def create[F[_]: Concurrent]: F[InMemorySpanExporter[F]] = + for { + storage <- Ref.of(Chain.empty[SpanData]) + } yield new InMemorySpanExporter[F](storage) + +} diff --git a/sdk/trace/src/test/scala/org/typelevel/otel4s/sdk/trace/exporter/SpanExporterSuite.scala b/sdk/trace/src/test/scala/org/typelevel/otel4s/sdk/trace/exporter/SpanExporterSuite.scala new file mode 100644 index 000000000..794063333 --- /dev/null +++ b/sdk/trace/src/test/scala/org/typelevel/otel4s/sdk/trace/exporter/SpanExporterSuite.scala @@ -0,0 +1,102 @@ +/* + * 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 exporter + +import cats.effect.IO +import munit.CatsEffectSuite +import org.typelevel.otel4s.sdk.trace.data.SpanData + +class SpanExporterSuite extends CatsEffectSuite { + + test("create a no-op instance") { + val exporter = SpanExporter.noop[IO] + + assertEquals(exporter.toString, "SpanExporter.Noop") + } + + test("of (empty input) - use noop") { + val exporter = SpanExporter.of[IO]() + + assertEquals(exporter.toString, "SpanExporter.Noop") + } + + test("of (single input) - use this input") { + val data: SpanData = getSpanData + + for { + inMemory <- InMemorySpanExporter.create[IO] + exporter <- IO.pure(SpanExporter.of(inMemory)) + _ <- exporter.exportSpans(List(data)) + spans <- inMemory.finishedSpans + } yield { + assertEquals(exporter.toString, "InMemorySpanExporter") + assertEquals(spans.toList, List(data)) + } + } + + test("of (multiple) - create a multi instance") { + val data: SpanData = getSpanData + + for { + inMemoryA <- InMemorySpanExporter.create[IO] + inMemoryB <- InMemorySpanExporter.create[IO] + exporter <- IO.pure(SpanExporter.of(inMemoryA, inMemoryB)) + _ <- exporter.exportSpans(List(data)) + spansA <- inMemoryA.finishedSpans + spansB <- inMemoryB.finishedSpans + } yield { + assertEquals(spansA.toList, List(data)) + assertEquals(spansB.toList, List(data)) + + assertEquals( + exporter.toString, + "SpanExporter.Multi(InMemorySpanExporter, InMemorySpanExporter)" + ) + } + } + + test("of (multiple) - flatten out nested multi instances") { + val data: SpanData = getSpanData + + for { + inMemoryA <- InMemorySpanExporter.create[IO] + inMemoryB <- InMemorySpanExporter.create[IO] + + multi1 <- IO.pure(SpanExporter.of(inMemoryA, inMemoryB)) + multi2 <- IO.pure(SpanExporter.of(inMemoryA, inMemoryB)) + + exporter <- IO.pure(SpanExporter.of(multi1, multi2)) + + _ <- exporter.exportSpans(List(data)) + spansA <- inMemoryA.finishedSpans + spansB <- inMemoryB.finishedSpans + } yield { + assertEquals(spansA.toList, List(data, data)) + assertEquals(spansB.toList, List(data, data)) + + assertEquals( + exporter.toString, + "SpanExporter.Multi(InMemorySpanExporter, InMemorySpanExporter, InMemorySpanExporter, InMemorySpanExporter)" + ) + } + } + + private def getSpanData: SpanData = + Gens.spanData.sample.getOrElse(getSpanData) + +}