diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3e9c50c8..8453a625 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -63,11 +63,11 @@ jobs: - name: Make target directories if: github.event_name != 'pull_request' && (startsWith(github.ref, 'refs/tags/v')) - run: mkdir -p money-java-servlet/target money-aspectj/target money-http-client/target money-otlp-http-exporter/target money-api/target money-kafka/target money-otel-handler/target money-otel-jaeger-exporter/target money-otlp-exporter/target money-otel-zipkin-exporter/target money-akka/target money-otel-formatters/target money-spring/target money-core/target money-otel-logging-exporter/target money-otel-inmemory-exporter/target money-wire/target project/target + run: mkdir -p money-java-servlet/target money-aspectj/target money-http-client/target money-otlp-http-exporter/target money-api/target money-kafka/target money-otel-handler/target money-otel-jaeger-exporter/target money-otlp-exporter/target money-otel-zipkin-exporter/target money-akka/target money-otel-formatters/target money-spring/target money-core/target money-otel-logging-exporter/target money-otel-inmemory-exporter/target money-jakarta-servlet/target money-wire/target project/target - name: Compress target directories if: github.event_name != 'pull_request' && (startsWith(github.ref, 'refs/tags/v')) - run: tar cf targets.tar money-java-servlet/target money-aspectj/target money-http-client/target money-otlp-http-exporter/target money-api/target money-kafka/target money-otel-handler/target money-otel-jaeger-exporter/target money-otlp-exporter/target money-otel-zipkin-exporter/target money-akka/target money-otel-formatters/target money-spring/target money-core/target money-otel-logging-exporter/target money-otel-inmemory-exporter/target money-wire/target project/target + run: tar cf targets.tar money-java-servlet/target money-aspectj/target money-http-client/target money-otlp-http-exporter/target money-api/target money-kafka/target money-otel-handler/target money-otel-jaeger-exporter/target money-otlp-exporter/target money-otel-zipkin-exporter/target money-akka/target money-otel-formatters/target money-spring/target money-core/target money-otel-logging-exporter/target money-otel-inmemory-exporter/target money-jakarta-servlet/target money-wire/target project/target - name: Upload target directories if: github.event_name != 'pull_request' && (startsWith(github.ref, 'refs/tags/v')) diff --git a/build.sbt b/build.sbt index f93ed239..bf1764bb 100644 --- a/build.sbt +++ b/build.sbt @@ -42,6 +42,7 @@ lazy val money = moneyAspectj, moneyHttpClient, moneyJavaServlet, + moneyJakartaServlet, moneyWire, moneyKafka, moneySpring, @@ -151,6 +152,18 @@ lazy val moneyJavaServlet = ) .dependsOn(moneyCore % "test->test;compile->compile") +lazy val moneyJakartaServlet = + Project("money-jakarta-servlet", file("./money-jakarta-servlet")) + .enablePlugins(AutomateHeaderPlugin) + .settings(projectSettings: _*) + .settings( + libraryDependencies ++= + Seq( + jakartaServlet + ) ++ commonTestDependencies + ) + .dependsOn(moneyCore % "test->test;compile->compile") + lazy val moneyWire = Project("money-wire", file("./money-wire")) .enablePlugins(AutomateHeaderPlugin) diff --git a/money-jakarta-servlet/src/main/scala/com/comcast/money/jakarta/servlet/TraceFilter.scala b/money-jakarta-servlet/src/main/scala/com/comcast/money/jakarta/servlet/TraceFilter.scala new file mode 100644 index 00000000..360981c5 --- /dev/null +++ b/money-jakarta-servlet/src/main/scala/com/comcast/money/jakarta/servlet/TraceFilter.scala @@ -0,0 +1,67 @@ +/* + * Copyright 2012 Comcast Cable Communications Management, LLC + * + * 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 com.comcast.money.jakarta.servlet + +import com.comcast.money.core.Money +import io.opentelemetry.context.{ Context, Scope } +import org.slf4j.LoggerFactory + +import jakarta.servlet._ +import jakarta.servlet.http.{ HttpServletRequest, HttpServletRequestWrapper, HttpServletResponse } +import scala.collection.JavaConverters._ + +/** + * A Java Servlet 2.5 Filter. Examines the inbound http request, and will set the + * trace context for the request if the money trace header or X-B3 style headers are found + */ +class TraceFilter extends Filter { + + private val logger = LoggerFactory.getLogger(classOf[TraceFilter]) + private val tracer = Money.Environment.tracer + private val formatter = Money.Environment.formatter + + override def init(filterConfig: FilterConfig): Unit = {} + + override def destroy(): Unit = {} + + private val spanName = "servlet" + + override def doFilter(request: ServletRequest, response: ServletResponse, chain: FilterChain): Unit = { + + val httpRequest = new HttpServletRequestWrapper(request.asInstanceOf[HttpServletRequest]) + + val headerNames: Iterable[String] = httpRequest.getHeaderNames.asScala.toIterable.asInstanceOf[Iterable[String]] + val scope: Scope = formatter.fromHttpHeaders(headerNames, httpRequest.getHeader, logger.warn) match { + case Some(spanId) => + val span = tracer.spanFactory.newSpan(spanId, spanName) + Context.root() + .`with`(span) + .makeCurrent() + case None => () => () + } + + try { + val httpResponse = response.asInstanceOf[HttpServletResponse] + formatter.setResponseHeaders(httpRequest.getHeader, httpResponse.addHeader) + + chain.doFilter(request, response) + } finally { + scope.close() + } + } + +} diff --git a/money-jakarta-servlet/src/test/scala/com/comcast/money/jakarta/servlet/TraceFilterSpec.scala b/money-jakarta-servlet/src/test/scala/com/comcast/money/jakarta/servlet/TraceFilterSpec.scala new file mode 100644 index 00000000..b48082cd --- /dev/null +++ b/money-jakarta-servlet/src/test/scala/com/comcast/money/jakarta/servlet/TraceFilterSpec.scala @@ -0,0 +1,124 @@ +/* + * Copyright 2012 Comcast Cable Communications Management, LLC + * + * 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 com.comcast.money.jakarta.servlet + +import com.comcast.money.api.{ Span, SpanId } +import com.comcast.money.core.formatters.FormatterUtils.randomRemoteSpanId +import com.comcast.money.core.internal.SpanLocal +import org.mockito.Mockito._ +import org.mockito.stubbing.OngoingStubbing +import org.scalatest.OptionValues._ +import org.scalatest.matchers.should.Matchers +import org.scalatest.wordspec.AnyWordSpec +import org.scalatest.{ BeforeAndAfter, OneInstancePerTest } +import org.scalatestplus.mockito.MockitoSugar + +import java.util.Collections +import jakarta.servlet.http.{ HttpServletRequest, HttpServletResponse } +import jakarta.servlet.{ FilterChain, FilterConfig, ServletRequest, ServletResponse } + +class TraceFilterSpec extends AnyWordSpec with Matchers with OneInstancePerTest with BeforeAndAfter with MockitoSugar { + + val mockRequest = mock[HttpServletRequest] + val mockResponse = mock[HttpServletResponse] + val mockFilterChain = mock[FilterChain] + val existingSpanId = randomRemoteSpanId() + val underTest = new TraceFilter() + val MoneyTraceFormat = "trace-id=%s;parent-id=%s;span-id=%s" + val filterChain: FilterChain = (_: ServletRequest, _: ServletResponse) => capturedSpan = SpanLocal.current + var capturedSpan: Option[Span] = None + + def traceParentHeader(spanId: SpanId): String = { + val traceId = spanId.traceId.replace("-", "").toLowerCase + f"00-$traceId%s-${spanId.selfId}%016x-00" + } + + before { + capturedSpan = None + val empty: java.util.Enumeration[_] = Collections.emptyEnumeration() + // The raw type seems to confuse the Scala compiler so the cast is required to compile successfully + when(mockRequest.getHeaderNames).asInstanceOf[OngoingStubbing[java.util.Enumeration[_]]].thenReturn(empty) + } + + "A TraceFilter" should { + "clear the trace context when an http request arrives" in { + underTest.doFilter(mockRequest, mockResponse, filterChain) + SpanLocal.current shouldBe None + } + + "always call the filter chain" in { + underTest.doFilter(mockRequest, mockResponse, mockFilterChain) + verify(mockFilterChain).doFilter(mockRequest, mockResponse) + } + + "set the trace context to the money trace header if present" in { + when(mockRequest.getHeader("X-MoneyTrace")) + .thenReturn(MoneyTraceFormat.format(existingSpanId.traceId, existingSpanId.parentId, existingSpanId.selfId)) + underTest.doFilter(mockRequest, mockResponse, filterChain) + capturedSpan.value.info.id shouldEqual existingSpanId + } + + "set the trace context to the traceparent header if present" in { + when(mockRequest.getHeader("traceparent")) + .thenReturn(traceParentHeader(existingSpanId)) + underTest.doFilter(mockRequest, mockResponse, filterChain) + + val actualSpanId = capturedSpan.value.info.id + actualSpanId.traceId shouldEqual existingSpanId.traceId + actualSpanId.parentId shouldEqual existingSpanId.selfId + } + + "prefer the money trace header over the W3C Trace Context header" in { + when(mockRequest.getHeader("X-MoneyTrace")) + .thenReturn(MoneyTraceFormat.format(existingSpanId.traceId, existingSpanId.parentId, existingSpanId.selfId)) + when(mockRequest.getHeader("traceparent")) + .thenReturn(traceParentHeader(SpanId.createNew())) + underTest.doFilter(mockRequest, mockResponse, filterChain) + capturedSpan.value.info.id shouldEqual existingSpanId + } + + "not set the trace context if the money trace header could not be parsed" in { + when(mockRequest.getHeader("X-MoneyTrace")).thenReturn("can't parse this") + underTest.doFilter(mockRequest, mockResponse, filterChain) + capturedSpan shouldBe None + } + + "adds Money header to response" in { + when(mockRequest.getHeader("X-MoneyTrace")) + .thenReturn(MoneyTraceFormat.format(existingSpanId.traceId, existingSpanId.parentId, existingSpanId.selfId)) + underTest.doFilter(mockRequest, mockResponse, mockFilterChain) + verify(mockResponse).addHeader( + "X-MoneyTrace", + MoneyTraceFormat.format(existingSpanId.traceId, existingSpanId.parentId, existingSpanId.selfId)) + } + + "adds Trace Context header to response" in { + when(mockRequest.getHeader("traceparent")) + .thenReturn(traceParentHeader(existingSpanId)) + underTest.doFilter(mockRequest, mockResponse, mockFilterChain) + verify(mockResponse).addHeader( + "traceparent", + traceParentHeader(existingSpanId)) + } + + "loves us some test coverage" in { + val mockConf = mock[FilterConfig] + underTest.init(mockConf) + underTest.destroy() + } + } +} diff --git a/project/Dependencies.scala b/project/Dependencies.scala index aabdf879..1b3614e8 100644 --- a/project/Dependencies.scala +++ b/project/Dependencies.scala @@ -43,6 +43,8 @@ object Dependencies { // Javax servlet - note: the group id and artfacit id have changed in 3.0 val javaxServlet = "javax.servlet" % "servlet-api" % "2.5" + val jakartaServlet = "jakarta.servlet" % "jakarta.servlet-api" % "5.0.0" + // Kafka, exclude dependencies that we will not need, should work for 2.10 and 2.11 val kafka = ("org.apache.kafka" %% "kafka" % "2.4.0") .exclude("javax.jms", "jms")