Skip to content

Commit

Permalink
refactor natchez-mtl trace options to facilitate future binary compat…
Browse files Browse the repository at this point in the history
…ibility
  • Loading branch information
bpholt committed Oct 4, 2024
1 parent 2966072 commit 36e9065
Show file tree
Hide file tree
Showing 2 changed files with 130 additions and 42 deletions.
156 changes: 121 additions & 35 deletions modules/mtl/src/main/scala/natchez/mtl/http4s/syntax/EntryPointOps.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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]`.
Expand Down Expand Up @@ -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]],
Expand All @@ -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)))
}
Expand All @@ -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),
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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.*
Expand 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]) =>
Expand Down Expand Up @@ -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)
Expand Down

0 comments on commit 36e9065

Please sign in to comment.