From c9e6e1262a5bead3af2a7a1cdcc97f32119b1352 Mon Sep 17 00:00:00 2001 From: Maksym Ochenashko Date: Wed, 12 Jun 2024 10:18:37 +0300 Subject: [PATCH 1/2] trace: add `@span` macro --- build.sbt | 17 +- .../otel4s/experimental/trace/span.scala | 246 ++++++++++++++ .../otel4s/experimental/trace/span.scala | 275 ++++++++++++++++ .../otel4s/experimental/trace/attribute.scala | 33 ++ .../otel4s/experimental/trace/package.scala | 28 ++ .../otel4s/experimental/trace/package.scala | 23 ++ .../trace/SpanAnnotationSuite.scala | 306 ++++++++++++++++++ 7 files changed, 927 insertions(+), 1 deletion(-) create mode 100644 modules/trace/src/main/scala-2/org/typelevel/otel4s/experimental/trace/span.scala create mode 100644 modules/trace/src/main/scala-3/org/typelevel/otel4s/experimental/trace/span.scala create mode 100644 modules/trace/src/main/scala/org/typelevel/otel4s/experimental/trace/attribute.scala create mode 100644 modules/trace/src/test/scala-2/org/typelevel/otel4s/experimental/trace/package.scala create mode 100644 modules/trace/src/test/scala-3/org/typelevel/otel4s/experimental/trace/package.scala create mode 100644 modules/trace/src/test/scala/org/typelevel/otel4s/experimental/trace/SpanAnnotationSuite.scala diff --git a/build.sbt b/build.sbt index 99c0213..7d1e7b0 100644 --- a/build.sbt +++ b/build.sbt @@ -29,7 +29,7 @@ ThisBuild / scalaVersion := Versions.Scala213 // the default Scala lazy val root = tlCrossRootProject .settings(name := "otel4s-experimental") - .aggregate(metrics) + .aggregate(metrics, trace) lazy val metrics = crossProject(JVMPlatform) .crossType(CrossType.Full) @@ -45,6 +45,21 @@ lazy val metrics = crossProject(JVMPlatform) ) ) +lazy val trace = crossProject(JVMPlatform, JSPlatform, NativePlatform) + .crossType(CrossType.Pure) + .in(file("modules/trace")) + .settings(munitDependencies) + .settings(scalaReflectDependency) + .settings( + name := "otel4s-experimental-trace", + libraryDependencies ++= Seq( + "org.typelevel" %%% "otel4s-core-trace" % Versions.Otel4s + ), + scalacOptions ++= { + if (tlIsScala3.value) Nil else Seq("-Ymacro-annotations") + } + ) + lazy val scalaReflectDependency = Def.settings( libraryDependencies ++= { if (tlIsScala3.value) Nil diff --git a/modules/trace/src/main/scala-2/org/typelevel/otel4s/experimental/trace/span.scala b/modules/trace/src/main/scala-2/org/typelevel/otel4s/experimental/trace/span.scala new file mode 100644 index 0000000..40af158 --- /dev/null +++ b/modules/trace/src/main/scala-2/org/typelevel/otel4s/experimental/trace/span.scala @@ -0,0 +1,246 @@ +/* + * Copyright 2024 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.experimental.trace + +import scala.annotation.StaticAnnotation +import scala.annotation.compileTimeOnly +import scala.annotation.unused +import scala.reflect.macros.blackbox + +/** Wraps the body of an annotated method or variable into the span. + * + * By default, the span name will be `className.methodName`, unless a name is provided as an + * argument. + * + * {{{ + * class Service[F[_]: Tracer](db: Database[F]) { + * @span + * def findUser(@attribute userId: Long): F[User] = + * db.findUser(id) + * } + * + * // expands into + * + * class Service[F[_]: Tracer](db: Database[F]) { + * def findUser(userId: Long): F[User] = + * Tracer[F] + * .span("Service.findUser", Attribute("userId", userId)) + * .surround(db.findUser(id)) + * } + * }}} + * + * @param name + * the custom name of the span. If not specified, the span name will be `className.methodName` + * + * @param debug + * whether to print the generated code to the console + */ +@compileTimeOnly("enable macro to expand macro annotations") +class span( + @unused name: String = "", + @unused debug: Boolean = false +) extends StaticAnnotation { + def macroTransform(annottees: Any*): Any = macro spanMacro.impl +} + +object spanMacro { + + def impl(c: blackbox.Context)(annottees: c.Expr[Any]*): c.Expr[c.Tree] = { + import c.universe._ + + def abort(message: String) = + c.abort(c.enclosingPosition, s"@span macro: $message") + + def ensureImplicitExist(tpe: Tree, reason: Throwable => String): c.Tree = + try { + val t = c.typecheck(tpe).tpe + c.inferImplicitValue(t) + } catch { + case e: Throwable => abort(reason(e)) + } + + def resolveEffectType(tpt: Tree): Tree = + tpt match { + case tq"$tpe[${_}]" => tpe + case _ => abort("cannot resolve the type of the effect") + } + + val macroName: Tree = + c.prefix.tree match { + case Apply(Select(New(name), _), _) => name + case _ => c.abort(c.enclosingPosition, "Unexpected macro application") + } + + val (nameParam, debug) = c.prefix.tree match { + case q"new ${`macroName`}(..$args)" => + ( + args.headOption + .collect { case Literal(value) => Left(value) } + .orElse( + args.collectFirst { case q"name = $value" => Right(value) } + ), + args.collectFirst { case q"debug = true" => }.isDefined + ) + case _ => + (None, false) + } + + def spanName(definition: ValOrDefDef): Tree = + nameParam match { + case Some(Left(const)) => + val literal = Literal(const) + q"$literal" + + case Some(Right(tree)) => + tree + + case None => + @annotation.tailrec + def resolveEnclosingName(symbol: Symbol, output: String): String = + if (symbol.isClass) { + val className = symbol.name.toString + if (className.startsWith("$anon")) + resolveEnclosingName(symbol.owner, "$anon" + "." + output) + else + className + "." + output + } else { + resolveEnclosingName(symbol.owner, output) + } + + val prefix = resolveEnclosingName(c.internal.enclosingOwner, "") + + val literal = Literal( + Constant(prefix + definition.name.decodedName.toString) + ) + q"$literal" + } + + def expandDef(defDef: DefDef): Tree = { + val effectType = resolveEffectType(defDef.tpt) + val name: Tree = spanName(defDef) + + val attributes = defDef.vparamss.flatten.flatMap { + case ValDef(mods, name, tpt, _) => + mods.annotations.flatMap { annotation => + val typed = c.typecheck(annotation) + if ( + typed.tpe.typeSymbol.fullName == "org.typelevel.otel4s.experimental.trace.attribute" + ) { + val keyArg = typed match { + case q"new ${_}(${keyArg})" => + keyArg + case _ => + abort("unknown structure of the @attribute annotation.") + } + + val key = keyArg match { + // the key param is not specified, use param name as a key + case Select(_, TermName("$lessinit$greater$default$1")) => + Literal(Constant(name.decodedName.toString)) + + // type of the AttributeKey must match the parameter type + case q"$_[$tpe]($_)(..$_)" => + val keyType = c.untypecheck(tpe) + val argType = c.typecheck(tpt, c.TYPEmode) + + if (!keyType.equalsStructure(argType)) { + abort( + s"the argument [${name.toString}] type [$argType] does not match the type of the attribute [$keyType]." + ) + } + keyArg + + case _ => + keyArg + } + + ensureImplicitExist( + q"_root_.org.typelevel.otel4s.AttributeKey.KeySelect[$tpt]", + e => + s"the argument [${name.decodedName}] cannot be used as an attribute. The type [$tpt] is not supported.${e.getMessage}" + ) + + List( + q"_root_.org.typelevel.otel4s.Attribute($key, $name)" + ) + } else { + Nil + } + } + + case _ => + Nil + } + + val body = + q""" + _root_.org.typelevel.otel4s.trace.Tracer[$effectType].span($name, ..$attributes).surround { + ${defDef.rhs} + } + """ + + DefDef( + defDef.mods, + defDef.name, + defDef.tparams, + defDef.vparamss, + defDef.tpt, + body + ) + } + + def expandVal(valDef: ValDef): Tree = { + val effectType = resolveEffectType(valDef.tpt) + val name: Tree = spanName(valDef) + + val body = + q""" + _root_.org.typelevel.otel4s.trace.Tracer[$effectType].span($name).surround { + ${valDef.rhs} + } + """ + + ValDef(valDef.mods, valDef.name, valDef.tpt, body) + } + + val result = annottees.map(_.tree).toList match { + case List(defDef: DefDef) => + expandDef(defDef) + + case List(valDef: ValDef) => + expandVal(valDef) + + case _ => + abort( + "unsupported definition. Only `def` and `val` with explicit result types and defined bodies are supported." + ) + } + + if (debug) { + val at: String = + if (c.enclosingPosition == NoPosition) + "" + else + s"at ${c.enclosingPosition.source.file.name}:${c.enclosingPosition.line} " + + scala.Predef.println(s"@span $at- expanded into:\n${result}") + } + + c.Expr(result) + } + +} diff --git a/modules/trace/src/main/scala-3/org/typelevel/otel4s/experimental/trace/span.scala b/modules/trace/src/main/scala-3/org/typelevel/otel4s/experimental/trace/span.scala new file mode 100644 index 0000000..b21375e --- /dev/null +++ b/modules/trace/src/main/scala-3/org/typelevel/otel4s/experimental/trace/span.scala @@ -0,0 +1,275 @@ +/* + * Copyright 2024 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.experimental.trace + +import org.typelevel.otel4s.Attribute +import org.typelevel.otel4s.AttributeKey +import org.typelevel.otel4s.trace.Tracer + +import scala.annotation.MacroAnnotation +import scala.annotation.compileTimeOnly +import scala.annotation.experimental +import scala.annotation.unused +import scala.quoted.* + +/** Wraps the body of an annotated method or variable into the span. + * + * By default, the span name will be `className.methodName`, unless a name is provided as an + * argument. + * + * {{{ + * @scala.annotation.experimental + * class Service[F[_]: Tracer](db: Database[F]) { + * @span + * def findUser(@attribute userId: Long): F[User] = + * db.findUser(id) + * } + * + * // expands into + * + * @scala.annotation.experimental + * class Service[F[_]: Tracer](db: Database[F]) { + * def findUser(userId: Long): F[User] = + * Tracer[F] + * .span("Service.findUser", Attribute("userId", id)) + * .surround(db.findUser(id)) + * } + * }}} + * + * @note + * macro remains experimental in Scala 3. Therefore, the enclosing class must be annotated with + * `@scala.annotation.experimental`. + * + * @param name + * the custom name of the span. If not specified, the span name will be `className.methodName` + * + * @param debug + * whether to print the generated code to the console + */ +// scalafmt: { maxColumn = 120 } +@compileTimeOnly("enable macro to expand macro annotations") +@experimental +class span( + @unused name: String = "", + @unused debug: Boolean = false +) extends MacroAnnotation { + + override def transform(using + quotes: Quotes + )(tree: quotes.reflect.Definition): List[quotes.reflect.Definition] = { + import quotes.reflect._ + + def abort(message: String) = + report.errorAndAbort(s"@span macro: $message", tree.pos) + + def resolveEffectType(tpe: TypeRepr): (TypeRepr, TypeRepr) = + tpe match { + case AppliedType(effect, inner :: Nil) => + (effect, inner) + + case _ => + abort("unknown structure of the val.") + } + + def resolveTracer(effect: TypeRepr): Term = { + val tracerType = TypeRepr.of[Tracer[_]] match { + case AppliedType(t, _) => + AppliedType(t, List(effect)) + case _ => + abort("cannot determine the effect type.") + } + + Implicits.search(tracerType) match { + case iss: ImplicitSearchSuccess => + iss.tree + case isf: ImplicitSearchFailure => + abort(s"cannot find Tracer[${effect.show}] in the implicit scope.") + } + } + + val (nameParam, debug) = { + val defaultArg = "$lessinit$greater$default$" + + def argValue[A](argTree: Tree, name: String)(pf: PartialFunction[Constant, A]): Option[A] = + argTree match { + case Select(_, n) if n.startsWith(defaultArg) => None + case Literal(pf(a)) => Some(a) + case NamedArg(_, Literal(pf(a))) => Some(a) + case other => abort(s"unknown structure of the '$name' argument: $other.") + } + + tree.symbol.getAnnotation(TypeRepr.of[span].typeSymbol) match { + case Some(Apply(_, nameArg :: debugArg :: Nil)) => + val name = argValue(nameArg, "name") { case StringConstant(const) => + const + } + + val debug = argValue(debugArg, "debug") { case BooleanConstant(const) => + const + } + + (name, debug.getOrElse(false)) + + case Some(other) => + abort(s"unknown structure of the @span annotation: $other.") + + case None => + abort("the @span annotation is missing.") + } + } + + def wrap( + tracer: Term, + resultType: TypeRepr, + body: Term, + definitionName: String, + attributes: Seq[Expr[Attribute[_]]] + ): Term = { + val nameArg = { + @annotation.tailrec + def resolveEnclosingName(symbol: Symbol, output: String): String = + if (symbol.isClassDef) { + val className = symbol.name + if (className.startsWith("$anon")) + resolveEnclosingName(symbol.owner, "$anon" + "." + output) + else + className + "." + output + } else { + resolveEnclosingName(symbol.owner, output) + } + + val name = nameParam.getOrElse { + val prefix = resolveEnclosingName(Symbol.spliceOwner, "") + prefix + definitionName + } + + Literal(StringConstant(name)) + } + + val attributesArg = Expr.ofSeq(attributes).asTerm + val args = List(nameArg, attributesArg) + + val spanOps = Select.overloaded(tracer, "span", Nil, args) + + Select + .unique(spanOps, "surround") + .appliedToType(resultType) + .appliedTo(body) + } + + def expandDef(defDef: DefDef): quotes.reflect.Definition = { + val (effect, inner) = resolveEffectType(defDef.returnTpt.tpe) + val tracer = resolveTracer(effect) + + val params: List[ValDef] = defDef.paramss.flatMap { + case TypeParamClause(_) => Nil + case TermParamClause(params) => params + } + + val attributes: List[Expr[Attribute[_]]] = + params.flatMap { case vd @ ValDef(name, tpt, _) => + val sym: Symbol = vd.symbol + + def verifyTypesMatch(argType: TypeTree) = + if (argType.tpe != tpt.tpe) + abort( + s"the argument [$name] type [${tpt.show}] does not match the type of the attribute [${argType.show}]." + ) + + sym.getAnnotation(TypeRepr.of[attribute].typeSymbol) match { + case Some(annotation) => + tpt.tpe.asType match { + case '[f] => + val keySelectExpr = Expr + .summon[AttributeKey.KeySelect[f]] + .getOrElse( + abort( + s"the argument [$name] cannot be used as an attribute. The type [${tpt.show}] is not supported." + ) + ) + + val argExpr = Ident(sym.termRef).asExprOf[f] + + val expr: Expr[Attribute[f]] = annotation match { + case Apply(_, List(Select(_, "$lessinit$greater$default$1"))) => + '{ Attribute(${ Expr(name) }, $argExpr)($keySelectExpr) } + + case Apply(_, List(literal @ Literal(StringConstant(const)))) => + '{ Attribute(${ Expr(const) }, $argExpr)($keySelectExpr) } + + case Apply(_, List(NamedArg(_, literal @ Literal(StringConstant(const))))) => + '{ Attribute(${ Expr(const) }, $argExpr)($keySelectExpr) } + + case Apply(_, List(apply @ Apply(Apply(TypeApply(_, List(typeArg)), _), _))) => + verifyTypesMatch(typeArg) + '{ Attribute(${ apply.asExprOf[AttributeKey[f]] }, $argExpr) } + + case Apply(_, List(NamedArg(_, apply @ Apply(Apply(TypeApply(_, List(typeArg)), _), _)))) => + verifyTypesMatch(typeArg) + '{ Attribute(${ apply.asExprOf[AttributeKey[f]] }, $argExpr) } + + case other => + abort(s"the argument [$name] has unsupported tree: ${other}.") + } + + List(expr) + } + + case None => + Nil + } + } + + val body = wrap(tracer, inner, defDef.rhs.get, defDef.name, attributes) + + DefDef.copy(tree)(defDef.name, defDef.paramss, defDef.returnTpt, Some(body)) + } + + def expandVal(valDef: ValDef): quotes.reflect.Definition = { + val (effect, inner) = resolveEffectType(valDef.tpt.tpe) + val tracer = resolveTracer(effect) + + val body = wrap(tracer, inner, valDef.rhs.get, valDef.name, Nil) + + ValDef.copy(tree)(valDef.name, valDef.tpt, Some(body)) + } + + val result = tree match { + case defDef @ DefDef(name, params, returnType, Some(rhs)) => + expandDef(defDef) + + case valDef @ ValDef(name, returnType, Some(rhs)) => + expandVal(valDef) + + case _ => + abort( + "unsupported definition. Only `def` and `val` with explicit result types and defined bodies are supported." + ) + } + + if (debug) { + val at = tree.symbol.pos + .map(pos => s"at ${pos.sourceFile.name}:${pos.startLine} ") + .getOrElse("") + + scala.Predef.println(s"@span $at- expanded into:\n${result.show}") + } + + List(result) + } + +} diff --git a/modules/trace/src/main/scala/org/typelevel/otel4s/experimental/trace/attribute.scala b/modules/trace/src/main/scala/org/typelevel/otel4s/experimental/trace/attribute.scala new file mode 100644 index 0000000..4ca3b4d --- /dev/null +++ b/modules/trace/src/main/scala/org/typelevel/otel4s/experimental/trace/attribute.scala @@ -0,0 +1,33 @@ +/* + * Copyright 2024 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.experimental.trace + +import org.typelevel.otel4s.AttributeKey + +import scala.annotation.StaticAnnotation +import scala.annotation.unused + +/** Marks a method parameter to be captured by the `@span` annotation. + * + * @param name + * the custom name of the attribute to use. If not specified, the name of the parameter will be + * used + */ +class attribute(@unused name: String = "") extends StaticAnnotation { + def this(key: AttributeKey[_]) = + this(key.name) +} diff --git a/modules/trace/src/test/scala-2/org/typelevel/otel4s/experimental/trace/package.scala b/modules/trace/src/test/scala-2/org/typelevel/otel4s/experimental/trace/package.scala new file mode 100644 index 0000000..fd6e95f --- /dev/null +++ b/modules/trace/src/test/scala-2/org/typelevel/otel4s/experimental/trace/package.scala @@ -0,0 +1,28 @@ +/* + * Copyright 2024 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.experimental + +import scala.annotation.Annotation +import scala.annotation.nowarn +import scala.annotation.unused + +package object trace { + + @nowarn + class experimental3(@unused value: String = "") extends Annotation + +} diff --git a/modules/trace/src/test/scala-3/org/typelevel/otel4s/experimental/trace/package.scala b/modules/trace/src/test/scala-3/org/typelevel/otel4s/experimental/trace/package.scala new file mode 100644 index 0000000..55e5bba --- /dev/null +++ b/modules/trace/src/test/scala-3/org/typelevel/otel4s/experimental/trace/package.scala @@ -0,0 +1,23 @@ +/* + * Copyright 2024 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.experimental + +package object trace { + + type experimental3 = scala.annotation.experimental + +} diff --git a/modules/trace/src/test/scala/org/typelevel/otel4s/experimental/trace/SpanAnnotationSuite.scala b/modules/trace/src/test/scala/org/typelevel/otel4s/experimental/trace/SpanAnnotationSuite.scala new file mode 100644 index 0000000..162d691 --- /dev/null +++ b/modules/trace/src/test/scala/org/typelevel/otel4s/experimental/trace/SpanAnnotationSuite.scala @@ -0,0 +1,306 @@ +/* + * Copyright 2024 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.experimental.trace + +import cats.Applicative +import cats.effect.IO +import cats.effect.Resource +import munit.CatsEffectSuite +import org.typelevel.otel4s.Attribute +import org.typelevel.otel4s.AttributeKey +import org.typelevel.otel4s.context.propagation.TextMapGetter +import org.typelevel.otel4s.context.propagation.TextMapUpdater +import org.typelevel.otel4s.trace.Span +import org.typelevel.otel4s.trace.SpanBuilder +import org.typelevel.otel4s.trace.SpanContext +import org.typelevel.otel4s.trace.SpanFinalizer.Strategy +import org.typelevel.otel4s.trace.SpanKind +import org.typelevel.otel4s.trace.SpanOps +import org.typelevel.otel4s.trace.Tracer + +import scala.collection.immutable +import scala.collection.mutable +import scala.concurrent.duration.FiniteDuration + +@experimental3 +class SpanAnnotationSuite extends CatsEffectSuite { + + test("def - capture annotated attributes") { + implicit val tracer: InMemoryTracer[IO] = new InMemoryTracer[IO] + + val userName = "user name" + val attempts = Seq(1L, 2L, 3L) + + val expected = Vector( + BuilderOp.Init("SpanAnnotationSuite.captureAttributes"), + BuilderOp.AddAttributes( + Seq( + Attribute("name", userName), + Attribute("attempts", attempts) + ) + ), + BuilderOp.Build + ) + + @span + def captureAttributes( + @attribute("name") name: String, + score: Long, + @attribute attempts: Seq[Long] + ): IO[Unit] = + IO.pure(name).void + + for { + _ <- captureAttributes(userName, 1L, attempts) + } yield assertEquals(tracer.builders.map(_.ops), Vector(expected)) + } + + test("def - derive method name") { + implicit val tracer: InMemoryTracer[IO] = new InMemoryTracer[IO] + + val expected = Vector( + BuilderOp.Init("SpanAnnotationSuite.methodName"), + BuilderOp.AddAttributes(Nil), + BuilderOp.Build + ) + + @span + def methodName: IO[Unit] = IO.unit + + for { + _ <- methodName + } yield assertEquals(tracer.builders.map(_.ops), Vector(expected)) + } + + test("resolve enclosing name - anonymous class") { + trait Service[F[_]] { + def find: F[Unit] + } + + implicit val tracer: InMemoryTracer[IO] = new InMemoryTracer[IO] + + val service: Service[IO] = new Service[IO] { + @span + def find: IO[Unit] = IO.unit + } + + val expected = Vector( + BuilderOp.Init("SpanAnnotationSuite.$anon.find"), + BuilderOp.AddAttributes(Nil), + BuilderOp.Build + ) + + for { + _ <- service.find + } yield assertEquals(tracer.builders.map(_.ops), Vector(expected)) + } + + // tagless + + test("tagless - def - derive name") { + implicit val tracer: InMemoryTracer[IO] = new InMemoryTracer[IO] + val service = new Service[IO] + + val userName = "user name" + val score = 1L + val attempts = Seq(1L, 2L, 3L) + + val expected = Vector( + BuilderOp.Init("Service.deriveNameDef"), + BuilderOp.AddAttributes(Nil), + BuilderOp.Build + ) + + for { + _ <- service.deriveNameDef(userName, score, attempts) + } yield assertEquals(tracer.builders.map(_.ops), Vector(expected)) + } + + test("tagless - def - custom name") { + implicit val tracer: InMemoryTracer[IO] = new InMemoryTracer[IO] + val service = new Service[IO] + + val userName = "user name" + val score = 1L + val isNew = false + + val expected = Vector( + BuilderOp.Init("custom_span_name"), + BuilderOp.AddAttributes( + Seq( + Attribute("user.name", userName), + Attribute("score", score), + Attribute("user.new", isNew) + ) + ), + BuilderOp.Build + ) + + for { + _ <- service.customNameDef(userName, score, isNew) + } yield assertEquals(tracer.builders.map(_.ops), Vector(expected)) + } + + test("tagless - val - derive name") { + implicit val tracer: InMemoryTracer[IO] = new InMemoryTracer[IO] + val service = new Service[IO] + + val expected = Vector( + BuilderOp.Init("Service.deriveNameVal"), + BuilderOp.AddAttributes(Nil), + BuilderOp.Build + ) + + for { + _ <- service.deriveNameVal + } yield assertEquals(tracer.builders.map(_.ops), Vector(expected)) + } + + test("tagless - val - custom name") { + implicit val tracer: InMemoryTracer[IO] = new InMemoryTracer[IO] + val service = new Service[IO] + + val expected = Vector( + BuilderOp.Init("some_custom_name"), + BuilderOp.AddAttributes(Nil), + BuilderOp.Build + ) + + for { + _ <- service.customNameVal + } yield assertEquals(tracer.builders.map(_.ops), Vector(expected)) + } + + class Service[F[_]: Tracer: Applicative] { + + @span + def deriveNameDef( + name: String, + score: Long, + attempts: Seq[Long] + ): F[Unit] = { + val _ = (name, score, attempts) + Applicative[F].unit + } + + @span("custom_span_name") + def customNameDef( + @attribute("user.name") name: String, + @attribute(name = "score") score: Long, + @attribute(AttributeKey[Boolean]("user.new")) isNew: Boolean + ): F[Unit] = { + val _ = (name, score, isNew) + Applicative[F].unit + } + + @span + lazy val deriveNameVal: F[Unit] = + Applicative[F].unit + + @span(name = "some_custom_name") + lazy val customNameVal: F[Unit] = + Applicative[F].unit + + } + + // utility + + private sealed trait BuilderOp + + private object BuilderOp { + case class Init(name: String) extends BuilderOp + + case class AddAttribute(attribute: Attribute[_]) extends BuilderOp + + case class AddAttributes( + attributes: immutable.Iterable[Attribute[_]] + ) extends BuilderOp + + case object Build extends BuilderOp + } + + private case class InMemoryBuilder[F[_]: Applicative]( + name: String + ) extends SpanBuilder[F] { + private val _ops: mutable.ArrayBuffer[BuilderOp] = new mutable.ArrayBuffer + _ops.addOne(BuilderOp.Init(name)) + + def ops: Vector[BuilderOp] = _ops.toVector + + def addAttribute[A](attribute: Attribute[A]): SpanBuilder[F] = { + _ops.addOne(BuilderOp.AddAttribute(attribute)) + this + } + + def addAttributes( + attributes: immutable.Iterable[Attribute[_]] + ): SpanBuilder[F] = { + _ops.addOne(BuilderOp.AddAttributes(attributes)) + this + } + + def addLink( + spanContext: SpanContext, + attributes: immutable.Iterable[Attribute[_]] + ): SpanBuilder[F] = ??? + + def withFinalizationStrategy(strategy: Strategy): SpanBuilder[F] = ??? + + def withSpanKind(spanKind: SpanKind): SpanBuilder[F] = ??? + + def withStartTimestamp(timestamp: FiniteDuration): SpanBuilder[F] = ??? + + def root: SpanBuilder[F] = ??? + + def withParent(parent: SpanContext): SpanBuilder[F] = ??? + + def build: SpanOps[F] = + new SpanOps[F] { + _ops.addOne(BuilderOp.Build) + def startUnmanaged: F[Span[F]] = ??? + def resource: Resource[F, SpanOps.Res[F]] = ??? + def use[A](f: Span[F] => F[A]): F[A] = + f(Span.fromBackend(Span.Backend.noop)) + def use_ : F[Unit] = Applicative[F].unit + } + } + + private class InMemoryTracer[F[_]: Applicative] extends Tracer[F] { + private val _builders: mutable.ArrayBuffer[InMemoryBuilder[F]] = + new mutable.ArrayBuffer + + def meta: Tracer.Meta[F] = Tracer.Meta.enabled + def currentSpanContext: F[Option[SpanContext]] = ??? + def currentSpanOrNoop: F[Span[F]] = ??? + def currentSpanOrThrow: F[Span[F]] = ??? + def childScope[A](parent: SpanContext)(fa: F[A]): F[A] = ??? + def joinOrRoot[A, C: TextMapGetter](carrier: C)(fa: F[A]): F[A] = ??? + def rootScope[A](fa: F[A]): F[A] = ??? + def noopScope[A](fa: F[A]): F[A] = ??? + def propagate[C: TextMapUpdater](carrier: C): F[C] = ??? + + def spanBuilder(name: String): SpanBuilder[F] = { + val builder = new InMemoryBuilder[F](name) + _builders.addOne(builder) + builder + } + + def builders: Vector[InMemoryBuilder[F]] = + _builders.toVector + } + +} From 67afc3fc8b3ce906e69eb5d860366c829435b270 Mon Sep 17 00:00:00 2001 From: Maksym Ochenashko Date: Tue, 9 Jul 2024 20:32:53 +0300 Subject: [PATCH 2/2] add docs --- .github/workflows/ci.yml | 54 +++++++++++++++++++++++++++++++++++++--- README.md | 46 +++++++++++++++++++++++++++++++++- build.sbt | 1 - 3 files changed, 96 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 69cf054..a1d0621 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,7 +30,7 @@ jobs: os: [ubuntu-latest] scala: [2.13, 3] java: [temurin@8] - project: [rootJVM] + project: [rootJS, rootJVM, rootNative] runs-on: ${{ matrix.os }} timeout-minutes: 60 steps: @@ -63,6 +63,14 @@ jobs: if: matrix.java == 'temurin@8' && matrix.os == 'ubuntu-latest' run: sbt 'project ${{ matrix.project }}' '++ ${{ matrix.scala }}' 'scalafixAll --check' + - name: scalaJSLink + if: matrix.project == 'rootJS' + run: sbt 'project ${{ matrix.project }}' '++ ${{ matrix.scala }}' Test/scalaJSLinkerResult + + - name: nativeLink + if: matrix.project == 'rootNative' + run: sbt 'project ${{ matrix.project }}' '++ ${{ matrix.scala }}' Test/nativeLink + - name: Test run: sbt 'project ${{ matrix.project }}' '++ ${{ matrix.scala }}' test @@ -76,11 +84,11 @@ jobs: - name: Make target directories if: github.event_name != 'pull_request' && (startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/main') - run: mkdir -p modules/metrics/jvm/target project/target + run: mkdir -p modules/trace/.jvm/target modules/trace/.js/target modules/metrics/jvm/target modules/trace/.native/target project/target - name: Compress target directories if: github.event_name != 'pull_request' && (startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/main') - run: tar cf targets.tar modules/metrics/jvm/target project/target + run: tar cf targets.tar modules/trace/.jvm/target modules/trace/.js/target modules/metrics/jvm/target modules/trace/.native/target project/target - name: Upload target directories if: github.event_name != 'pull_request' && (startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/main') @@ -117,6 +125,16 @@ jobs: if: matrix.java == 'temurin@8' && steps.setup-java-temurin-8.outputs.cache-hit == 'false' run: sbt +update + - name: Download target directories (2.13, rootJS) + uses: actions/download-artifact@v4 + with: + name: target-${{ matrix.os }}-${{ matrix.java }}-2.13-rootJS + + - name: Inflate target directories (2.13, rootJS) + run: | + tar xf targets.tar + rm targets.tar + - name: Download target directories (2.13, rootJVM) uses: actions/download-artifact@v4 with: @@ -127,6 +145,26 @@ jobs: tar xf targets.tar rm targets.tar + - name: Download target directories (2.13, rootNative) + uses: actions/download-artifact@v4 + with: + name: target-${{ matrix.os }}-${{ matrix.java }}-2.13-rootNative + + - name: Inflate target directories (2.13, rootNative) + run: | + tar xf targets.tar + rm targets.tar + + - name: Download target directories (3, rootJS) + uses: actions/download-artifact@v4 + with: + name: target-${{ matrix.os }}-${{ matrix.java }}-3-rootJS + + - name: Inflate target directories (3, rootJS) + run: | + tar xf targets.tar + rm targets.tar + - name: Download target directories (3, rootJVM) uses: actions/download-artifact@v4 with: @@ -137,6 +175,16 @@ jobs: tar xf targets.tar rm targets.tar + - name: Download target directories (3, rootNative) + uses: actions/download-artifact@v4 + with: + name: target-${{ matrix.os }}-${{ matrix.java }}-3-rootNative + + - name: Inflate target directories (3, rootNative) + run: | + tar xf targets.tar + rm targets.tar + - name: Import signing key if: env.PGP_SECRET != '' && env.PGP_PASSPHRASE == '' env: diff --git a/README.md b/README.md index fc969d1..668526c 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ * Provide access to the unstable functionality without breaking the `otel4s` * Some features may be upstreamed to the `otel4s` eventually -## Getting started +## Metrics - getting started Add the `otel4s-experimental-metrics` dependency to the `build.sbt`: ```scala @@ -40,4 +40,48 @@ def run: IO[Unit] = setup.surround(app) ``` +## Trace - getting started + +Add the `otel4s-experimental-trace` dependency to the `build.sbt`: +```scala +libraryDependencies ++= Seq( + "org.typelevel" %% "otel4s-experimental-trace" % "" +) +``` + +### 1) `@span` annotation + +The body of a method annotated with `@span` will be wrapped into a span: +```scala +import org.typelevel.otel4s.experimental.{attribute, trace} + +@span +def findUser( + @attribute userId: Long, + @attribute("user.hash") hash: String +): F[User] = ??? + +// expands into + +def findUser( + userId: Long, + hash: String +): F[User] = + Tracer[F].span( + "findUser", + Attribute("userId", userId), Attribute("user.hash", hash) + ).surround(???) +``` + +The macro works with variables too: +```scala +@span("custom_name") +val findUser: IO[User] = ??? + +// expands into + +val findUser: IO[User] = + Tracer[IO].span("custom_name").surround(???) +``` + [otel4s]: https://github.com/typelevel/otel4s diff --git a/build.sbt b/build.sbt index 7d1e7b0..98311ed 100644 --- a/build.sbt +++ b/build.sbt @@ -39,7 +39,6 @@ lazy val metrics = crossProject(JVMPlatform) name := "otel4s-experimental-metrics", Test / fork := true, libraryDependencies ++= Seq( - "org.typelevel" %%% "otel4s-core-metrics" % Versions.Otel4s, "org.typelevel" %%% "otel4s-core-metrics" % Versions.Otel4s, "org.typelevel" %%% "otel4s-sdk-metrics-testkit" % Versions.Otel4s % Test )