diff --git a/modules/mtl/src/main/scala/natchez/mtl/http4s/syntax/EntryPointOps.scala b/modules/mtl/src/main/scala/natchez/mtl/http4s/syntax/EntryPointOps.scala index 4b98a00..cdf2edb 100644 --- a/modules/mtl/src/main/scala/natchez/mtl/http4s/syntax/EntryPointOps.scala +++ b/modules/mtl/src/main/scala/natchez/mtl/http4s/syntax/EntryPointOps.scala @@ -18,27 +18,70 @@ import org.typelevel.ci.CIString trait EntryPointOps[F[_]] { outer => def self: EntryPoint[F] + /** + * Starts or continues a trace for each request handled by the passed `HttpApp[F]` + * using the default [[HttpTracingOptions]]. + * + * @param routes the `HttpApp[F]` that handles requests. Each invocation will occur in the scope of a new or continued `Trace[F]` + * @return the wrapped `HttpApp[F]` + */ + def liftApp(routes: HttpApp[F]) + (implicit F: MonadCancelThrow[F], + L: Local[F, Span[F]], + ): HttpApp[F] = + liftApp(routes, HttpTracingOptions[F]) + /** * Starts or continues a trace for each request handled by the passed `HttpApp[F]`. * * @param routes the `HttpApp[F]` that handles requests. Each invocation will occur in the scope of a new or continued `Trace[F]` - * @param isKernelHeader a function to determine whether a given request header should be included in the `Trace[F]`'s `Kernel` - * @param spanName a function to derive the name of the created span from the request being handled. - * By default, this uses the request method and URI path, although strictly speaking this - * is not compliant with the OpenTelemetry spec for any URIs with path variables. See Note 5 in the - * [[https://opentelemetry.io/docs/specs/semconv/attributes-registry/http/#http-attributes Semantic Conventions for HTTP]]. - * @param spanOptions allows the caller to override the Natchez Span options. By default, this uses the default options with a `Server` span kind. + * @param options the [[HttpTracingOptions]] to apply to each request * @return the wrapped `HttpApp[F]` */ def liftApp(routes: HttpApp[F], - isKernelHeader: CIString => Boolean = name => !ExcludedHeaders.contains(name), - spanName: Request[F] => String = (req: Request[F]) => s"${req.method} ${req.uri.path}", - spanOptions: Span.Options = Span.Options.Defaults.withSpanKind(Server), - ) + options: HttpTracingOptions[F]) (implicit F: MonadCancelThrow[F], L: Local[F, Span[F]], ): HttpApp[F] = - liftHttp[F](routes, isKernelHeader, spanName, spanOptions, FunctionK.id) + liftHttp(routes, options, FunctionK.id) + + /** + * Starts or continues a trace for each request handled by the passed `HttpRoutes[F]` + * using the default [[HttpTracingOptions]]. + * + * When using this method with OpenTelemetry to add tracing to a subset of the routes + * handled by your app, be careful to place the traced routes in the lowest priority + * when combining them with untraced routes. If the `HttpRoutes[F]` wrapped by this + * method are higher priority, traces will be emitted that contain no information, + * because [[EntryPoint.continueOrElseRoot(name:String,kernel:natchez\.Kernel)*]] + * will be invoked regardless of whether the routes passed to this method actually + * handle the request or not. + * + * For example: + * {{{ + * def routesToBeTraced: HttpRoutes[F] = ??? + * def untracedRoutes: HttpRoutes[F] = ??? + * + * untracedRoutes <+> entryPoint.liftRoutes(routesToBeTraced) + * }}} + * + * will work as expected, but + * + * {{{ + * entryPoint.liftRoutes(routesToBeTraced) <+> untracedRoutes + * }}} + * + * will not, and the requests handled by `untracedRoutes` will in fact generate + * empty traces. + * + * @param routes the `HttpRoutes[F]` that handles requests. Each invocation will occur in the scope of a new or continued `Trace[F]` + * @return the wrapped `HttpRoutes[F]` + */ + def liftRoutes(routes: HttpRoutes[F]) + (implicit F: MonadCancelThrow[F], + L: Local[F, Span[F]], + ): HttpRoutes[F] = + liftRoutes(routes, HttpTracingOptions[F]) /** * Starts or continues a trace for each request handled by the passed `HttpRoutes[F]`. @@ -69,42 +112,41 @@ trait EntryPointOps[F[_]] { outer => * empty traces. * * @param routes the `HttpRoutes[F]` that handles requests. Each invocation will occur in the scope of a new or continued `Trace[F]` - * @param isKernelHeader a function to determine whether a given request header should be included in the `Trace[F]`'s `Kernel` - * @param spanName a function to derive the name of the created span from the request being handled. - * By default, this uses the request method and URI path, although strictly speaking this - * is not compliant with the OpenTelemetry spec for any URIs with path variables. See Note 5 in the - * [[https://opentelemetry.io/docs/specs/semconv/attributes-registry/http/#http-attributes Semantic Conventions for HTTP]]. - * @param spanOptions allows the caller to override the Natchez Span options. By default, this uses the default options with a `Server` span kind. + * @param options the [[HttpTracingOptions]] to apply to each request * @return the wrapped `HttpRoutes[F]` */ def liftRoutes(routes: HttpRoutes[F], - isKernelHeader: CIString => Boolean = name => !ExcludedHeaders.contains(name), - spanName: Request[F] => String = (req: Request[F]) => s"${req.method} ${req.uri.path}", - spanOptions: Span.Options = Span.Options.Defaults.withSpanKind(Server), - ) + options: HttpTracingOptions[F]) (implicit F: MonadCancelThrow[F], L: Local[F, Span[F]], ): HttpRoutes[F] = - liftHttp(routes, isKernelHeader, spanName, spanOptions, OptionT.liftK) + liftHttp(routes, options, OptionT.liftK) + + /** + * Starts or continues a trace for each request handled by the passed `Http[G, F]` + * using the default [[HttpTracingOptions]]. + * + * @param routes the `Http[G, F]` that handles requests. Each invocation will occur in the scope of a new or continued `Trace[F]` + * @return the wrapped `Http[G, F]` + */ + def liftHttp[G[_]](routes: Http[G, F], + fk: F ~> G) + (implicit F: MonadCancelThrow[F], + G: MonadCancelThrow[G], + L: Local[G, Span[F]], + ): Http[G, F] = + liftHttp(routes, HttpTracingOptions[F], fk) /** * Starts or continues a trace for each request handled by the passed `Http[G, F]`. * * @param routes the `Http[G, F]` that handles requests. Each invocation will occur in the scope of a new or continued `Trace[F]` - * @param isKernelHeader a function to determine whether a given request header should be included in the `Trace[F]`'s `Kernel` - * @param spanName a function to derive the name of the created span from the request being handled. - * By default, this uses the request method and URI path, although strictly speaking this - * is not compliant with the OpenTelemetry spec for any URIs with path variables. See Note 5 in the - * [[https://opentelemetry.io/docs/specs/semconv/attributes-registry/http/#http-attributes Semantic Conventions for HTTP]]. - * @param spanOptions allows the caller to override the Natchez Span options. By default, this uses the default options with a `Server` span kind. + * @param options the [[HttpTracingOptions]] to apply to each request * @return the wrapped `Http[G, F]` */ def liftHttp[G[_]](routes: Http[G, F], - isKernelHeader: CIString => Boolean, - spanName: Request[F] => String, - spanOptions: Span.Options, - fk: F ~> G, - ) + options: HttpTracingOptions[F], + fk: F ~> G) (implicit F: MonadCancelThrow[F], G: MonadCancelThrow[G], L: Local[G, Span[F]], @@ -113,12 +155,12 @@ trait EntryPointOps[F[_]] { outer => val kernelHeaders = req.headers.headers .collect { - case header if isKernelHeader(header.name) => header.name -> header.value + case header if options.isKernelHeader(header.name) => header.name -> header.value } .toMap self - .continueOrElseRoot(spanName(req), Kernel(kernelHeaders), spanOptions) + .continueOrElseRoot(options.spanName(req), Kernel(kernelHeaders), options.spanOptions) .mapK(fk) .use(Local[G, Span[F]].scope(routes.run(req))) } @@ -133,3 +175,47 @@ trait ToEntryPointOps { } object entrypoint extends ToEntryPointOps + +/** + * A class holding the various options available to be set on traces started by [[EntryPointOps]]. + * + * @param isKernelHeader a function to determine whether a given request header should be included in the `Trace[F]`'s `Kernel` + * @param spanName a function to derive the name of the created span from the request being handled. + * By default, this uses the request method and URI path, although strictly speaking this + * is not compliant with the OpenTelemetry spec for any URIs with path variables. See Note 5 in the + * [[https://opentelemetry.io/docs/specs/semconv/attributes-registry/http/#http-attributes Semantic Conventions for HTTP]]. + * @param spanOptions allows the caller to override the Natchez Span options. By default, this uses the default options with a `Server` span kind. + */ +class HttpTracingOptions[F[_]] private(val isKernelHeader: CIString => Boolean, + val spanName: Request[F] => String, + val spanOptions: Span.Options, + ) { + def withKernelHeaderDiscriminator(f: CIString => Boolean): HttpTracingOptions[F] = + new HttpTracingOptions(f, spanName, spanOptions) + def withSpanNameBuilder[G[_]](f: Request[G] => String): HttpTracingOptions[G] = + new HttpTracingOptions(isKernelHeader, f, spanOptions) + def withSpanOptions(options: Span.Options): HttpTracingOptions[F] = + new HttpTracingOptions(isKernelHeader, spanName, options) + def mapK[G[_]](fk: G ~> F): HttpTracingOptions[G] = + this.withSpanNameBuilder(spanName.compose(_.mapK(fk))) +} + +object HttpTracingOptions { + /** + * Returns the default options for [[EntryPointOps]] functions. + * + * - `isKernelHeader` will exclude the headers defined in [[natchez.http4s.DefaultValues.ExcludedHeaders]] + * - `spanName` uses the request method and URI path, although strictly speaking this + * is not compliant with the OpenTelemetry spec for any URIs with path variables. See Note 5 in the + * [[https://opentelemetry.io/docs/specs/semconv/attributes-registry/http/#http-attributes Semantic Conventions for HTTP]]. + * - `spanOptions` uses Natchez's default span options with a `Server` span kind + * + * @return the default options for [[EntryPointOps]] functions. + */ + def apply[F[_]]: HttpTracingOptions[F] = + new HttpTracingOptions[F]( + isKernelHeader = !ExcludedHeaders.contains(_), + spanName = (req: Request[F]) => s"${req.method} ${req.uri.path}", + spanOptions = Span.Options.Defaults.withSpanKind(Server), + ) +} diff --git a/modules/mtl/src/test/scala/natchez/mtl/http4s/syntax/EntryPointOpsSuite.scala b/modules/mtl/src/test/scala/natchez/mtl/http4s/syntax/EntryPointOpsSuite.scala index 19801c8..178f79a 100644 --- a/modules/mtl/src/test/scala/natchez/mtl/http4s/syntax/EntryPointOpsSuite.scala +++ b/modules/mtl/src/test/scala/natchez/mtl/http4s/syntax/EntryPointOpsSuite.scala @@ -4,6 +4,7 @@ package natchez.mtl.http4s.syntax +import cats.arrow.FunctionK import cats.effect.{IO, IOLocal, MonadCancelThrow} import cats.mtl.Local import cats.syntax.all.* @@ -29,18 +30,19 @@ class EntryPointOpsSuite implicit val kernelMonoid: Monoid[Kernel] = Monoid.instance(Kernel(Map.empty), (a, b) => Kernel(a.toHeaders |+| b.toHeaders)) testLift("liftRoutes uses the kernel from the request to continue or create a new trace") { implicit local: Local[IO, Span[IO]] => - (ep: EntryPoint[IO]) => - _.fold(ep.liftRoutes(httpRoutes[IO]))(so => ep.liftRoutes(httpRoutes[IO], spanOptions = so)) - .orNotFound + _.liftRoutes(httpRoutes[IO], _).orNotFound } testLift("liftApp uses the kernel from the request to continue or create a new trace") { implicit local: Local[IO, Span[IO]] => - (ep: EntryPoint[IO]) => - _.fold(ep.liftApp(httpRoutes[IO].orNotFound))(so => ep.liftApp(httpRoutes[IO].orNotFound, spanOptions = so)) + _.liftApp(httpRoutes[IO].orNotFound, _) + } + + testLift("liftHttp uses the kernel from the request to continue or create a new trace") { implicit local: Local[IO, Span[IO]] => + _.liftHttp(httpRoutes[IO].orNotFound, _, FunctionK.id) } private def testLift(options: TestOptions) - (body: Local[IO, Span[IO]] => EntryPoint[IO] => Option[Span.Options] => HttpApp[IO]) + (body: Local[IO, Span[IO]] => (EntryPoint[IO], HttpTracingOptions[IO]) => HttpApp[IO]) (implicit loc: Location): Unit = test(options) { PropF.forAllNoShrinkF { (kernel: Kernel, maybeSpanOptions: Option[Span.Options]) => @@ -80,7 +82,7 @@ class EntryPointOpsSuite IOLocal(Span.noop[IO]) .map(EntryPointOpsSuite.catsMtlEffectLocalForIO(_)) .flatMap { - body(_)(ep)(maybeSpanOptions) + body(_)(ep, maybeSpanOptions.foldl(HttpTracingOptions[IO])(_ withSpanOptions _)) .run(request) } .flatMap(_ => ep.ref.get)