diff --git a/build.sbt b/build.sbt index 0243805a..7d8c05df 100644 --- a/build.sbt +++ b/build.sbt @@ -50,7 +50,7 @@ val Scala213 = "2.13.17" val Scala3 = "3.3.6" ThisBuild / crossScalaVersions := Seq(Scala212, Scala3, Scala213) -val catsEffectVersion = "3.5.7" +val catsEffectVersion = "3.6.3" val circeVersion = "0.14.15" val fs2Version = "3.12.2" val http4sVersion = "0.23.32" @@ -89,7 +89,9 @@ lazy val lambda = crossProject(JSPlatform, JVMPlatform) name := "feral-lambda", libraryDependencies ++= Seq( "org.typelevel" %%% "cats-effect" % catsEffectVersion, + "org.typelevel" %%% "cats-mtl" % "1.6.0", "org.tpolecat" %%% "natchez-core" % natchezVersion, + "org.tpolecat" %%% "natchez-mtl" % natchezVersion, "io.circe" %%% "circe-scodec" % circeVersion, "io.circe" %%% "circe-jawn" % circeVersion, "com.comcast" %%% "ip4s-core" % "3.7.0", diff --git a/examples/shared/src/main/scala/feral/examples/Http4sLambda.scala b/examples/shared/src/main/scala-2/feral/examples/Http4sHandler.scala similarity index 71% rename from examples/shared/src/main/scala/feral/examples/Http4sLambda.scala rename to examples/shared/src/main/scala-2/feral/examples/Http4sHandler.scala index 6d43807f..3333f11b 100644 --- a/examples/shared/src/main/scala/feral/examples/Http4sLambda.scala +++ b/examples/shared/src/main/scala-2/feral/examples/Http4sHandler.scala @@ -16,12 +16,13 @@ package feral.examples -import cats.effect._ import cats.effect.std.Random +import cats.effect.{Trace => _, _} +import cats.mtl._ import feral.lambda._ import feral.lambda.events._ import feral.lambda.http4s._ -import natchez.Trace +import natchez._ import natchez.http4s.NatchezMiddleware import natchez.xray.XRay import org.http4s.HttpApp @@ -43,6 +44,9 @@ import org.http4s.syntax.all._ object http4sHandler extends IOLambda[ApiGatewayProxyEventV2, ApiGatewayProxyStructuredResultV2] { + private type Handler = + Invocation[IO, ApiGatewayProxyEventV2] => IO[Option[ApiGatewayProxyStructuredResultV2]] + /** * Actually, this is a `Resource` that builds your handler. The handler is acquired exactly * once when your Lambda starts and is permanently installed to process all incoming events. @@ -52,29 +56,31 @@ object http4sHandler * event come from? Because accessing the event via `Invocation` is now also an effect in * `IO`, it becomes a step in your program. */ - def handler = for { - entrypoint <- Resource - .eval(Random.scalaUtilRandom[IO]) - .flatMap(implicit r => XRay.entryPoint[IO]()) - client <- EmberClientBuilder.default[IO].build - } yield { implicit inv => // the Invocation provides access to the event and context + def handler: Resource[IO, Handler] = + for { + entrypoint <- Resource + .eval(Random.scalaUtilRandom[IO]) + .flatMap(implicit r => XRay.entryPoint[IO]()) + client <- EmberClientBuilder.default[IO].build + implicit0(local: Local[IO, Span[IO]]) <- IO.local(Span.noop[IO]).toResource + } yield { implicit inv => // the Invocation provides access to the event and context - // a middleware to add tracing to any handler - // it extracts the kernel from the event and adds tags derived from the context - TracedHandler(entrypoint) { implicit trace => - val tracedClient = NatchezMiddleware.client(client) + // a middleware to add tracing to any handler + // it extracts the kernel from the event and adds tags derived from the context + TracedHandler(entrypoint) { implicit trace => + val tracedClient = NatchezMiddleware.client(client) - // a "middleware" that converts an HttpApp into a ApiGatewayProxyHandler - ApiGatewayProxyHandlerV2(myApp[IO](tracedClient)) + // a "middleware" that converts an HttpApp into a ApiGatewayProxyHandler + ApiGatewayProxyHandlerV2(myApp[IO](tracedClient)) + } } - } /** * Nothing special about this method, including its existence, just an example :) */ def myApp[F[_]: Concurrent: Trace](client: Client[F]): HttpApp[F] = { implicit val dsl = Http4sDsl[F] - import dsl._ + import dsl.* val routes = HttpRoutes.of[F] { case GET -> Root / "foo" => Ok("bar") diff --git a/examples/shared/src/main/scala-3/feral/examples/Http4sHandler.scala b/examples/shared/src/main/scala-3/feral/examples/Http4sHandler.scala new file mode 100644 index 00000000..9a73ea18 --- /dev/null +++ b/examples/shared/src/main/scala-3/feral/examples/Http4sHandler.scala @@ -0,0 +1,87 @@ +/* + * Copyright 2021 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 feral.examples + +import cats.effect.std.Random +import cats.effect.{Trace as _, *} +import cats.mtl.* +import feral.lambda.* +import feral.lambda.events.* +import feral.lambda.http4s.* +import natchez.* +import natchez.http4s.NatchezMiddleware +import natchez.xray.XRay +import org.http4s.HttpApp +import org.http4s.HttpRoutes +import org.http4s.client.Client +import org.http4s.dsl.Http4sDsl +import org.http4s.ember.client.EmberClientBuilder +import org.http4s.syntax.all.* + +/** + * For a gentle introduction, please look at the `KinesisLambda` first which uses + * `IOLambda.Simple`. + * + * The `IOLambda` uses a slightly more complicated encoding by introducing an effect + * `Invocation[F]` which provides access to the event and context in `F`. This allows you to + * compose your handler as a stack of "middlewares", making it easy to e.g. add tracing to your + * Lambda. + */ +object http4sHandler + extends IOLambda[ApiGatewayProxyEventV2, ApiGatewayProxyStructuredResultV2]: + + private type Handler = + Invocation[IO, ApiGatewayProxyEventV2] => IO[Option[ApiGatewayProxyStructuredResultV2]] + + /** + * Actually, this is a `Resource` that builds your handler. The handler is acquired exactly + * once when your Lambda starts and is permanently installed to process all incoming events. + * + * The handler itself is a program expressed as `IO[Option[Result]]`, which is run every time + * that your Lambda is triggered. This may seem counter-intuitive at first: where does the + * event come from? Because accessing the event via `Invocation` is now also an effect in + * `IO`, it becomes a step in your program. + */ + def handler: Resource[IO, Handler] = + for + entrypoint <- Resource + .eval(Random.scalaUtilRandom[IO]) + .flatMap(implicit r => XRay.entryPoint[IO]()) + client <- EmberClientBuilder.default[IO].build + given Local[IO, Span[IO]] <- IO.local(Span.noop[IO]).toResource + yield implicit inv => // the Invocation provides access to the event and context + + // a middleware to add tracing to any handler + // it extracts the kernel from the event and adds tags derived from the context + TracedHandler(entrypoint): + val tracedClient = NatchezMiddleware.client(client) + + // a "middleware" that converts an HttpApp into a ApiGatewayProxyHandler + ApiGatewayProxyHandlerV2(myApp[IO](tracedClient)) + + /** + * Nothing special about this method, including its existence, just an example :) + */ + def myApp[F[_]: Concurrent: Trace](client: Client[F]): HttpApp[F] = + implicit val dsl = Http4sDsl[F] + import dsl.* + + val routes = HttpRoutes.of[F]: + case GET -> Root / "foo" => Ok("bar") + case GET -> Root / "joke" => Ok(client.expect[String](uri"icanhazdadjoke.com")) + + NatchezMiddleware.server(routes).orNotFound diff --git a/lambda/shared/src/main/scala-2/feral/lambda/TracedHandlerPlatform.scala b/lambda/shared/src/main/scala-2/feral/lambda/TracedHandlerPlatform.scala new file mode 100644 index 00000000..f73e628c --- /dev/null +++ b/lambda/shared/src/main/scala-2/feral/lambda/TracedHandlerPlatform.scala @@ -0,0 +1,30 @@ +/* + * Copyright 2021 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 feral.lambda + +import cats.effect.{Trace => _, _} +import cats.mtl.Local +import natchez._ + +trait TracedHandlerPlatform { + def apply[F[_]: MonadCancelThrow, Event, Result](entryPoint: EntryPoint[F])( + handler: Trace[F] => F[Option[Result]])( + implicit inv: Invocation[F, Event], + KS: KernelSource[Event], + L: Local[F, Span[F]]): F[Option[Result]] = + TracedHandlerImpl[F, Event, Result](entryPoint)(handler) +} diff --git a/lambda/shared/src/main/scala-3/feral/lambda/TracedHandlerPlatform.scala b/lambda/shared/src/main/scala-3/feral/lambda/TracedHandlerPlatform.scala new file mode 100644 index 00000000..70c3f4f4 --- /dev/null +++ b/lambda/shared/src/main/scala-3/feral/lambda/TracedHandlerPlatform.scala @@ -0,0 +1,29 @@ +/* + * Copyright 2021 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 feral.lambda + +import cats.effect.{Trace as _, *} +import cats.mtl.Local +import natchez.* + +trait TracedHandlerPlatform: + def apply[F[_]: MonadCancelThrow, Event, Result](entryPoint: EntryPoint[F])( + handler: Trace[F] ?=> F[Option[Result]])( + using Invocation[F, Event], + KernelSource[Event], + Local[F, Span[F]]): F[Option[Result]] = + TracedHandlerImpl[F, Event, Result](entryPoint) { implicit t => handler } diff --git a/lambda/shared/src/main/scala/feral/lambda/TracedHandler.scala b/lambda/shared/src/main/scala/feral/lambda/TracedHandler.scala index ac8cc28c..f12e535d 100644 --- a/lambda/shared/src/main/scala/feral/lambda/TracedHandler.scala +++ b/lambda/shared/src/main/scala/feral/lambda/TracedHandler.scala @@ -18,44 +18,89 @@ package feral.lambda import cats.data.Kleisli import cats.effect.IO -import cats.effect.kernel.MonadCancelThrow +import cats.effect.{Trace => _, _} +import cats.mtl.Local import cats.syntax.all._ -import natchez.EntryPoint -import natchez.Span -import natchez.Trace +import fs2.compat.NotGiven +import natchez._ +import natchez.mtl._ -object TracedHandler { +object TracedHandler extends TracedHandlerPlatform { - def apply[Event, Result](entryPoint: EntryPoint[IO])( + def apply[Event, Result]( + entryPoint: EntryPoint[IO], handler: Trace[IO] => IO[Option[Result]])( implicit inv: Invocation[IO, Event], - KS: KernelSource[Event]): IO[Option[Result]] = for { - event <- inv.event - context <- inv.context - kernel = KS.extract(event) - result <- entryPoint.continueOrElseRoot(context.functionName, kernel).use { span => - span.put( - AwsTags.arn(context.invokedFunctionArn), - AwsTags.requestId(context.awsRequestId) - ) >> Trace.ioTrace(span) >>= handler - } - } yield result + KS: KernelSource[Event], + @annotation.unused NotLocal: NotGiven[Local[IO, Span[IO]]]): IO[Option[Result]] = + for { + event <- inv.event + context <- inv.context + kernel = KS.extract(event) + result <- entryPoint.continueOrElseRoot(context.functionName, kernel).use { span => + span.put( + AwsTags.arn(context.invokedFunctionArn), + AwsTags.requestId(context.awsRequestId) + ) >> Trace.ioTrace(span) >>= handler + } + } yield result def apply[F[_]: MonadCancelThrow, Event, Result]( entryPoint: EntryPoint[F], handler: Kleisli[F, Span[F], Option[Result]])( - // inv first helps bind Event for KernelSource. h/t @bpholt implicit inv: Invocation[F, Event], - KS: KernelSource[Event]): F[Option[Result]] = for { - event <- inv.event - context <- inv.context - kernel = KS.extract(event) - result <- entryPoint.continueOrElseRoot(context.functionName, kernel).use { span => - span.put( - AwsTags.arn(context.invokedFunctionArn), - AwsTags.requestId(context.awsRequestId) - ) >> handler(span) - } - } yield result + KS: KernelSource[Event], + @annotation.unused NotLocal: NotGiven[Local[IO, Span[IO]]]): F[Option[Result]] = + for { + event <- inv.event + context <- inv.context + kernel = KS.extract(event) + result <- entryPoint.continueOrElseRoot(context.functionName, kernel).use { span => + span.put( + AwsTags.arn(context.invokedFunctionArn), + AwsTags.requestId(context.awsRequestId) + ) >> handler(span) + } + } yield result + + @deprecated("use variant with Local tracing semantics", "0.3.2") + def apply[Event, Result]( + entryPoint: EntryPoint[IO], + handler: Trace[IO] => IO[Option[Result]], + inv: Invocation[IO, Event], + KS: KernelSource[Event]): IO[Option[Result]] = + apply(entryPoint, handler)(inv, KS, implicitly) + + @deprecated("use variant with Local tracing semantics", "0.3.2") + def apply[F[_], Event, Result]( + entryPoint: EntryPoint[F], + handler: Kleisli[F, Span[F], Option[Result]], + M: MonadCancel[F, Throwable], + inv: Invocation[F, Event], + KS: KernelSource[Event]): F[Option[Result]] = { + apply(entryPoint, handler)(M, inv, KS, implicitly) + } + +} + +private[lambda] object TracedHandlerImpl { + def apply[F[_]: MonadCancelThrow, Event, Result](entryPoint: EntryPoint[F])( + handler: Trace[F] => F[Option[Result]])( + implicit inv: Invocation[F, Event], + KS: KernelSource[Event], + L: Local[F, Span[F]]): F[Option[Result]] = + for { + event <- Invocation[F, Event].event + context <- Invocation[F, Event].context + kernel = KernelSource[Event].extract(event) + result <- entryPoint.continueOrElseRoot(context.functionName, kernel).use { + Local[F, Span[F]].scope { + Trace[F].put( + AwsTags.arn(context.invokedFunctionArn), + AwsTags.requestId(context.awsRequestId) + ) >> handler(Trace[F]) + } + } + } yield result } diff --git a/lambda/shared/src/test/scala-2/feral/lambda/TracedLambdaSuite.scala b/lambda/shared/src/test/scala-2/feral/lambda/TracedLambdaSuite.scala new file mode 100644 index 00000000..77bbee24 --- /dev/null +++ b/lambda/shared/src/test/scala-2/feral/lambda/TracedLambdaSuite.scala @@ -0,0 +1,44 @@ +/* + * Copyright 2021 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 feral.lambda + +import cats.data.Kleisli +import cats.effect.IO +import feral.lambda.events.KinesisStreamEvent +import natchez.EntryPoint +import natchez.Span +import natchez.Trace + +import scala.annotation.nowarn + +class TracedLambdaSuite { + + @nowarn + def syntaxTest = { // Checking for compilation, nothing more + + implicit def inv: Invocation[IO, KinesisStreamEvent] = ??? + def ioEntryPoint: EntryPoint[IO] = ??? + def needsTrace[F[_]: Trace]: F[Option[INothing]] = ??? + + IO.local(Span.noop[IO]).flatMap { implicit local => + TracedHandler(ioEntryPoint) { implicit trace => needsTrace[IO] } + } + + TracedHandler(ioEntryPoint, Kleisli[IO, Span[IO], Option[INothing]](???)) + } + +} diff --git a/lambda/shared/src/test/scala/feral/lambda/TracedHandlerSuite.scala b/lambda/shared/src/test/scala-3/feral/lambda/TracedLambdaSuite.scala similarity index 90% rename from lambda/shared/src/test/scala/feral/lambda/TracedHandlerSuite.scala rename to lambda/shared/src/test/scala-3/feral/lambda/TracedLambdaSuite.scala index b02044fe..359bdcf9 100644 --- a/lambda/shared/src/test/scala/feral/lambda/TracedHandlerSuite.scala +++ b/lambda/shared/src/test/scala-3/feral/lambda/TracedLambdaSuite.scala @@ -34,7 +34,9 @@ class TracedLambdaSuite { def ioEntryPoint: EntryPoint[IO] = ??? def needsTrace[F[_]: Trace]: F[Option[INothing]] = ??? - TracedHandler(ioEntryPoint) { implicit trace => needsTrace[IO] } + IO.local(Span.noop[IO]).flatMap { implicit local => + TracedHandler(ioEntryPoint) { needsTrace[IO] } + } TracedHandler(ioEntryPoint, Kleisli[IO, Span[IO], Option[INothing]](???)) }