From 0edcafadeb32e21887f0bf29b479323de4e1a438 Mon Sep 17 00:00:00 2001 From: Maksym Ochenashko Date: Thu, 9 May 2024 16:04:02 +0300 Subject: [PATCH] core-trace-experimental: add `@withSpan` macro --- .github/workflows/ci.yml | 4 +- build.sbt | 19 +- .../otel4s/trace/experimental/withSpan.scala | 247 ++++++++++++++ .../otel4s/trace/experimental/withSpan.scala | 276 ++++++++++++++++ .../trace/experimental/spanAttribute.scala | 33 ++ .../otel4s/trace/experimental/package.scala | 28 ++ .../otel4s/trace/experimental/package.scala | 23 ++ .../trace/experimental/WithSpanSuite.scala | 306 ++++++++++++++++++ 8 files changed, 933 insertions(+), 3 deletions(-) create mode 100644 core/trace-experimental/src/main/scala-2/org/typelevel/otel4s/trace/experimental/withSpan.scala create mode 100644 core/trace-experimental/src/main/scala-3/org/typelevel/otel4s/trace/experimental/withSpan.scala create mode 100644 core/trace-experimental/src/main/scala/org/typelevel/otel4s/trace/experimental/spanAttribute.scala create mode 100644 core/trace-experimental/src/test/scala-2/org/typelevel/otel4s/trace/experimental/package.scala create mode 100644 core/trace-experimental/src/test/scala-3/org/typelevel/otel4s/trace/experimental/package.scala create mode 100644 core/trace-experimental/src/test/scala/org/typelevel/otel4s/trace/experimental/WithSpanSuite.scala diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 32403ef8b..ffed263ad 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -88,11 +88,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 semconv/stable/.jvm/target oteljava/metrics/target sdk-exporter/common/.js/target sdk/common/.native/target sdk/common/.js/target core/trace/.js/target sdk-exporter/all/.jvm/target semconv/experimental/.js/target sdk/trace/.js/target core/common/.jvm/target sdk-exporter/common/.native/target oteljava/common-testkit/target sdk/metrics/.native/target sdk-exporter/metrics/.jvm/target sdk-exporter/trace/.jvm/target unidocs/target sdk-exporter/metrics/.native/target oteljava/trace-testkit/target core/metrics/.native/target core/all/.native/target sdk/trace-testkit/.jvm/target sdk/trace-testkit/.native/target sdk/testkit/.native/target semconv/experimental/.native/target core/metrics/.jvm/target core/all/.js/target sdk-exporter/proto/.jvm/target sdk-exporter/proto/.js/target sdk-exporter/metrics/.js/target semconv/stable/.native/target sdk/all/.native/target sdk/metrics-testkit/.js/target core/metrics/.js/target sdk/testkit/.js/target core/all/.jvm/target sdk-exporter/trace/.native/target sdk/common/.jvm/target core/trace/.native/target oteljava/metrics-testkit/target sdk/trace/.native/target semconv/experimental/.jvm/target sdk/metrics-testkit/.native/target sdk/metrics/.jvm/target oteljava/common/target scalafix/rules/target sdk-exporter/proto/.native/target core/trace/.jvm/target sdk-exporter/common/.jvm/target sdk/metrics-testkit/.jvm/target sdk/metrics/.js/target sdk-exporter/trace/.js/target core/common/.native/target sdk/trace-testkit/.js/target core/common/.js/target oteljava/trace/target oteljava/testkit/target sdk/testkit/.jvm/target sdk-exporter/all/.js/target sdk/all/.js/target sdk/all/.jvm/target sdk-exporter/all/.native/target oteljava/all/target sdk/trace/.jvm/target semconv/stable/.js/target project/target + run: mkdir -p semconv/stable/.jvm/target oteljava/metrics/target core/trace-experimental/.js/target sdk-exporter/common/.js/target sdk/common/.native/target sdk/common/.js/target core/trace/.js/target sdk-exporter/all/.jvm/target semconv/experimental/.js/target sdk/trace/.js/target core/common/.jvm/target sdk-exporter/common/.native/target oteljava/common-testkit/target sdk/metrics/.native/target sdk-exporter/metrics/.jvm/target sdk-exporter/trace/.jvm/target unidocs/target sdk-exporter/metrics/.native/target oteljava/trace-testkit/target core/metrics/.native/target core/all/.native/target sdk/trace-testkit/.jvm/target sdk/trace-testkit/.native/target sdk/testkit/.native/target semconv/experimental/.native/target core/metrics/.jvm/target core/all/.js/target sdk-exporter/proto/.jvm/target sdk-exporter/proto/.js/target sdk-exporter/metrics/.js/target semconv/stable/.native/target sdk/all/.native/target sdk/metrics-testkit/.js/target core/trace-experimental/.native/target core/metrics/.js/target sdk/testkit/.js/target core/all/.jvm/target sdk-exporter/trace/.native/target sdk/common/.jvm/target core/trace-experimental/.jvm/target core/trace/.native/target oteljava/metrics-testkit/target sdk/trace/.native/target semconv/experimental/.jvm/target sdk/metrics-testkit/.native/target sdk/metrics/.jvm/target oteljava/common/target scalafix/rules/target sdk-exporter/proto/.native/target core/trace/.jvm/target sdk-exporter/common/.jvm/target sdk/metrics-testkit/.jvm/target sdk/metrics/.js/target sdk-exporter/trace/.js/target core/common/.native/target sdk/trace-testkit/.js/target core/common/.js/target oteljava/trace/target oteljava/testkit/target sdk/testkit/.jvm/target sdk-exporter/all/.js/target sdk/all/.js/target sdk/all/.jvm/target sdk-exporter/all/.native/target oteljava/all/target sdk/trace/.jvm/target semconv/stable/.js/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 semconv/stable/.jvm/target oteljava/metrics/target sdk-exporter/common/.js/target sdk/common/.native/target sdk/common/.js/target core/trace/.js/target sdk-exporter/all/.jvm/target semconv/experimental/.js/target sdk/trace/.js/target core/common/.jvm/target sdk-exporter/common/.native/target oteljava/common-testkit/target sdk/metrics/.native/target sdk-exporter/metrics/.jvm/target sdk-exporter/trace/.jvm/target unidocs/target sdk-exporter/metrics/.native/target oteljava/trace-testkit/target core/metrics/.native/target core/all/.native/target sdk/trace-testkit/.jvm/target sdk/trace-testkit/.native/target sdk/testkit/.native/target semconv/experimental/.native/target core/metrics/.jvm/target core/all/.js/target sdk-exporter/proto/.jvm/target sdk-exporter/proto/.js/target sdk-exporter/metrics/.js/target semconv/stable/.native/target sdk/all/.native/target sdk/metrics-testkit/.js/target core/metrics/.js/target sdk/testkit/.js/target core/all/.jvm/target sdk-exporter/trace/.native/target sdk/common/.jvm/target core/trace/.native/target oteljava/metrics-testkit/target sdk/trace/.native/target semconv/experimental/.jvm/target sdk/metrics-testkit/.native/target sdk/metrics/.jvm/target oteljava/common/target scalafix/rules/target sdk-exporter/proto/.native/target core/trace/.jvm/target sdk-exporter/common/.jvm/target sdk/metrics-testkit/.jvm/target sdk/metrics/.js/target sdk-exporter/trace/.js/target core/common/.native/target sdk/trace-testkit/.js/target core/common/.js/target oteljava/trace/target oteljava/testkit/target sdk/testkit/.jvm/target sdk-exporter/all/.js/target sdk/all/.js/target sdk/all/.jvm/target sdk-exporter/all/.native/target oteljava/all/target sdk/trace/.jvm/target semconv/stable/.js/target project/target + run: tar cf targets.tar semconv/stable/.jvm/target oteljava/metrics/target core/trace-experimental/.js/target sdk-exporter/common/.js/target sdk/common/.native/target sdk/common/.js/target core/trace/.js/target sdk-exporter/all/.jvm/target semconv/experimental/.js/target sdk/trace/.js/target core/common/.jvm/target sdk-exporter/common/.native/target oteljava/common-testkit/target sdk/metrics/.native/target sdk-exporter/metrics/.jvm/target sdk-exporter/trace/.jvm/target unidocs/target sdk-exporter/metrics/.native/target oteljava/trace-testkit/target core/metrics/.native/target core/all/.native/target sdk/trace-testkit/.jvm/target sdk/trace-testkit/.native/target sdk/testkit/.native/target semconv/experimental/.native/target core/metrics/.jvm/target core/all/.js/target sdk-exporter/proto/.jvm/target sdk-exporter/proto/.js/target sdk-exporter/metrics/.js/target semconv/stable/.native/target sdk/all/.native/target sdk/metrics-testkit/.js/target core/trace-experimental/.native/target core/metrics/.js/target sdk/testkit/.js/target core/all/.jvm/target sdk-exporter/trace/.native/target sdk/common/.jvm/target core/trace-experimental/.jvm/target core/trace/.native/target oteljava/metrics-testkit/target sdk/trace/.native/target semconv/experimental/.jvm/target sdk/metrics-testkit/.native/target sdk/metrics/.jvm/target oteljava/common/target scalafix/rules/target sdk-exporter/proto/.native/target core/trace/.jvm/target sdk-exporter/common/.jvm/target sdk/metrics-testkit/.jvm/target sdk/metrics/.js/target sdk-exporter/trace/.js/target core/common/.native/target sdk/trace-testkit/.js/target core/common/.js/target oteljava/trace/target oteljava/testkit/target sdk/testkit/.jvm/target sdk-exporter/all/.js/target sdk/all/.js/target sdk/all/.jvm/target sdk-exporter/all/.native/target oteljava/all/target sdk/trace/.jvm/target semconv/stable/.js/target project/target - name: Upload target directories if: github.event_name != 'pull_request' && (startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/main') diff --git a/build.sbt b/build.sbt index 903292991..19b2af5e7 100644 --- a/build.sbt +++ b/build.sbt @@ -1,4 +1,4 @@ -ThisBuild / tlBaseVersion := "0.7" +ThisBuild / tlBaseVersion := "0.8" ThisBuild / organization := "org.typelevel" ThisBuild / organizationName := "Typelevel" @@ -95,6 +95,7 @@ lazy val root = tlCrossRootProject `core-common`, `core-metrics`, `core-trace`, + `core-trace-experimental`, core, `sdk-common`, `sdk-metrics`, @@ -186,6 +187,21 @@ lazy val `core-trace` = crossProject(JVMPlatform, JSPlatform, NativePlatform) ) .settings(scalafixSettings) +lazy val `core-trace-experimental` = + crossProject(JVMPlatform, JSPlatform, NativePlatform) + .crossType(CrossType.Pure) + .in(file("core/trace-experimental")) + .dependsOn(`core-trace`) + .settings(scalaReflectDependency) + .settings(munitDependencies) + .settings( + name := "otel4s-core-trace-experimental", + scalacOptions ++= { + if (tlIsScala3.value) Nil else Seq("-Ymacro-annotations") + } + ) + .settings(scalafixSettings) + lazy val core = crossProject(JVMPlatform, JSPlatform, NativePlatform) .crossType(CrossType.Pure) .in(file("core/all")) @@ -691,6 +707,7 @@ lazy val unidocs = project `core-common`.jvm, `core-metrics`.jvm, `core-trace`.jvm, + `core-trace-experimental`.jvm, core.jvm, `sdk-common`.jvm, `sdk-metrics`.jvm, diff --git a/core/trace-experimental/src/main/scala-2/org/typelevel/otel4s/trace/experimental/withSpan.scala b/core/trace-experimental/src/main/scala-2/org/typelevel/otel4s/trace/experimental/withSpan.scala new file mode 100644 index 000000000..423b41891 --- /dev/null +++ b/core/trace-experimental/src/main/scala-2/org/typelevel/otel4s/trace/experimental/withSpan.scala @@ -0,0 +1,247 @@ +/* + * 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.experimental + +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]) { + * @withSpan + * def findUser(@spanAttribute 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 withSpan( + @unused name: String = "", + @unused debug: Boolean = false +) extends StaticAnnotation { + def macroTransform(annottees: Any*): Any = macro withSpanMacro.impl +} + +object withSpanMacro { + + 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"@withSpan 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.trace.experimental.spanAttribute" + ) { + val keyArg = typed match { + case q"new ${_}(${keyArg})" => + keyArg + case _ => + abort("unknown structure of the @spanAttribute 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"@withSpan $at- expanded into:\n${result}") + } + + c.Expr(result) + } + +} diff --git a/core/trace-experimental/src/main/scala-3/org/typelevel/otel4s/trace/experimental/withSpan.scala b/core/trace-experimental/src/main/scala-3/org/typelevel/otel4s/trace/experimental/withSpan.scala new file mode 100644 index 000000000..d95f10e08 --- /dev/null +++ b/core/trace-experimental/src/main/scala-3/org/typelevel/otel4s/trace/experimental/withSpan.scala @@ -0,0 +1,276 @@ +/* + * 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.experimental + +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]) { + * @withSpan + * def findUser(@spanAttribute 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 withSpan( + @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"@withSpan 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[withSpan].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 @withSpan annotation: $other.") + + case None => + abort("the @withSpan 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[spanAttribute].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"@withSpan $at- expanded into:\n${result.show}") + } + + List(result) + } + +} diff --git a/core/trace-experimental/src/main/scala/org/typelevel/otel4s/trace/experimental/spanAttribute.scala b/core/trace-experimental/src/main/scala/org/typelevel/otel4s/trace/experimental/spanAttribute.scala new file mode 100644 index 000000000..a0108bbb7 --- /dev/null +++ b/core/trace-experimental/src/main/scala/org/typelevel/otel4s/trace/experimental/spanAttribute.scala @@ -0,0 +1,33 @@ +/* + * 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.experimental + +import org.typelevel.otel4s.AttributeKey + +import scala.annotation.StaticAnnotation +import scala.annotation.unused + +/** Marks a method parameter to be captured by the `@withSpan` annotation. + * + * @param name + * the custom name of the attribute to use. If not specified, the name of the + * parameter will be used + */ +class spanAttribute(@unused name: String = "") extends StaticAnnotation { + def this(key: AttributeKey[_]) = + this(key.name) +} diff --git a/core/trace-experimental/src/test/scala-2/org/typelevel/otel4s/trace/experimental/package.scala b/core/trace-experimental/src/test/scala-2/org/typelevel/otel4s/trace/experimental/package.scala new file mode 100644 index 000000000..73ffa11ab --- /dev/null +++ b/core/trace-experimental/src/test/scala-2/org/typelevel/otel4s/trace/experimental/package.scala @@ -0,0 +1,28 @@ +/* + * 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 scala.annotation.Annotation +import scala.annotation.nowarn +import scala.annotation.unused + +package object experimental { + + @nowarn + class experimental3(@unused value: String = "") extends Annotation + +} diff --git a/core/trace-experimental/src/test/scala-3/org/typelevel/otel4s/trace/experimental/package.scala b/core/trace-experimental/src/test/scala-3/org/typelevel/otel4s/trace/experimental/package.scala new file mode 100644 index 000000000..a327dc4cb --- /dev/null +++ b/core/trace-experimental/src/test/scala-3/org/typelevel/otel4s/trace/experimental/package.scala @@ -0,0 +1,23 @@ +/* + * 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 + +package object experimental { + + type experimental3 = scala.annotation.experimental + +} diff --git a/core/trace-experimental/src/test/scala/org/typelevel/otel4s/trace/experimental/WithSpanSuite.scala b/core/trace-experimental/src/test/scala/org/typelevel/otel4s/trace/experimental/WithSpanSuite.scala new file mode 100644 index 000000000..c32eaaef3 --- /dev/null +++ b/core/trace-experimental/src/test/scala/org/typelevel/otel4s/trace/experimental/WithSpanSuite.scala @@ -0,0 +1,306 @@ +/* + * 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.experimental + +import cats.Applicative +import cats.effect.IO +import cats.effect.kernel.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 WithSpanSuite 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("WithSpanSuite.captureAttributes"), + BuilderOp.AddAttributes( + Seq( + Attribute("name", userName), + Attribute("attempts", attempts) + ) + ), + BuilderOp.Build + ) + + @withSpan + def captureAttributes( + @spanAttribute("name") name: String, + score: Long, + @spanAttribute 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("WithSpanSuite.methodName"), + BuilderOp.AddAttributes(Nil), + BuilderOp.Build + ) + + @withSpan + 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] { + @withSpan + def find: IO[Unit] = IO.unit + } + + val expected = Vector( + BuilderOp.Init("WithSpanSuite.$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] { + + @withSpan + def deriveNameDef( + name: String, + score: Long, + attempts: Seq[Long] + ): F[Unit] = { + val _ = (name, score, attempts) + Applicative[F].unit + } + + @withSpan("custom_span_name") + def customNameDef( + @spanAttribute("user.name") name: String, + @spanAttribute(name = "score") score: Long, + @spanAttribute(AttributeKey[Boolean]("user.new")) isNew: Boolean + ): F[Unit] = { + val _ = (name, score, isNew) + Applicative[F].unit + } + + @withSpan + lazy val deriveNameVal: F[Unit] = + Applicative[F].unit + + @withSpan(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 + } + +}