From e96d28285053de262b1974700eb2daa1f3ba6156 Mon Sep 17 00:00:00 2001 From: Marissa Date: Thu, 7 Nov 2024 07:57:29 -0500 Subject: [PATCH] Abstract and fix middlewares Add abstraction to middlewares to support full customisation of span names and attributes while keeping default use case as simple as possible. Fix default attributes to align with otel semantic conventions. Add tests for all attribute creation. --- build.sbt | 1 + .../otel4s/middleware/TypedAttributes.scala | 276 ++++-------- .../otel4s/middleware/UriRedactor.scala | 132 ------ .../middleware/redact/PathRedactor.scala | 35 ++ .../middleware/redact/QueryRedactor.scala | 35 ++ .../otel4s/middleware/redact/package.scala | 23 + .../middleware/TypedAttributesTest.scala | 179 ++++++++ .../otel4s/middleware/UriRedactorTest.scala | 92 ---- .../middleware/redact/PathRedactorTest.scala | 34 ++ .../middleware/redact/QueryRedactorTest.scala | 36 ++ .../main/scala/example/Http4sExample.scala | 27 +- .../trace/client/AttributeProvider.scala | 81 ++++ .../trace/client/ClientMiddleware.scala | 254 ----------- .../client/ClientMiddlewareBuilder.scala | 174 ++++++++ .../trace/client/SpanDataProvider.scala | 230 ++++++++++ .../trace/client/TypedClientAttributes.scala | 63 ++- .../middleware/trace/client/UriRedactor.scala | 75 ++++ .../trace/client/UriTemplateClassifier.scala | 39 ++ .../trace/client/ClientMiddlewareTests.scala | 105 +++-- .../client/TypedClientAttributesTest.scala | 104 +++++ .../trace/client/UriRedactorTest.scala | 53 +++ .../trace/HeadersAllowedAsAttributes.scala | 190 ++++++++ .../otel4s/middleware/trace/package.scala | 26 ++ .../HeadersAllowedAsAttributesTest.scala | 41 ++ .../trace/server/AttributeProvider.scala | 81 ++++ .../trace/server/OriginalScheme.scala | 49 +++ .../trace/server/RouteClassifier.scala | 63 +++ .../trace/server/ServerMiddleware.scala | 284 ------------ .../server/ServerMiddlewareBuilder.scala | 174 ++++++++ .../trace/server/SpanDataProvider.scala | 245 +++++++++++ .../trace/server/TypedServerAttributes.scala | 197 ++++++++- .../middleware/trace/server/package.scala | 38 ++ .../trace/server/OriginalSchemeTest.scala | 60 +++ .../trace/server/ServerMiddlewareTests.scala | 55 ++- .../server/TypedServerAttributesTest.scala | 415 ++++++++++++++++++ 35 files changed, 2933 insertions(+), 1033 deletions(-) delete mode 100644 core/src/main/scala/org/http4s/otel4s/middleware/UriRedactor.scala create mode 100644 core/src/main/scala/org/http4s/otel4s/middleware/redact/PathRedactor.scala create mode 100644 core/src/main/scala/org/http4s/otel4s/middleware/redact/QueryRedactor.scala create mode 100644 core/src/main/scala/org/http4s/otel4s/middleware/redact/package.scala create mode 100644 core/src/test/scala/org/http4s/otel4s/middleware/TypedAttributesTest.scala delete mode 100644 core/src/test/scala/org/http4s/otel4s/middleware/UriRedactorTest.scala create mode 100644 core/src/test/scala/org/http4s/otel4s/middleware/redact/PathRedactorTest.scala create mode 100644 core/src/test/scala/org/http4s/otel4s/middleware/redact/QueryRedactorTest.scala create mode 100644 trace/client/src/main/scala/org/http4s/otel4s/middleware/trace/client/AttributeProvider.scala delete mode 100644 trace/client/src/main/scala/org/http4s/otel4s/middleware/trace/client/ClientMiddleware.scala create mode 100644 trace/client/src/main/scala/org/http4s/otel4s/middleware/trace/client/ClientMiddlewareBuilder.scala create mode 100644 trace/client/src/main/scala/org/http4s/otel4s/middleware/trace/client/SpanDataProvider.scala create mode 100644 trace/client/src/main/scala/org/http4s/otel4s/middleware/trace/client/UriRedactor.scala create mode 100644 trace/client/src/main/scala/org/http4s/otel4s/middleware/trace/client/UriTemplateClassifier.scala create mode 100644 trace/client/src/test/scala/org/http4s/otel4s/middleware/trace/client/TypedClientAttributesTest.scala create mode 100644 trace/client/src/test/scala/org/http4s/otel4s/middleware/trace/client/UriRedactorTest.scala create mode 100644 trace/core/src/main/scala/org/http4s/otel4s/middleware/trace/HeadersAllowedAsAttributes.scala create mode 100644 trace/core/src/main/scala/org/http4s/otel4s/middleware/trace/package.scala create mode 100644 trace/core/src/test/scala/org/http4s/otel4s/middleware/trace/HeadersAllowedAsAttributesTest.scala create mode 100644 trace/server/src/main/scala/org/http4s/otel4s/middleware/trace/server/AttributeProvider.scala create mode 100644 trace/server/src/main/scala/org/http4s/otel4s/middleware/trace/server/OriginalScheme.scala create mode 100644 trace/server/src/main/scala/org/http4s/otel4s/middleware/trace/server/RouteClassifier.scala delete mode 100644 trace/server/src/main/scala/org/http4s/otel4s/middleware/trace/server/ServerMiddleware.scala create mode 100644 trace/server/src/main/scala/org/http4s/otel4s/middleware/trace/server/ServerMiddlewareBuilder.scala create mode 100644 trace/server/src/main/scala/org/http4s/otel4s/middleware/trace/server/SpanDataProvider.scala create mode 100644 trace/server/src/main/scala/org/http4s/otel4s/middleware/trace/server/package.scala create mode 100644 trace/server/src/test/scala/org/http4s/otel4s/middleware/trace/server/OriginalSchemeTest.scala create mode 100644 trace/server/src/test/scala/org/http4s/otel4s/middleware/trace/server/TypedServerAttributesTest.scala diff --git a/build.sbt b/build.sbt index 334d593..cdb6e8f 100644 --- a/build.sbt +++ b/build.sbt @@ -114,6 +114,7 @@ lazy val `trace-server` = crossProject(JVMPlatform, JSPlatform, NativePlatform) "org.typelevel" %%% "otel4s-core-common" % otel4sV, "org.typelevel" %%% "otel4s-core-trace" % otel4sV, "org.typelevel" %%% "otel4s-semconv" % otel4sV, + "org.http4s" %%% "http4s-dsl" % http4sV % Test, ), ) diff --git a/core/src/main/scala/org/http4s/otel4s/middleware/TypedAttributes.scala b/core/src/main/scala/org/http4s/otel4s/middleware/TypedAttributes.scala index afdd7ce..bab07c5 100644 --- a/core/src/main/scala/org/http4s/otel4s/middleware/TypedAttributes.scala +++ b/core/src/main/scala/org/http4s/otel4s/middleware/TypedAttributes.scala @@ -18,7 +18,7 @@ package org.http4s package otel4s.middleware import com.comcast.ip4s.IpAddress -import org.http4s.headers.Host +import com.comcast.ip4s.Port import org.http4s.headers.`User-Agent` import org.typelevel.ci.CIString import org.typelevel.otel4s.Attribute @@ -27,22 +27,34 @@ import org.typelevel.otel4s.Attributes import org.typelevel.otel4s.semconv.attributes.ErrorAttributes import org.typelevel.otel4s.semconv.attributes.HttpAttributes import org.typelevel.otel4s.semconv.attributes.NetworkAttributes -import org.typelevel.otel4s.semconv.attributes.ServerAttributes -import org.typelevel.otel4s.semconv.attributes.UrlAttributes import org.typelevel.otel4s.semconv.attributes.UserAgentAttributes import java.util.Locale /** Methods for creating appropriate `Attribute`s from typed HTTP objects. */ object TypedAttributes { + private[this] lazy val knownMethods: Set[Method] = Method.all.toSet + + /** The http.request.method `Attribute` with the special value _OTHER */ + val httpRequestMethodOther: Attribute[String] = + HttpAttributes.HttpRequestMethod("_OTHER") + + /** @return the `error.type` `Attribute` */ + def errorType(cause: Throwable): Attribute[String] = + ErrorAttributes.ErrorType(cause.getClass.getName) + + /** @return the `error.type` `Attribute` */ + def errorType(status: Status): Attribute[String] = + ErrorAttributes.ErrorType(s"${status.code}") /** @return the `http.request.method` `Attribute` */ def httpRequestMethod(method: Method): Attribute[String] = - HttpAttributes.HttpRequestMethod(method.name) + if (knownMethods.contains(method)) HttpAttributes.HttpRequestMethod(method.name) + else httpRequestMethodOther - /** @return the `http.request.resend_count` `Attribute` */ - def httpRequestResendCount(count: Long): Attribute[Long] = - HttpAttributes.HttpRequestResendCount(count) + /** @return the `http.request.method_original` `Attribute` */ + def httpRequestMethodOriginal(method: Method): Attribute[String] = + HttpAttributes.HttpRequestMethodOriginal(method.name) /** @return the `http.response.status_code` `Attribute` */ def httpResponseStatusCode(status: Status): Attribute[Long] = @@ -52,195 +64,75 @@ object TypedAttributes { def networkPeerAddress(ip: IpAddress): Attribute[String] = NetworkAttributes.NetworkPeerAddress(ip.toString) - /** @return the `server.address` `Attribute` */ - def serverAddress(host: Host): Attribute[String] = - ServerAttributes.ServerAddress(Host.headerInstance.value(host)) - - /** Returns of the following `Attribute`s when their corresponding values are - * present in the URL and not redacted by the provided [[`UriRedactor`]]: - * - * - `url.full` - * - `url.scheme` - * - `url.path` - * - `url.query` - * - `url.fragment` (extremely unlikely to be present) - */ - def url(unredacted: Uri, redactor: UriRedactor): Attributes = - redactor.redact(unredacted).fold(Attributes.empty) { url => - val b = Attributes.newBuilder - b += UrlAttributes.UrlFull(url.renderString) - url.scheme.foreach(scheme => b += UrlAttributes.UrlScheme(scheme.value)) - if (url.path != Uri.Path.empty) b += UrlAttributes.UrlPath(url.path.renderString) - if (url.query.nonEmpty) b += UrlAttributes.UrlQuery(url.query.renderString) - url.fragment.foreach(b += UrlAttributes.UrlFragment(_)) - b.result() + /** @return the `network.peer.port` `Attribute` */ + def networkPeerPort(port: Port): Attribute[Long] = + NetworkAttributes.NetworkPeerPort(port.value.toLong) + + /** @return the `network.protocol.version` `Attribute` */ + def networkProtocolVersion(version: HttpVersion): Attribute[String] = { + val rendered = version.major match { + case m if m <= 1 => s"$m.${version.minor}" + case m /* if m >= 2 */ => s"$m" } + NetworkAttributes.NetworkProtocolVersion(rendered) + } /** @return the `user_agent.original` `Attribute` */ - def userAgentOriginal(userAgent: `User-Agent`): Attribute[String] = - UserAgentAttributes.UserAgentOriginal(`User-Agent`.headerInstance.value(userAgent)) + def userAgentOriginal(headers: Headers): Option[Attribute[String]] = + headers + .get(`User-Agent`.name) + .map(nel => UserAgentAttributes.UserAgentOriginal(nel.head.value)) - /** @return the `error.type` `Attribute` */ - def errorType(cause: Throwable): Attribute[String] = - ErrorAttributes.ErrorType(cause.getClass.getName) - - /** @return the `error.type` `Attribute` */ - def errorType(status: Status): Attribute[String] = - ErrorAttributes.ErrorType(status.code.toString) + /* header stuff here, because it's long */ /** Methods for creating appropriate `Attribute`s from typed HTTP headers. */ - object Headers { - private[this] def generic( - headers: Headers, - allowedHeaders: Set[CIString], - prefixKey: AttributeKey[Seq[String]], - ): Attributes = - headers - .redactSensitive() - .headers - .groupMap(_.name)(_.value) - .view - .collect { - case (name, values) if allowedHeaders.contains(name) => - val key = - prefixKey - .transformName(_ + "." + name.toString.toLowerCase(Locale.ROOT)) - Attribute(key, values) - } - .to(Attributes) - - /** @return `http.request.header.` `Attribute`s for - * all headers in `allowedHeaders` - */ - def request(headers: Headers, allowedHeaders: Set[CIString]): Attributes = - generic(headers, allowedHeaders, HttpAttributes.HttpRequestHeader) - - /** @return `http.response.header.` `Attribute`s for - * all headers in `allowedHeaders` - */ - def response(headers: Headers, allowedHeaders: Set[CIString]): Attributes = - generic(headers, allowedHeaders, HttpAttributes.HttpResponseHeader) - - /** The default set of headers allowed to be turned into `Attribute`s. */ - lazy val defaultAllowedHeaders: Set[CIString] = Set( - "Accept", - "Accept-CH", - "Accept-Charset", - "Accept-CH-Lifetime", - "Accept-Encoding", - "Accept-Language", - "Accept-Ranges", - "Access-Control-Allow-Credentials", - "Access-Control-Allow-Headers", - "Access-Control-Allow-Origin", - "Access-Control-Expose-Methods", - "Access-Control-Max-Age", - "Access-Control-Request-Headers", - "Access-Control-Request-Method", - "Age", - "Allow", - "Alt-Svc", - "B3", - "Cache-Control", - "Clear-Site-Data", - "Connection", - "Content-Disposition", - "Content-Encoding", - "Content-Language", - "Content-Length", - "Content-Location", - "Content-Range", - "Content-Security-Policy", - "Content-Security-Policy-Report-Only", - "Content-Type", - "Cross-Origin-Embedder-Policy", - "Cross-Origin-Opener-Policy", - "Cross-Origin-Resource-Policy", - "Date", - "Deprecation", - "Device-Memory", - "DNT", - "Early-Data", - "ETag", - "Expect", - "Expect-CT", - "Expires", - "Feature-Policy", - "Forwarded", - "From", - "Host", - "If-Match", - "If-Modified-Since", - "If-None-Match", - "If-Range", - "If-Unmodified-Since", - "Keep-Alive", - "Large-Allocation", - "Last-Modified", - "Link", - "Location", - "Max-Forwards", - "Origin", - "Pragma", - "Proxy-Authenticate", - "Public-Key-Pins", - "Public-Key-Pins-Report-Only", - "Range", - "Referer", - "Referer-Policy", - "Retry-After", - "Save-Data", - "Sec-CH-UA", - "Sec-CH-UA-Arch", - "Sec-CH-UA-Bitness", - "Sec-CH-UA-Full-Version", - "Sec-CH-UA-Full-Version-List", - "Sec-CH-UA-Mobile", - "Sec-CH-UA-Model", - "Sec-CH-UA-Platform", - "Sec-CH-UA-Platform-Version", - "Sec-Fetch-Dest", - "Sec-Fetch-Mode", - "Sec-Fetch-Site", - "Sec-Fetch-User", - "Server", - "Server-Timing", - "SourceMap", - "Strict-Transport-Security", - "TE", - "Timing-Allow-Origin", - "Tk", - "Trailer", - "Transfer-Encoding", - "Upgrade", - "User-Agent", - "Vary", - "Via", - "Viewport-Width", - "Warning", - "Width", - "WWW-Authenticate", - "X-B3-Sampled", - "X-B3-SpanId", - "X-B3-TraceId", - "X-Content-Type-Options", - "X-DNS-Prefetch-Control", - "X-Download-Options", - "X-Forwarded-For", - "X-Forwarded-Host", - "X-Forwarded-Port", - "X-Forwarded-Proto", - "X-Forwarded-Scheme", - "X-Frame-Options", - "X-Permitted-Cross-Domain-Policies", - "X-Powered-By", - "X-Real-Ip", - "X-Request-Id", - "X-Request-Start", - "X-Runtime", - "X-Scheme", - "X-SourceMap", - "X-XSS-Protection", - ).map(CIString(_)) - } + private[this] def genericHttpHeaders( + headers: Headers, + allowedHeaders: Set[CIString], + prefixKey: AttributeKey[Seq[String]], + )(b: Attributes.Builder): b.type = + b ++= headers + .redactSensitive() + .headers + .groupMap(_.name)(_.value) + .view + .collect { + case (name, values) if allowedHeaders.contains(name) => + val key = + prefixKey + .transformName(_ + "." + name.toString.toLowerCase(Locale.ROOT)) + Attribute(key, values) + } + + /** Adds the `http.request.header.` `Attribute`s for all + * headers in `allowedHeaders` to the provided builder. + */ + def httpRequestHeadersForBuilder(headers: Headers, allowedHeaders: Set[CIString])( + b: Attributes.Builder + ): b.type = + if (allowedHeaders.isEmpty) b + else genericHttpHeaders(headers, allowedHeaders, HttpAttributes.HttpRequestHeader)(b) + + /** @return `http.request.header.` `Attributes` for + * all headers in `allowedHeaders` + */ + def httpRequestHeaders(headers: Headers, allowedHeaders: Set[CIString]): Attributes = + if (allowedHeaders.isEmpty) Attributes.empty + else httpRequestHeadersForBuilder(headers, allowedHeaders)(Attributes.newBuilder).result() + + /** Adds the `http.response.header.` `Attribute`s for all + * headers in `allowedHeaders` to the provided builder. + */ + def httpResponseHeadersForBuilder(headers: Headers, allowedHeaders: Set[CIString])( + b: Attributes.Builder + ): b.type = + if (allowedHeaders.isEmpty) b + else genericHttpHeaders(headers, allowedHeaders, HttpAttributes.HttpResponseHeader)(b) + + /** @return `http.response.header.` `Attribute`s for + * all headers in `allowedHeaders` + */ + def httpResponseHeaders(headers: Headers, allowedHeaders: Set[CIString]): Attributes = + if (allowedHeaders.isEmpty) Attributes.empty + else httpResponseHeadersForBuilder(headers, allowedHeaders)(Attributes.newBuilder).result() } diff --git a/core/src/main/scala/org/http4s/otel4s/middleware/UriRedactor.scala b/core/src/main/scala/org/http4s/otel4s/middleware/UriRedactor.scala deleted file mode 100644 index 668bdcf..0000000 --- a/core/src/main/scala/org/http4s/otel4s/middleware/UriRedactor.scala +++ /dev/null @@ -1,132 +0,0 @@ -/* - * Copyright 2023 http4s.org - * - * 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.http4s -package otel4s.middleware - -/** Something that redacts URIs to remove sensitive information. - * - * @note It is highly recommended to use [[UriRedactor.ByParts]] for - * implementations, as it allows easier redaction of individual - * parts of the URI. - * - * @see [[UriRedactor.ByParts]] - */ -trait UriRedactor { - - /** Redacts a URI. - * - * @note [[UriRedactor.ByParts]] comes with a default implementation of this - * method - * @return a redacted URI, or `None` if the entire URI is sensitive - */ - def redact(uri: Uri): Option[Uri] - - /** Redacts a URI using this redactor, and then redacts the result using - * `that` redactor. - */ - final def andThen(that: UriRedactor): UriRedactor = - uri => this.redact(uri).flatMap(that.redact) - - /** Redacts a URI using `that` redactor, and then redacts the result using - * this redactor. - */ - final def compose(that: UriRedactor): UriRedactor = - that.andThen(this) -} - -object UriRedactor { - - /** The string `"REDACTED"`, for use in URI redactors. */ - final val Redacted = "REDACTED" - - /** A `UriRedactor` that only redacts the userinfo part of the URI's authority. */ - val OnlyRedactUserInfo: UriRedactor = - new ByParts { - protected def redactPath(path: Uri.Path): Uri.Path = path - protected def redactQuery(query: Query): Query = query - protected def redactFragment( - fragment: Uri.Fragment - ): Option[Uri.Fragment] = Some(fragment) - } - - /** A `UriRedactor` that always redacts the entire URI. */ - val RedactEntirely: UriRedactor = _ => None - - /** A [[`UriRedactor`]] that redacts individual parts of the URI separately. */ - trait ByParts extends UriRedactor { - - /** Redacts the username and password from a URI's authority. */ - protected final def redactUserInfo(authority: Uri.Authority): Uri.Authority = - authority.userInfo.fold(authority) { info => - authority.copy(userInfo = - Some( - Uri.UserInfo( - username = UriRedactor.Redacted, - password = info.password.map(_ => UriRedactor.Redacted), - ) - ) - ) - } - - /** @return a redacted authority, or `None` if the entire authority is - * sensitive - */ - protected def redactAuthority(authority: Uri.Authority): Option[Uri.Authority] = - Some(redactUserInfo(authority)) - - /** @return a redacted path, or `Uri.Path.empty` if the entire path is - * sensitive - */ - protected def redactPath(path: Uri.Path): Uri.Path - - /** @return a redacted query, or `Query.empty` if the entire query is - * sensitive - */ - protected def redactQuery(query: Query): Query - - /** @return a redacted fragment, or `None` if the entire fragment is - * sensitive - */ - protected def redactFragment(fragment: Uri.Fragment): Option[Uri.Fragment] - - /** @return an entire URI redacted using [[`redactAuthority`]], - * [[`redactPath`]], [[`redactQuery`]] and [[`redactFragment`]] - */ - protected final def redactParts(uri: Uri): Uri = { - val path = { - val p = uri.path - if (p == Uri.Path.empty) p - else redactPath(p) - } - val query = { - val q = uri.query - if (q.isEmpty) q - else redactQuery(q) - } - - uri.copy( - authority = uri.authority.flatMap(redactAuthority), - path = path, - query = query, - fragment = uri.fragment.flatMap(redactFragment), - ) - } - - def redact(uri: Uri): Option[Uri] = - Some(redactParts(uri)) - } -} diff --git a/core/src/main/scala/org/http4s/otel4s/middleware/redact/PathRedactor.scala b/core/src/main/scala/org/http4s/otel4s/middleware/redact/PathRedactor.scala new file mode 100644 index 0000000..f95cff2 --- /dev/null +++ b/core/src/main/scala/org/http4s/otel4s/middleware/redact/PathRedactor.scala @@ -0,0 +1,35 @@ +/* + * Copyright 2023 http4s.org + * + * 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.http4s +package otel4s.middleware.redact + +/** Redacts a URI or request path. */ +trait PathRedactor { + + /** @return a redacted URI or request path, or `Uri.Path.empty` if the entire + * path is sensitive + */ + def redactPath(path: Uri.Path): Uri.Path +} + +object PathRedactor { + + /** A `PathRedactor` that never redacts anything. */ + trait NeverRedact extends PathRedactor { + def redactPath(path: Uri.Path): Uri.Path = path + } +} diff --git a/core/src/main/scala/org/http4s/otel4s/middleware/redact/QueryRedactor.scala b/core/src/main/scala/org/http4s/otel4s/middleware/redact/QueryRedactor.scala new file mode 100644 index 0000000..7b42df5 --- /dev/null +++ b/core/src/main/scala/org/http4s/otel4s/middleware/redact/QueryRedactor.scala @@ -0,0 +1,35 @@ +/* + * Copyright 2023 http4s.org + * + * 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.http4s +package otel4s.middleware.redact + +/** Redacts a URI or request query. */ +trait QueryRedactor { + + /** @return a redacted URI or request query, or `Query.empty` if the entire + * query is sensitive + */ + def redactQuery(query: Query): Query +} + +object QueryRedactor { + + /** A `QueryRedactor` that never redacts anything. */ + trait NeverRedact extends QueryRedactor { + def redactQuery(query: Query): Query = query + } +} diff --git a/core/src/main/scala/org/http4s/otel4s/middleware/redact/package.scala b/core/src/main/scala/org/http4s/otel4s/middleware/redact/package.scala new file mode 100644 index 0000000..03bcd2a --- /dev/null +++ b/core/src/main/scala/org/http4s/otel4s/middleware/redact/package.scala @@ -0,0 +1,23 @@ +/* + * Copyright 2023 http4s.org + * + * 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.http4s.otel4s.middleware + +package object redact { + + /** The string "REDACTED" */ + final val REDACTED = "REDACTED" +} diff --git a/core/src/test/scala/org/http4s/otel4s/middleware/TypedAttributesTest.scala b/core/src/test/scala/org/http4s/otel4s/middleware/TypedAttributesTest.scala new file mode 100644 index 0000000..31d1590 --- /dev/null +++ b/core/src/test/scala/org/http4s/otel4s/middleware/TypedAttributesTest.scala @@ -0,0 +1,179 @@ +/* + * Copyright 2023 http4s.org + * + * 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.http4s +package otel4s.middleware + +import com.comcast.ip4s.IpAddress +import com.comcast.ip4s.Ipv4Address +import com.comcast.ip4s.Ipv6Address +import com.comcast.ip4s.Port +import munit.FunSuite +import munit.Location +import org.http4s.headers.`User-Agent` +import org.typelevel.ci._ +import org.typelevel.otel4s.Attribute +import org.typelevel.otel4s.Attributes + +class TypedAttributesTest extends FunSuite { + import TypedAttributesTest.CustomException + + private[this] def checkAttr[A]( + attr: TypedAttributes.type => Attribute[A], + expected: Attribute[A], + )(implicit loc: Location): Unit = + assertEquals(attr(TypedAttributes), expected) + + private[this] def checkOpt[A]( + attr: TypedAttributes.type => Option[Attribute[A]], + expected: Option[Attribute[A]], + )(implicit loc: Location): Unit = + assertEquals(attr(TypedAttributes), expected) + + private[this] def check( + attr: TypedAttributes.type => Attributes, + expected: Attributes, + )(implicit loc: Location): Unit = + assertEquals(attr(TypedAttributes), expected) + + test("errorType(Throwable)") { + def check(t: String => Throwable, expected: String)(implicit loc: Location): Unit = + checkAttr(_.errorType(t("unused")), Attribute("error.type", expected)) + + check(new IllegalArgumentException(_), "java.lang.IllegalArgumentException") + check(new IllegalStateException(_), "java.lang.IllegalStateException") + check(new Error(_), "java.lang.Error") + check( + new CustomException.Impl(_), + "org.http4s.otel4s.middleware.TypedAttributesTest$CustomException$Impl", + ) + check(new CustomException(_) {}, "org.http4s.otel4s.middleware.TypedAttributesTest$$anon$1") + } + + test("errorType(Status)") { + def check(status: Status, expected: String)(implicit loc: Location): Unit = + checkAttr(_.errorType(status), Attribute("error.type", expected)) + + check(Status.Gone, "410") + check(Status.ImATeapot, "418") + check(Status.BadGateway, "502") + check(Status.ServiceUnavailable, "503") + } + + test("httpRequestMethod") { + def check(method: Method, expected: String)(implicit loc: Location): Unit = + checkAttr(_.httpRequestMethod(method), Attribute("http.request.method", expected)) + + def unsafeMethod(name: String): Method = + Method.fromString(name).toTry.get + + check(Method.GET, "GET") + check(unsafeMethod("GET"), "GET") + check(unsafeMethod("GeT"), "_OTHER") + check(unsafeMethod("NOT-A-METHOD"), "_OTHER") + } + + test("httpRequestMethodOriginal") { + def check(method: Method, expected: String)(implicit loc: Location): Unit = + checkAttr( + _.httpRequestMethodOriginal(method), + Attribute("http.request.method_original", expected), + ) + + def unsafeMethod(name: String): Method = + Method.fromString(name).toTry.get + + check(Method.GET, "GET") + check(unsafeMethod("GET"), "GET") + check(unsafeMethod("GeT"), "GeT") + check(unsafeMethod("NOT-A-METHOD"), "NOT-A-METHOD") + } + + test("httpResponseStatusCode") { + def check(status: Status, expected: Long)(implicit loc: Location): Unit = + checkAttr(_.httpResponseStatusCode(status), Attribute("http.response.status_code", expected)) + + check(Status.Processing, 102L) + check(Status.NoContent, 204L) + check(Status.SeeOther, 303L) + check(Status.MethodNotAllowed, 405L) + check(Status.GatewayTimeout, 504L) + } + + test("networkPeerAddress") { + def check(ip: IpAddress, expected: String)(implicit loc: Location): Unit = + checkAttr(_.networkPeerAddress(ip), Attribute("network.peer.address", expected)) + + check(Ipv4Address.fromBytes(192, 168, 1, 1), "192.168.1.1") + check(Ipv6Address.Loopback, "::1") + } + + test("networkPeerPort") { + checkAttr(_.networkPeerPort(Port.fromInt(8080).get), Attribute("network.peer.port", 8080L)) + } + + test("networkProtocolVersion") { + def check(httpVersion: HttpVersion, expected: String)(implicit loc: Location): Unit = + checkAttr( + _.networkProtocolVersion(httpVersion), + Attribute("network.protocol.version", expected), + ) + + check(HttpVersion.`HTTP/0.9`, "0.9") + check(HttpVersion.`HTTP/1.0`, "1.0") + check(HttpVersion.`HTTP/1.1`, "1.1") + check(HttpVersion.`HTTP/2`, "2") + check(HttpVersion.`HTTP/3`, "3") + } + + test("userAgentOriginal") { + val userAgent = "Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:132.0) Gecko/20100101 Firefox/132.0" + val header = Header.Raw(`User-Agent`.name, userAgent) + checkOpt( + _.userAgentOriginal(Headers(header)), + Some(Attribute("user_agent.original", userAgent)), + ) + } + + test("httpRequestHeaders") { + check( + _.httpRequestHeaders(Headers("foo" -> "1", "bar" -> "2"), Set(ci"foo")), + Attributes(Attribute("http.request.header.foo", Seq("1"))), + ) + check( + _.httpRequestHeaders(Headers("Cookie" -> "abc123"), Set(ci"Cookie")), + Attributes(Attribute("http.request.header.cookie", Seq(""))), + ) + } + + test("httpResponseHeaders") { + check( + _.httpResponseHeaders(Headers("foo" -> "1", "bar" -> "2"), Set(ci"foo")), + Attributes(Attribute("http.response.header.foo", Seq("1"))), + ) + check( + _.httpResponseHeaders(Headers("Set-Cookie" -> "abc123"), Set(ci"Set-Cookie")), + Attributes(Attribute("http.response.header.set-cookie", Seq(""))), + ) + } +} + +object TypedAttributesTest { + abstract class CustomException(message: String) extends Exception(message) + object CustomException { + final class Impl(message: String) extends CustomException(message) + } +} diff --git a/core/src/test/scala/org/http4s/otel4s/middleware/UriRedactorTest.scala b/core/src/test/scala/org/http4s/otel4s/middleware/UriRedactorTest.scala deleted file mode 100644 index d3e8c44..0000000 --- a/core/src/test/scala/org/http4s/otel4s/middleware/UriRedactorTest.scala +++ /dev/null @@ -1,92 +0,0 @@ -/* - * Copyright 2023 http4s.org - * - * 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.http4s.otel4s.middleware - -import munit.FunSuite -import org.http4s.Query -import org.http4s.Uri -import org.http4s.syntax.literals._ - -class UriRedactorTest extends FunSuite { - test("UriRedactor.OnlyRedactUserInfo redacts username and password") { - val r1 = UriRedactor.OnlyRedactUserInfo - .redact(uri"http://user:Password1@localhost/") - assertEquals(r1, Some(uri"http://REDACTED:REDACTED@localhost/")) - - val r2 = UriRedactor.OnlyRedactUserInfo - .redact(uri"http://user@localhost/") - assertEquals(r2, Some(uri"http://REDACTED@localhost/")) - } - - test("UriRedactor.RedactEntirely redacts everything") { - assertEquals(UriRedactor.RedactEntirely.redact(uri"http://localhost/"), None) - } - - test("UriRedactor.ByParts redacts by parts") { - val redactor = new UriRedactor.ByParts { - override protected def redactAuthority(authority: Uri.Authority): Option[Uri.Authority] = - None - protected def redactPath(path: Uri.Path): Uri.Path = - path"/REDACTED" - protected def redactQuery(query: Query): Query = - Query.fromMap(Map("REDACTED" -> Seq("REDACTED"))) - protected def redactFragment(fragment: Uri.Fragment): Option[Uri.Fragment] = - Some("REDACTED") - } - - val r1 = redactor.redact(uri"http://localhost/foo/bar?baz=qux&baz2=qux2#stuff") - assertEquals(r1, Some(uri"http:/REDACTED?REDACTED=REDACTED#REDACTED")) - - val r2 = redactor.redact(uri"https:") - assertEquals(r2, Some(uri"https:")) - } - - test("UriRedactor#andThen and UriRedactor#compose") { - val excluded = "foo" - abstract class QueryRedactor extends UriRedactor.ByParts { - protected def redactPath(path: Uri.Path): Uri.Path = path - protected def redactFragment(fragment: Uri.Fragment): Option[Uri.Fragment] = None - } - val redactor1 = new QueryRedactor { - protected def redactQuery(query: Query): Query = - query.filterNot(_._1 == excluded) - } - val redactor2 = new QueryRedactor { - protected def redactQuery(query: Query): Query = - if (query.containsQueryParam(excluded)) Query.empty - else query - } - val r1andThen2 = redactor1.andThen(redactor2) - val r2compose1 = redactor2.compose(redactor1) - val r2andThen1 = redactor2.andThen(redactor1) - val r1compose2 = redactor1.compose(redactor2) - - val uri1 = uri"https://example.com?foo=bar&baz=qux" - val uri2 = uri"https://example.com?baz=qux" - val uri3 = uri"https://example.com" - - assertEquals(r1andThen2.redact(uri1), Some(uri2)) - assertEquals(r2compose1.redact(uri1), Some(uri2)) - assertEquals(r2andThen1.redact(uri1), Some(uri3)) - assertEquals(r1compose2.redact(uri1), Some(uri3)) - - assertEquals(r1andThen2.redact(uri2), Some(uri2)) - assertEquals(r2compose1.redact(uri2), Some(uri2)) - assertEquals(r2andThen1.redact(uri2), Some(uri2)) - assertEquals(r1compose2.redact(uri2), Some(uri2)) - } -} diff --git a/core/src/test/scala/org/http4s/otel4s/middleware/redact/PathRedactorTest.scala b/core/src/test/scala/org/http4s/otel4s/middleware/redact/PathRedactorTest.scala new file mode 100644 index 0000000..544a0c0 --- /dev/null +++ b/core/src/test/scala/org/http4s/otel4s/middleware/redact/PathRedactorTest.scala @@ -0,0 +1,34 @@ +/* + * Copyright 2023 http4s.org + * + * 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.http4s +package otel4s.middleware.redact + +import munit.FunSuite +import munit.Location + +class PathRedactorTest extends FunSuite { + test("PathRedactor.NeverRedact") { + val redactor = new PathRedactor.NeverRedact {} + + def check(path: Uri.Path)(implicit loc: Location): Unit = + assertEquals(redactor.redactPath(path), path) + + check(Uri.Path.empty) + check(Uri.Path.Root) + check(Uri.Path.Root / "foo" / "bar") + } +} diff --git a/core/src/test/scala/org/http4s/otel4s/middleware/redact/QueryRedactorTest.scala b/core/src/test/scala/org/http4s/otel4s/middleware/redact/QueryRedactorTest.scala new file mode 100644 index 0000000..e2968ee --- /dev/null +++ b/core/src/test/scala/org/http4s/otel4s/middleware/redact/QueryRedactorTest.scala @@ -0,0 +1,36 @@ +/* + * Copyright 2023 http4s.org + * + * 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.http4s +package otel4s.middleware.redact + +import munit.FunSuite +import munit.Location + +class QueryRedactorTest extends FunSuite { + test("QueryRedactor.NeverRedact") { + val redactor = new QueryRedactor.NeverRedact {} + + def check(query: Query)(implicit loc: Location): Unit = + assertEquals(redactor.redactQuery(query), query) + + check(Query.empty) + check(Query.blank) + check(Query("foo" -> None, "bar" -> None)) + check(Query("empty" -> None, "foo" -> Some("bar"), "baz" -> Some("qux"))) + check(Query("foo" -> Some("1"), "foo" -> Some("2"))) + } +} diff --git a/examples/src/main/scala/example/Http4sExample.scala b/examples/src/main/scala/example/Http4sExample.scala index 4f42db1..90269c7 100644 --- a/examples/src/main/scala/example/Http4sExample.scala +++ b/examples/src/main/scala/example/Http4sExample.scala @@ -20,12 +20,17 @@ import cats.effect._ import cats.effect.syntax.all._ import com.comcast.ip4s._ import fs2.io.net.Network +import org.http4s.Query +import org.http4s.Uri +import org.http4s.Uri.Fragment import org.http4s.ember.client.EmberClientBuilder import org.http4s.ember.server.EmberServerBuilder import org.http4s.implicits._ import org.http4s.otel4s.middleware.metrics.OtelMetrics -import org.http4s.otel4s.middleware.trace.client.ClientMiddleware -import org.http4s.otel4s.middleware.trace.server.ServerMiddleware +import org.http4s.otel4s.middleware.redact +import org.http4s.otel4s.middleware.trace.client.ClientMiddlewareBuilder +import org.http4s.otel4s.middleware.trace.client.UriRedactor +import org.http4s.otel4s.middleware.trace.server.ServerMiddlewareBuilder import org.http4s.server.Server import org.http4s.server.middleware.Metrics import org.typelevel.otel4s.Otel4s @@ -51,6 +56,20 @@ import org.typelevel.otel4s.trace.Tracer */ object Http4sExample extends IOApp with Common { + // you probably want to be a bit more permissive than this + val redactor: UriRedactor = new UriRedactor { + def redactPath(path: Uri.Path): Uri.Path = + if (path.isEmpty) path + else Uri.Path.Root / redact.REDACTED + + def redactQuery(query: Query): Query = + if (query.isEmpty) query + else Query(redact.REDACTED -> None) + + def redactFragment(fragment: Fragment): Option[Fragment] = + Some(if (fragment.isEmpty) fragment else redact.REDACTED) + } + def tracer[F[_]](otel: Otel4s[F]): F[Tracer[F]] = otel.tracerProvider.tracer("Http4sExample").get @@ -63,9 +82,9 @@ object Http4sExample extends IOApp with Common { client <- EmberClientBuilder .default[F] .build - .map(ClientMiddleware.default.build) + .map(ClientMiddlewareBuilder.default(redactor).build) metricsOps <- OtelMetrics.serverMetricsOps[F]().toResource - app = ServerMiddleware.default[F].buildHttpApp { + app = ServerMiddlewareBuilder.default[F](redactor).buildHttpApp { Metrics(metricsOps)(routes(client)).orNotFound } sv <- EmberServerBuilder.default[F].withPort(port"8080").withHttpApp(app).build diff --git a/trace/client/src/main/scala/org/http4s/otel4s/middleware/trace/client/AttributeProvider.scala b/trace/client/src/main/scala/org/http4s/otel4s/middleware/trace/client/AttributeProvider.scala new file mode 100644 index 0000000..a1314c7 --- /dev/null +++ b/trace/client/src/main/scala/org/http4s/otel4s/middleware/trace/client/AttributeProvider.scala @@ -0,0 +1,81 @@ +/* + * Copyright 2023 http4s.org + * + * 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.http4s +package otel4s.middleware +package trace.client + +import org.typelevel.ci.CIString +import org.typelevel.otel4s.Attributes + +/** Provides attributes for spans using requests and responses. + * + * @note Implementations MUST NOT access request or response bodies. + */ +trait AttributeProvider { self => + + /** Provides attributes for a span using the given request. + * + * @note Implementation MUST NOT access request body. + */ + def requestAttributes[F[_]]( + request: Request[F], + urlTemplateClassifier: UriTemplateClassifier, + urlRedactor: UriRedactor, + headersAllowedAsAttributes: Set[CIString], + ): Attributes + + /** Provides attributes for a span using the given response. + * + * @note Implementation MUST NOT access response body. + */ + def responseAttributes[F[_]]( + response: Response[F], + headersAllowedAsAttributes: Set[CIString], + ): Attributes + + /** @return an `AttributeProvider` that provides the attributes from this and + * another `AttributeProvider` + */ + def and(that: AttributeProvider): AttributeProvider = + new AttributeProvider { + def requestAttributes[F[_]]( + request: Request[F], + urlTemplateClassifier: UriTemplateClassifier, + urlRedactor: UriRedactor, + headersAllowedAsAttributes: Set[CIString], + ): Attributes = + self.requestAttributes( + request, + urlTemplateClassifier, + urlRedactor, + headersAllowedAsAttributes, + ) ++ + that.requestAttributes( + request, + urlTemplateClassifier, + urlRedactor, + headersAllowedAsAttributes, + ) + + def responseAttributes[F[_]]( + response: Response[F], + headersAllowedAsAttributes: Set[CIString], + ): Attributes = + self.responseAttributes(response, headersAllowedAsAttributes) ++ + that.responseAttributes(response, headersAllowedAsAttributes) + } +} diff --git a/trace/client/src/main/scala/org/http4s/otel4s/middleware/trace/client/ClientMiddleware.scala b/trace/client/src/main/scala/org/http4s/otel4s/middleware/trace/client/ClientMiddleware.scala deleted file mode 100644 index 1eeed8e..0000000 --- a/trace/client/src/main/scala/org/http4s/otel4s/middleware/trace/client/ClientMiddleware.scala +++ /dev/null @@ -1,254 +0,0 @@ -/* - * Copyright 2023 http4s.org - * - * 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.http4s -package otel4s.middleware -package trace.client - -import cats.effect.Concurrent -import cats.effect.MonadCancelThrow -import cats.effect.Outcome -import cats.effect.Resource -import cats.effect.SyncIO -import cats.syntax.applicative._ -import cats.syntax.flatMap._ -import org.http4s.client.Client -import org.http4s.client.RequestKey -import org.http4s.client.middleware.Retry -import org.http4s.headers.Host -import org.http4s.headers.`User-Agent` -import org.typelevel.ci.CIString -import org.typelevel.otel4s.Attribute -import org.typelevel.otel4s.Attributes -import org.typelevel.otel4s.trace.SpanKind -import org.typelevel.otel4s.trace.StatusCode -import org.typelevel.otel4s.trace.Tracer -import org.typelevel.vault.Key -import org.typelevel.vault.Vault - -import scala.collection.immutable - -/** Middleware for wrapping an http4s `Client` to add tracing. - * - * @see [[https://opentelemetry.io/docs/specs/semconv/http/http-spans/#http-client]] - */ -object ClientMiddleware { - - /** @return a client middleware builder with default configuration */ - def default[F[_]: Tracer: Concurrent]: ClientMiddlewareBuilder[F] = - new ClientMiddlewareBuilder[F]( - Defaults.allowedRequestHeaders, - Defaults.allowedResponseHeaders, - Defaults.clientSpanName, - Defaults.additionalRequestAttributes, - Defaults.additionalResponseAttributes, - Defaults.urlRedactor, - ) - - /** The default configuration values for a client middleware builder. */ - object Defaults { - def allowedRequestHeaders: Set[CIString] = - TypedAttributes.Headers.defaultAllowedHeaders - def allowedResponseHeaders: Set[CIString] = - TypedAttributes.Headers.defaultAllowedHeaders - val clientSpanName: RequestPrelude => String = - req => s"Http Client - ${req.method}" - val additionalRequestAttributes: RequestPrelude => immutable.Iterable[Attribute[_]] = - _ => Nil - val additionalResponseAttributes: ResponsePrelude => immutable.Iterable[Attribute[_]] = - _ => Nil - def urlRedactor: UriRedactor = UriRedactor.OnlyRedactUserInfo - } - - /** A builder for client middlewares. */ - final class ClientMiddlewareBuilder[F[_]: Tracer: Concurrent] private[ClientMiddleware] ( - private val allowedRequestHeaders: Set[CIString], - private val allowedResponseHeaders: Set[CIString], - private val clientSpanName: RequestPrelude => String, - private val additionalRequestAttributes: RequestPrelude => immutable.Iterable[Attribute[_]], - private val additionalResponseAttributes: ResponsePrelude => immutable.Iterable[Attribute[_]], - private val urlRedactor: UriRedactor, - ) { - private def copy( - allowedRequestHeaders: Set[CIString] = this.allowedRequestHeaders, - allowedResponseHeaders: Set[CIString] = this.allowedResponseHeaders, - clientSpanName: RequestPrelude => String = this.clientSpanName, - additionalRequestAttributes: RequestPrelude => immutable.Iterable[Attribute[_]] = - this.additionalRequestAttributes, - additionalResponseAttributes: ResponsePrelude => immutable.Iterable[Attribute[_]] = - this.additionalResponseAttributes, - urlRedactor: UriRedactor = this.urlRedactor, - ): ClientMiddlewareBuilder[F] = - new ClientMiddlewareBuilder[F]( - allowedRequestHeaders, - allowedResponseHeaders, - clientSpanName, - additionalRequestAttributes, - additionalResponseAttributes, - urlRedactor, - ) - - /** Sets which request headers are allowed to made into `Attribute`s. */ - def withAllowedRequestHeaders(allowedHeaders: Set[CIString]): ClientMiddlewareBuilder[F] = - copy(allowedRequestHeaders = allowedHeaders) - - /** Sets which response headers are allowed to made into `Attribute`s. */ - def withAllowedResponseHeaders(allowedHeaders: Set[CIString]): ClientMiddlewareBuilder[F] = - copy(allowedResponseHeaders = allowedHeaders) - - /** Sets how to derive the name of a client span from a request. */ - def withClientSpanName(clientSpanName: RequestPrelude => String): ClientMiddlewareBuilder[F] = - copy(clientSpanName = clientSpanName) - - /** Sets how to derive additional `Attribute`s from a request to add to the - * client span. - */ - def withAdditionalRequestAttributes( - additionalRequestAttributes: RequestPrelude => immutable.Iterable[Attribute[_]] - ): ClientMiddlewareBuilder[F] = - copy(additionalRequestAttributes = additionalRequestAttributes) - - /** Sets how to derive additional `Attribute`s from a response to add to the - * client span. - */ - def withAdditionalResponseAttributes( - additionalResponseAttributes: ResponsePrelude => immutable.Iterable[Attribute[_]] - ): ClientMiddlewareBuilder[F] = - copy(additionalResponseAttributes = additionalResponseAttributes) - - /** Sets how to redact URLs before turning them into `Attribute`s. */ - def withUrlRedactor(urlRedactor: UriRedactor): ClientMiddlewareBuilder[F] = - copy(urlRedactor = urlRedactor) - - /** @return the configured middleware */ - def build: Client[F] => Client[F] = (client: Client[F]) => - Client[F] { (req: Request[F]) => // Resource[F, Response[F]] - val reqPrelude = req.requestPrelude - val base = - request(req, allowedRequestHeaders, urlRedactor) ++ - additionalRequestAttributes(reqPrelude) - MonadCancelThrow[Resource[F, *]].uncancelable { poll => - for { - res <- Tracer[F] - .spanBuilder( - req.attributes.lookup(OverrideSpanNameKey).getOrElse(clientSpanName(reqPrelude)) - ) - .withSpanKind(SpanKind.Client) - .addAttributes(base) - .build - .resource - span = res.span - trace = res.trace - traceHeaders <- Resource.eval(Tracer[F].propagate(Headers.empty)).mapK(trace) - newReq = req.withHeaders(traceHeaders ++ req.headers) - - resp <- poll(client.run(newReq).mapK(trace)).guaranteeCase { - case Outcome.Succeeded(fa) => - fa.evalMap { resp => - val out = response(resp, allowedResponseHeaders) ++ - additionalResponseAttributes(resp.responsePrelude) - - span.addAttributes(out) >> span - .setStatus(StatusCode.Error) - .unlessA(resp.status.isSuccess) - } - - case Outcome.Errored(e) => - Resource.eval(span.addAttributes(TypedAttributes.errorType(e))) - - case Outcome.Canceled() => - Resource.unit - } - } yield resp - } - } - } - - /** A key used to attach additional `Attribute`s to a request or response. */ - val ExtraAttributesKey: Key[Attributes] = - Key.newKey[SyncIO, Attributes].unsafeRunSync() - - /** A key used to override the span name for a specific request. If set, this attribute takes precedence over - * anything configured through [[ClientMiddlewareBuilder.withClientSpanName]]. - */ - val OverrideSpanNameKey: Key[String] = - Key.newKey[SyncIO, String].unsafeRunSync() - - /** @return the default `Attribute`s for a request */ - private def request[F[_]]( - request: Request[F], - allowedHeaders: Set[CIString], - urlRedactor: UriRedactor, - ): Attributes = { - val builder = Attributes.newBuilder - builder += TypedAttributes.httpRequestMethod(request.method) - builder ++= TypedAttributes.url(request.uri, urlRedactor) - val host = request.headers.get[Host].getOrElse { - val key = RequestKey.fromRequest(request) - Host(key.authority.host.value, key.authority.port) - } - builder += TypedAttributes.serverAddress(host) - request.headers - .get[`User-Agent`] - .foreach(ua => builder += TypedAttributes.userAgentOriginal(ua)) - - request.remote.foreach { socketAddress => - builder += - TypedAttributes.networkPeerAddress(socketAddress.host) - - builder += - TypedClientAttributes.serverPort(socketAddress.port) - - } - retryCount(request.attributes).foreach { count => - builder += TypedAttributes.httpRequestResendCount(count.toLong) - } - builder ++= - TypedAttributes.Headers.request(request.headers, allowedHeaders) - - request.attributes.lookup(ExtraAttributesKey).foreach(builder ++= _) - - builder.result() - } - - /** @return the default `Attribute`s for a response */ - private def response[F[_]](response: Response[F], allowedHeaders: Set[CIString]): Attributes = { - val builder = Attributes.newBuilder - - builder += TypedAttributes.httpResponseStatusCode(response.status) - retryCount(response.attributes).foreach { count => - builder += TypedAttributes.httpRequestResendCount(count.toLong) - } - - builder ++= TypedAttributes.Headers.response(response.headers, allowedHeaders) - builder ++= response.attributes.lookup(ExtraAttributesKey).toList.flatten - - // https://opentelemetry.io/docs/specs/semconv/http/http-spans/#http-client - // [5]: If response status code was sent or received and status indicates an error according - // to HTTP span status definition, `error.type` SHOULD be set to the status code number (represented as a string), - // an exception type (if thrown) or a component-specific error identifier. - if (!response.status.isSuccess) - builder += TypedAttributes.errorType(response.status) - - builder.result() - } - - private def retryCount(vault: Vault): Option[Int] = - // AttemptCountKey is 1,2,3,4 for the initial request, - // since we want to do retries. We substract by 1 to get 0,1,2,3. - vault.lookup(Retry.AttemptCountKey).map(i => i - 1) - -} diff --git a/trace/client/src/main/scala/org/http4s/otel4s/middleware/trace/client/ClientMiddlewareBuilder.scala b/trace/client/src/main/scala/org/http4s/otel4s/middleware/trace/client/ClientMiddlewareBuilder.scala new file mode 100644 index 0000000..9785196 --- /dev/null +++ b/trace/client/src/main/scala/org/http4s/otel4s/middleware/trace/client/ClientMiddlewareBuilder.scala @@ -0,0 +1,174 @@ +/* + * Copyright 2023 http4s.org + * + * 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.http4s +package otel4s.middleware +package trace +package client + +import cats.effect.Concurrent +import cats.effect.MonadCancelThrow +import cats.effect.Outcome +import cats.effect.Resource +import cats.syntax.applicative._ +import cats.syntax.flatMap._ +import fs2.Stream +import org.http4s.client.Client +import org.typelevel.otel4s.trace.SpanKind +import org.typelevel.otel4s.trace.StatusCode +import org.typelevel.otel4s.trace.Tracer + +/** Middleware builder for wrapping an http4s `Client` to add tracing. + * + * @see [[https://opentelemetry.io/docs/specs/semconv/http/http-spans/#http-client]] + */ +class ClientMiddlewareBuilder[F[_]: Tracer: Concurrent] private ( + urlRedactor: UriRedactor, + spanDataProvider: SpanDataProvider, + urlTemplateClassifier: UriTemplateClassifier, + headersAllowedAsAttributes: HeadersAllowedAsAttributes, + shouldTrace: RequestPrelude => ShouldTrace, +) { + private[this] def copy( + spanDataProvider: SpanDataProvider = this.spanDataProvider, + urlTemplateClassifier: UriTemplateClassifier = this.urlTemplateClassifier, + headersAllowedAsAttributes: HeadersAllowedAsAttributes = this.headersAllowedAsAttributes, + shouldTrace: RequestPrelude => ShouldTrace = this.shouldTrace, + ): ClientMiddlewareBuilder[F] = + new ClientMiddlewareBuilder[F]( + this.urlRedactor, + spanDataProvider, + urlTemplateClassifier, + headersAllowedAsAttributes, + shouldTrace, + ) + + /** Sets how a span's name and `Attributes` are derived from a request and + * response. + */ + def withSpanDataProvider(spanDataProvider: SpanDataProvider): ClientMiddlewareBuilder[F] = + copy(spanDataProvider = spanDataProvider) + + /** Sets how to determine the template of an absolute path reference from a + * URL. + */ + def withUrlTemplateClassifier( + urlTemplateClassifier: UriTemplateClassifier + ): ClientMiddlewareBuilder[F] = + copy(urlTemplateClassifier = urlTemplateClassifier) + + /** Sets which headers are allowed to be made into `Attribute`s. */ + def withHeadersAllowedAsAttributes( + headersAllowedAsAttributes: HeadersAllowedAsAttributes + ): ClientMiddlewareBuilder[F] = + copy(headersAllowedAsAttributes = headersAllowedAsAttributes) + + /** Sets how to determine when to trace a request and its response. */ + def withShouldTrace(shouldTrace: RequestPrelude => ShouldTrace): ClientMiddlewareBuilder[F] = + copy(shouldTrace = shouldTrace) + + /** @return the configured middleware */ + def build: Client[F] => Client[F] = (client: Client[F]) => + Client[F] { (req: Request[F]) => // Resource[F, Response[F]] + if ( + !shouldTrace(req.requestPrelude).shouldTrace || + !Tracer[F].meta.isEnabled + ) { + client.run(req) + } else { + val reqNoBody = req.withBodyStream(Stream.empty) + val shared = + spanDataProvider.processSharedData( + reqNoBody, + urlTemplateClassifier, + urlRedactor, + ) + val spanName = + spanDataProvider.spanName( + reqNoBody, + urlTemplateClassifier, + urlRedactor, + shared, + ) + val reqAttributes = + spanDataProvider.requestAttributes( + reqNoBody, + urlTemplateClassifier, + urlRedactor, + shared, + headersAllowedAsAttributes.request, + ) + + MonadCancelThrow[Resource[F, *]].uncancelable { poll => + for { + res <- Tracer[F] + .spanBuilder(spanName) + .withSpanKind(SpanKind.Client) + .addAttributes(reqAttributes) + .build + .resource + span = res.span + trace = res.trace + traceHeaders <- Resource.eval(Tracer[F].propagate(Headers.empty)).mapK(trace) + newReq = req.withHeaders(traceHeaders ++ req.headers) + + resp <- poll(client.run(newReq).mapK(trace)).guaranteeCase { + case Outcome.Succeeded(fa) => + fa.evalMap { resp => + val respAttributes = + spanDataProvider.responseAttributes( + resp.withBodyStream(Stream.empty), + headersAllowedAsAttributes.response, + ) + span.addAttributes(respAttributes) >> span + .setStatus(StatusCode.Error) + .unlessA(resp.status.isSuccess) + } + + case Outcome.Errored(e) => + Resource.eval(span.addAttributes(TypedAttributes.errorType(e))) + + case Outcome.Canceled() => + Resource.unit + } + } yield resp + } + } + } +} + +object ClientMiddlewareBuilder { + + /** @return a client middleware builder with default configuration */ + def default[F[_]: Tracer: Concurrent](urlRedactor: UriRedactor): ClientMiddlewareBuilder[F] = + new ClientMiddlewareBuilder[F]( + urlRedactor, + Defaults.spanDataProvider, + Defaults.urlTemplateClassifier, + Defaults.headersAllowedAsAttributes, + Defaults.shouldTrace, + ) + + /** The default configuration values for a client middleware builder. */ + object Defaults { + def spanDataProvider: SpanDataProvider = SpanDataProvider.default + def urlTemplateClassifier: UriTemplateClassifier = + UriTemplateClassifier.indeterminate + def headersAllowedAsAttributes: HeadersAllowedAsAttributes = + HeadersAllowedAsAttributes.default + val shouldTrace: RequestPrelude => ShouldTrace = _ => ShouldTrace.Trace + } +} diff --git a/trace/client/src/main/scala/org/http4s/otel4s/middleware/trace/client/SpanDataProvider.scala b/trace/client/src/main/scala/org/http4s/otel4s/middleware/trace/client/SpanDataProvider.scala new file mode 100644 index 0000000..f8590fd --- /dev/null +++ b/trace/client/src/main/scala/org/http4s/otel4s/middleware/trace/client/SpanDataProvider.scala @@ -0,0 +1,230 @@ +/* + * Copyright 2023 http4s.org + * + * 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.http4s +package otel4s.middleware +package trace.client + +import org.typelevel.ci.CIString +import org.typelevel.otel4s.Attribute +import org.typelevel.otel4s.Attributes + +/** Provides a name and attributes for spans using requests and responses. + * + * @note Implementations MUST NOT access request or response bodies. + */ +trait SpanDataProvider extends AttributeProvider { self => + + /** The type of shared processed data used to provide both the span name and + * request `Attributes`. + */ + type Shared + + /** Process data used to provide both the span name and request attributes. + * + * @note Implementation MUST NOT access request body. + */ + def processSharedData[F[_]]( + request: Request[F], + urlTemplateClassifier: UriTemplateClassifier, + urlRedactor: UriRedactor, + ): Shared + + /** Provides the name for a span using the given request. + * + * @note Implementation MUST NOT access request body. + */ + def spanName[F[_]]( + request: Request[F], + urlTemplateClassifier: UriTemplateClassifier, + urlRedactor: UriRedactor, + sharedProcessedData: Shared, + ): String + + /** Provides attributes for a span using the given request. + * + * @note Implementation MUST NOT access request body. + */ + def requestAttributes[F[_]]( + request: Request[F], + urlTemplateClassifier: UriTemplateClassifier, + urlRedactor: UriRedactor, + sharedProcessedData: Shared, + headersAllowedAsAttributes: Set[CIString], + ): Attributes + + final def requestAttributes[F[_]]( + request: Request[F], + urlTemplateClassifier: UriTemplateClassifier, + urlRedactor: UriRedactor, + headersAllowedAsAttributes: Set[CIString], + ): Attributes = + requestAttributes( + request, + urlTemplateClassifier, + urlRedactor, + processSharedData(request, urlTemplateClassifier, urlRedactor), + headersAllowedAsAttributes, + ) + + /** Returns an `AttributeProvider` that provides the attributes from this and + * another `AttributeProvider`. + * + * If `that` is a `SpanAndAttributeProvider`, it will not be used to provide + * span names. + */ + override def and(that: AttributeProvider): SpanDataProvider = + new SpanDataProvider { + type Shared = self.Shared + + def processSharedData[F[_]]( + request: Request[F], + urlTemplateClassifier: UriTemplateClassifier, + urlRedactor: UriRedactor, + ): Shared = + self.processSharedData(request, urlTemplateClassifier, urlRedactor) + + def spanName[F[_]]( + request: Request[F], + urlTemplateClassifier: UriTemplateClassifier, + urlRedactor: UriRedactor, + sharedProcessedData: Shared, + ): String = + self.spanName(request, urlTemplateClassifier, urlRedactor, sharedProcessedData) + + def requestAttributes[F[_]]( + request: Request[F], + urlTemplateClassifier: UriTemplateClassifier, + urlRedactor: UriRedactor, + sharedProcessedData: Shared, + headersAllowedAsAttributes: Set[CIString], + ): Attributes = + self.requestAttributes( + request, + urlTemplateClassifier, + urlRedactor, + sharedProcessedData, + headersAllowedAsAttributes, + ) ++ + that.requestAttributes( + request, + urlTemplateClassifier, + urlRedactor, + headersAllowedAsAttributes, + ) + + def responseAttributes[F[_]]( + response: Response[F], + headersAllowedAsAttributes: Set[CIString], + ): Attributes = + self.responseAttributes(response, headersAllowedAsAttributes) ++ + that.responseAttributes(response, headersAllowedAsAttributes) + } +} + +object SpanDataProvider { + + /** The default provider, which follows OpenTelemetry semantic conventions. */ + def default: SpanDataProvider = openTelemetry + + /** A `SpanAndAttributeProvider` following OpenTelemetry semantic conventions. */ + val openTelemetry: SpanDataProvider = { + final case class Data(httpRequestMethod: Attribute[String]) { + val requestMethodIsUnknown: Boolean = + httpRequestMethod == TypedAttributes.httpRequestMethodOther + } + + new SpanDataProvider { + // https://opentelemetry.io/docs/specs/semconv/http/http-spans/#http-client + + type Shared = Data + + def processSharedData[F[_]]( + request: Request[F], + urlTemplateClassifier: UriTemplateClassifier, + urlRedactor: UriRedactor, + ): Data = + Data(TypedAttributes.httpRequestMethod(request.method)) + + def spanName[F[_]]( + request: Request[F], + urlTemplateClassifier: UriTemplateClassifier, + urlRedactor: UriRedactor, + sharedProcessedData: Data, + ): String = { + val method = + if (sharedProcessedData.requestMethodIsUnknown) "HTTP" + else sharedProcessedData.httpRequestMethod.value + urlTemplateClassifier + .classify(request.uri) + .fold(method)(urlTemplate => s"$method $urlTemplate") + } + + def requestAttributes[F[_]]( + request: Request[F], + urlTemplateClassifier: UriTemplateClassifier, + urlRedactor: UriRedactor, + sharedProcessedData: Data, + headersAllowedAsAttributes: Set[CIString], + ): Attributes = { + val b = Attributes.newBuilder + + b += sharedProcessedData.httpRequestMethod + b ++= TypedClientAttributes.serverAddress(request.uri.host) + b ++= TypedClientAttributes.serverPort(request.remotePort, request.uri) + b += TypedClientAttributes.urlFull(request.uri, urlRedactor) + // `error.type` handled by `responseAttributes` + if (sharedProcessedData.requestMethodIsUnknown) { + b += TypedAttributes.httpRequestMethodOriginal(request.method) + } + // `http.response.status_code` handled by `responseAttributes` + // `network.protocol.name` not required because http4s only supports http + // and `Request#httpVersion` is always populated/available + b ++= TypedClientAttributes.httpRequestResendCount(request) + request.remote.foreach { socketAddress => + b += TypedAttributes.networkPeerAddress(socketAddress.host) + b += TypedAttributes.networkPeerPort(socketAddress.port) + } + b += TypedAttributes.networkProtocolVersion(request.httpVersion) + TypedAttributes.httpRequestHeadersForBuilder(request.headers, headersAllowedAsAttributes)(b) + // `http.response.header.`s handled by `responseAttributes` + // `network.transport` not opted into at this time + b ++= TypedClientAttributes.urlScheme(request.uri.scheme) + b ++= TypedAttributes.userAgentOriginal(request.headers) + + b.result() + } + + def responseAttributes[F[_]]( + response: Response[F], + headersAllowedAsAttributes: Set[CIString], + ): Attributes = { + val b = Attributes.newBuilder + + if (!response.status.isSuccess) { + // setting `error.type` for a `Throwable` must be done in the middleware + b += TypedAttributes.errorType(response.status) + } + b += TypedAttributes.httpResponseStatusCode(response.status) + TypedAttributes.httpResponseHeadersForBuilder(response.headers, headersAllowedAsAttributes)( + b + ) + + b.result() + } + } + } +} diff --git a/trace/client/src/main/scala/org/http4s/otel4s/middleware/trace/client/TypedClientAttributes.scala b/trace/client/src/main/scala/org/http4s/otel4s/middleware/trace/client/TypedClientAttributes.scala index ab04b9b..86df586 100644 --- a/trace/client/src/main/scala/org/http4s/otel4s/middleware/trace/client/TypedClientAttributes.scala +++ b/trace/client/src/main/scala/org/http4s/otel4s/middleware/trace/client/TypedClientAttributes.scala @@ -14,18 +14,73 @@ * limitations under the License. */ -package org.http4s.otel4s.middleware.trace.client +package org.http4s +package otel4s.middleware +package trace +package client import com.comcast.ip4s.Port +import org.http4s.client.middleware.Retry import org.typelevel.otel4s.Attribute +import org.typelevel.otel4s.semconv.attributes.HttpAttributes import org.typelevel.otel4s.semconv.attributes.ServerAttributes +import org.typelevel.otel4s.semconv.attributes.UrlAttributes /** Methods for creating appropriate `Attribute`s from typed HTTP objects * within an HTTP client. */ object TypedClientAttributes { - /** @return the `server.port` `Attribute` */ - def serverPort(port: Port): Attribute[Long] = - ServerAttributes.ServerPort(port.value.toLong) + /** @return the `http.request.resend_count` `Attribute` */ + def httpRequestResendCount[F[_]](request: Request[F]): Option[Attribute[Long]] = + // `AttemptCountKey` is 1 for the initial request. Since we want to track + // resends, we subtract 1 + request.attributes + .lookup(Retry.AttemptCountKey) + .collect { + case c if c > 1 => c - 1L + } + .map(HttpAttributes.HttpRequestResendCount.apply) + + /** @return the `server.address` `Attribute` */ + def serverAddress(host: Uri.Host): Attribute[String] = + ServerAttributes.ServerAddress(host.value) + + /** This is a required `Attribute`, but `Uri` does not guarantee that it is + * populated. So we just hope that it's always there in practice. + * + * @return the `server.address` `Attribute` + */ + def serverAddress(host: Option[Uri.Host]): Option[Attribute[String]] = + host.map(serverAddress) + + /** This is a required `Attribute`, but none of `Request#remote`, `Uri#port` + * and `Uri#scheme` are guaranteed to be populated. So we just hope that at + * least one of them is always there in practice. + * + * @return the `server.port` `Attribute` + */ + def serverPort( + remotePort: Option[Port], + url: Uri, + ): Option[Attribute[Long]] = + remotePort + .map(_.value.toLong) + .orElse(url.port.map(_.toLong)) + .orElse(portFromScheme(url.scheme)) + .map(ServerAttributes.ServerPort.apply) + + /** @return the `url.full` `Attribute` containing the URL after redacting it + * with the provided [[`UriRedactor`]]. + */ + def urlFull(unredacted: Uri, redactor: UriRedactor): Attribute[String] = + UrlAttributes.UrlFull(redactor.redactFull(unredacted).renderString) + + /** @return the `url.scheme` `Attribute` */ + def urlScheme(scheme: Uri.Scheme): Attribute[String] = + UrlAttributes.UrlScheme(scheme.value) + + /** @return the `url.scheme` `Attribute` */ + def urlScheme(scheme: Option[Uri.Scheme]): Option[Attribute[String]] = + scheme.map(urlScheme) } diff --git a/trace/client/src/main/scala/org/http4s/otel4s/middleware/trace/client/UriRedactor.scala b/trace/client/src/main/scala/org/http4s/otel4s/middleware/trace/client/UriRedactor.scala new file mode 100644 index 0000000..c9d1dc1 --- /dev/null +++ b/trace/client/src/main/scala/org/http4s/otel4s/middleware/trace/client/UriRedactor.scala @@ -0,0 +1,75 @@ +/* + * Copyright 2023 http4s.org + * + * 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.http4s +package otel4s.middleware +package trace.client + +import org.http4s.otel4s.middleware.redact.PathRedactor +import org.http4s.otel4s.middleware.redact.QueryRedactor + +/** Redacts URIs to remove sensitive information. */ +trait UriRedactor extends PathRedactor with QueryRedactor { + + /** Redacts the username and password from a URI's authority. */ + protected final def redactUserInfo(authority: Uri.Authority): Uri.Authority = + authority.userInfo.fold(authority) { info => + authority.copy(userInfo = + Some( + Uri.UserInfo( + username = redact.REDACTED, + password = info.password.map(_ => redact.REDACTED), + ) + ) + ) + } + + /** @return a redacted authority, or `None` if the entire authority is + * sensitive + */ + def redactAuthority(authority: Uri.Authority): Option[Uri.Authority] = + Some(redactUserInfo(authority)) + + /** @return a redacted fragment, or `None` if the entire fragment is + * sensitive + */ + def redactFragment(fragment: Uri.Fragment): Option[Uri.Fragment] + + /** @return a URI redacted by individually redacting it authority, path, + * query and fragment + */ + final def redactFull(uri: Uri): Uri = + uri.copy( + authority = uri.authority.flatMap(redactAuthority), + path = redactPath(uri.path), + query = redactQuery(uri.query), + fragment = uri.fragment.flatMap(redactFragment), + ) +} + +object UriRedactor { + + /** A `UriRedactor` that only redacts the username and password from a URI's + * authority. + */ + trait OnlyRedactUserInfo + extends UriRedactor + with PathRedactor.NeverRedact + with QueryRedactor.NeverRedact { + def redactFragment(fragment: Uri.Fragment): Option[Uri.Fragment] = + Some(fragment) + } +} diff --git a/trace/client/src/main/scala/org/http4s/otel4s/middleware/trace/client/UriTemplateClassifier.scala b/trace/client/src/main/scala/org/http4s/otel4s/middleware/trace/client/UriTemplateClassifier.scala new file mode 100644 index 0000000..eb76807 --- /dev/null +++ b/trace/client/src/main/scala/org/http4s/otel4s/middleware/trace/client/UriTemplateClassifier.scala @@ -0,0 +1,39 @@ +/* + * Copyright 2023 http4s.org + * + * 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.http4s +package otel4s.middleware.trace.client + +/** Determines the template of an + * [[https://www.rfc-editor.org/rfc/rfc3986#section-4.2 absolute path reference]] + * from a URI. + * + * @note This trait is used to provide the value for the experimental + * `url.template` `Attribute`, which is used in the span name even + * though the `url.template` `Attribute` is not included in the span + * because it is experimental. See + * [[https://opentelemetry.io/docs/specs/semconv/http/http-spans/#http-client-experimental-attributes]] + * for more information. + */ +trait UriTemplateClassifier { + def classify(url: Uri): Option[String] +} + +object UriTemplateClassifier { + + /** A classifier that does not classify any URI templates. */ + val indeterminate: UriTemplateClassifier = _ => None +} diff --git a/trace/client/src/test/scala/org/http4s/otel4s/middleware/trace/client/ClientMiddlewareTests.scala b/trace/client/src/test/scala/org/http4s/otel4s/middleware/trace/client/ClientMiddlewareTests.scala index 6400a45..74753ed 100644 --- a/trace/client/src/test/scala/org/http4s/otel4s/middleware/trace/client/ClientMiddlewareTests.scala +++ b/trace/client/src/test/scala/org/http4s/otel4s/middleware/trace/client/ClientMiddlewareTests.scala @@ -15,7 +15,8 @@ */ package org.http4s -package otel4s.middleware.trace.client +package otel4s.middleware.trace +package client import cats.effect.IO import cats.effect.Resource @@ -24,6 +25,7 @@ import cats.syntax.flatMap._ import munit.CatsEffectSuite import org.http4s.client.Client import org.http4s.syntax.literals._ +import org.typelevel.ci.CIString import org.typelevel.ci.CIStringSyntax import org.typelevel.otel4s.Attribute import org.typelevel.otel4s.AttributeKey @@ -41,6 +43,7 @@ import scala.concurrent.duration.Duration import scala.util.control.NoStackTrace class ClientMiddlewareTests extends CatsEffectSuite { + import ClientMiddlewareTests.MinimalRedactor private val spanLimits = SpanLimits.default @@ -60,10 +63,14 @@ class ClientMiddlewareTests extends CatsEffectSuite { HttpApp[IO](_.body.compile.drain.as(response)) } val tracedClient = - ClientMiddleware - .default[IO] - .withAllowedRequestHeaders(Set(ci"foo")) - .withAllowedResponseHeaders(Set(ci"baz")) + ClientMiddlewareBuilder + .default[IO](MinimalRedactor) + .withHeadersAllowedAsAttributes( + HeadersAllowedAsAttributes( + request = Set(ci"foo"), + response = Set(ci"baz"), + ) + ) .build(fakeClient) val request = @@ -75,24 +82,23 @@ class ClientMiddlewareTests extends CatsEffectSuite { } yield { assertEquals(spans.length, 1) val span = spans.head - assertEquals(span.name, "Http Client - GET") + assertEquals(span.name, "GET") assertEquals(span.kind, SpanKind.Client) assertEquals(span.status, StatusData.Unset) val attributes = span.attributes.elements - assertEquals(attributes.size, 10) + assertEquals(attributes.size, 9) def getAttr[A: AttributeKey.KeySelect](name: String): Option[A] = attributes.get[A](name).map(_.value) assertEquals(getAttr[String]("http.request.method"), Some("GET")) assertEquals(getAttr[Seq[String]]("http.request.header.foo"), Some(Seq("bar"))) assertEquals(getAttr[Seq[String]]("http.request.header.baz"), None) + assertEquals(getAttr[String]("network.protocol.version"), Some("1.1")) + assertEquals(getAttr[String]("server.address"), Some("localhost")) + assertEquals(getAttr[Long]("server.port"), Some(80L)) assertEquals(getAttr[String]("url.full"), Some("http://localhost/?#")) assertEquals(getAttr[String]("url.scheme"), Some("http")) - assertEquals(getAttr[String]("url.path"), Some("/")) - assertEquals(getAttr[String]("url.query"), Some("")) - assertEquals(getAttr[String]("url.fragment"), Some("")) - assertEquals(getAttr[String]("server.address"), Some("localhost")) assertEquals(getAttr[Long]("http.response.status_code"), Some(200L)) assertEquals(getAttr[Seq[String]]("http.response.header.foo"), None) assertEquals(getAttr[Seq[String]]("http.response.header.baz"), Some(Seq("qux"))) @@ -118,7 +124,8 @@ class ClientMiddlewareTests extends CatsEffectSuite { .run(req) .evalTap(_ => Tracer[IO].currentSpanOrThrow.flatMap(_.updateName("NEW SPAN NAME"))) } - val tracedClient = ClientMiddleware.default[IO].build(traceManipulatingClient) + val tracedClient = + ClientMiddlewareBuilder.default[IO](MinimalRedactor).build(traceManipulatingClient) val request = Request[IO](Method.GET, uri"http://localhost/?#") tracedClient.run(request).use(_.body.compile.drain) @@ -133,6 +140,36 @@ class ClientMiddlewareTests extends CatsEffectSuite { } test("ClientMiddleware allows overriding span name") { + val provider: SpanDataProvider = new SpanDataProvider { + type Shared = None.type + + def processSharedData[F[_]]( + request: Request[F], + urlTemplateClassifier: UriTemplateClassifier, + urlRedactor: UriRedactor, + ): Shared = None + + def spanName[F[_]]( + request: Request[F], + urlTemplateClassifier: UriTemplateClassifier, + urlRedactor: UriRedactor, + sharedProcessedData: Shared, + ): String = "Overridden span name" + + def requestAttributes[F[_]]( + request: Request[F], + urlTemplateClassifier: UriTemplateClassifier, + urlRedactor: UriRedactor, + sharedProcessedData: Shared, + headersAllowedAsAttributes: Set[CIString], + ): Attributes = Attributes.empty + + def responseAttributes[F[_]]( + response: Response[F], + headersAllowedAsAttributes: Set[CIString], + ): Attributes = Attributes.empty + } + val spanName = "Overridden span name" TracesTestkit .inMemory[IO]() @@ -147,12 +184,12 @@ class ClientMiddlewareTests extends CatsEffectSuite { HttpApp[IO](_.body.compile.drain.as(response)) } val tracedClient = - ClientMiddleware - .default[IO] + ClientMiddlewareBuilder + .default[IO](MinimalRedactor) + .withSpanDataProvider(provider) .build(fakeClient) val request = Request[IO](Method.GET, uri"http://localhost/?#") - .withAttribute(ClientMiddleware.OverrideSpanNameKey, spanName) tracedClient.run(request).use(_.body.compile.drain) } spans <- testkit.finishedSpans @@ -176,7 +213,8 @@ class ClientMiddlewareTests extends CatsEffectSuite { Resource.raiseError[IO, Response[IO], Throwable](error) } - val tracedClient = ClientMiddleware.default[IO].build(fakeClient) + val tracedClient = + ClientMiddlewareBuilder.default[IO](MinimalRedactor).build(fakeClient) val request = Request[IO](Method.GET, uri"http://localhost/") val events = Vector( @@ -192,12 +230,13 @@ class ClientMiddlewareTests extends CatsEffectSuite { val status = StatusData(StatusCode.Error) val attributes = Attributes( + Attribute("error.type", error.getClass.getName), Attribute("http.request.method", "GET"), - Attribute("url.path", "/"), + Attribute("network.protocol.version", "1.1"), + Attribute("server.address", "localhost"), + Attribute("server.port", 80L), Attribute("url.full", "http://localhost/"), Attribute("url.scheme", "http"), - Attribute("server.address", "localhost"), - Attribute("error.type", error.getClass.getName), ) for { @@ -223,7 +262,8 @@ class ClientMiddlewareTests extends CatsEffectSuite { Resource.canceled[IO] >> Resource.never[IO, Response[IO]] } - val tracedClient = ClientMiddleware.default[IO].build(fakeClient) + val tracedClient = + ClientMiddlewareBuilder.default[IO](MinimalRedactor).build(fakeClient) val request = Request[IO](Method.GET, uri"http://localhost/?#") val status = StatusData(StatusCode.Error, "canceled") @@ -254,7 +294,8 @@ class ClientMiddlewareTests extends CatsEffectSuite { HttpApp[IO](_.body.compile.drain.as(Response[IO](Status.Ok))) } - val tracedClient = ClientMiddleware.default[IO].build(fakeClient) + val tracedClient = + ClientMiddlewareBuilder.default[IO](MinimalRedactor).build(fakeClient) val request = Request[IO](Method.GET, uri"http://localhost/") val events = Vector( @@ -272,10 +313,11 @@ class ClientMiddlewareTests extends CatsEffectSuite { val attributes = Attributes( Attribute("http.request.method", "GET"), Attribute("http.response.status_code", 200L), - Attribute("url.path", "/"), + Attribute("network.protocol.version", "1.1"), + Attribute("server.address", "localhost"), + Attribute("server.port", 80L), Attribute("url.full", "http://localhost/"), Attribute("url.scheme", "http"), - Attribute("server.address", "localhost"), ) for { @@ -302,7 +344,8 @@ class ClientMiddlewareTests extends CatsEffectSuite { HttpApp[IO](_.body.compile.drain.as(Response[IO](Status.Ok))) } - val tracedClient = ClientMiddleware.default[IO].build(fakeClient) + val tracedClient = + ClientMiddlewareBuilder.default[IO](MinimalRedactor).build(fakeClient) val request = Request[IO](Method.GET, uri"http://localhost/?#") val status = StatusData(StatusCode.Error, "canceled") @@ -331,19 +374,21 @@ class ClientMiddlewareTests extends CatsEffectSuite { HttpApp[IO](_.body.compile.drain.as(Response[IO](Status.InternalServerError))) } - val tracedClient = ClientMiddleware.default[IO].build(fakeClient) + val tracedClient = + ClientMiddlewareBuilder.default[IO](MinimalRedactor).build(fakeClient) val request = Request[IO](Method.GET, uri"http://localhost/") val status = StatusData(StatusCode.Error) val attributes = Attributes( + Attribute("error.type", "500"), Attribute("http.request.method", "GET"), Attribute("http.response.status_code", 500L), - Attribute("url.path", "/"), + Attribute("network.protocol.version", "1.1"), + Attribute("server.address", "localhost"), + Attribute("server.port", 80L), Attribute("url.full", "http://localhost/"), Attribute("url.scheme", "http"), - Attribute("server.address", "localhost"), - Attribute("error.type", "500"), ) for { @@ -359,3 +404,7 @@ class ClientMiddlewareTests extends CatsEffectSuite { } } } + +object ClientMiddlewareTests { + object MinimalRedactor extends UriRedactor.OnlyRedactUserInfo +} diff --git a/trace/client/src/test/scala/org/http4s/otel4s/middleware/trace/client/TypedClientAttributesTest.scala b/trace/client/src/test/scala/org/http4s/otel4s/middleware/trace/client/TypedClientAttributesTest.scala new file mode 100644 index 0000000..0e91c80 --- /dev/null +++ b/trace/client/src/test/scala/org/http4s/otel4s/middleware/trace/client/TypedClientAttributesTest.scala @@ -0,0 +1,104 @@ +/* + * Copyright 2023 http4s.org + * + * 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.http4s +package otel4s.middleware.trace.client + +import com.comcast.ip4s.Port +import munit.FunSuite +import munit.Location +import org.http4s.client.middleware.Retry +import org.http4s.otel4s.middleware.redact.PathRedactor +import org.http4s.otel4s.middleware.redact.QueryRedactor +import org.http4s.syntax.literals._ +import org.typelevel.otel4s.Attribute + +class TypedClientAttributesTest extends FunSuite { + + private[this] def checkAttr[A]( + attr: TypedClientAttributes.type => Attribute[A], + expected: Attribute[A], + )(implicit loc: Location): Unit = + assertEquals(attr(TypedClientAttributes), expected) + + private[this] def checkOpt[A]( + attr: TypedClientAttributes.type => Option[Attribute[A]], + expected: Option[Attribute[A]], + )(implicit loc: Location): Unit = + assertEquals(attr(TypedClientAttributes), expected) + + test("httpRequestResendCount") { + def check(attemptCount: Int, expected: Option[Long])(implicit loc: Location): Unit = { + val req = Request().withAttribute(Retry.AttemptCountKey, attemptCount) + checkOpt( + _.httpRequestResendCount(req), + expected.map(Attribute("http.request.resend_count", _)), + ) + } + + check(0, None) + check(1, None) + check(2, Some(1L)) + check(3, Some(2L)) + } + + test("serverAddress") { + def check(host: Option[Uri.Host], expected: Option[String])(implicit loc: Location): Unit = + checkOpt(_.serverAddress(host), expected.map(Attribute("server.address", _))) + + check(None, None) + check(Some(Uri.Host.unsafeFromString("localhost")), Some("localhost")) + check(Some(Uri.Host.unsafeFromString("example.com")), Some("example.com")) + } + + test("serverPort") { + def check(port: Option[Port], url: Uri, expected: Option[Long])(implicit loc: Location): Unit = + checkOpt(_.serverPort(port, url), expected.map(Attribute("server.port", _))) + + check(Port.fromInt(4140), uri"http://localhost:8080", Some(4140L)) + check(Port.fromInt(4140), uri"//localhost", Some(4140L)) + check(None, uri"http://localhost:8080", Some(8080L)) + check(None, uri"https://example.com", Some(443L)) + check(None, uri"//localhost", None) + } + + test("urlFull") { + val redactor = + new UriRedactor with PathRedactor.NeverRedact with QueryRedactor.NeverRedact { + def redactFragment(fragment: Uri.Fragment): Option[Uri.Fragment] = Some(fragment) + } + def check(url: Uri, expected: String)(implicit loc: Location): Unit = + checkAttr(_.urlFull(url, redactor), Attribute("url.full", expected)) + + check(uri"http://localhost:8080", "http://localhost:8080") + check(uri"https://example.com/", "https://example.com/") + check(uri"https://example.com/foo?", "https://example.com/foo?") + check(uri"http://unsafe.example.com?#", "http://unsafe.example.com?#") + } + + test("urlScheme") { + def check( + scheme: Option[Uri.Scheme], + expected: Option[String], + )(implicit loc: Location): Unit = + checkOpt(_.urlScheme(scheme), expected.map(Attribute("url.scheme", _))) + + check(None, None) + check(Some(Uri.Scheme.http), Some("http")) + check(Some(Uri.Scheme.https), Some("https")) + } + +} diff --git a/trace/client/src/test/scala/org/http4s/otel4s/middleware/trace/client/UriRedactorTest.scala b/trace/client/src/test/scala/org/http4s/otel4s/middleware/trace/client/UriRedactorTest.scala new file mode 100644 index 0000000..57ec8e4 --- /dev/null +++ b/trace/client/src/test/scala/org/http4s/otel4s/middleware/trace/client/UriRedactorTest.scala @@ -0,0 +1,53 @@ +/* + * Copyright 2023 http4s.org + * + * 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.http4s.otel4s.middleware.trace.client + +import munit.FunSuite +import org.http4s.Query +import org.http4s.Uri +import org.http4s.syntax.literals._ + +class UriRedactorTest extends FunSuite { + test("UriRedactor.OnlyRedactUserInfo redacts username and password") { + val redactor = new UriRedactor.OnlyRedactUserInfo {} + + val r1 = redactor.redactFull(uri"http://user:Password1@localhost/") + assertEquals(r1, uri"http://REDACTED:REDACTED@localhost/") + + val r2 = redactor.redactFull(uri"http://user@localhost/") + assertEquals(r2, uri"http://REDACTED@localhost/") + } + + test("custom UriRedactor") { + val redactor = new UriRedactor { + override def redactAuthority(authority: Uri.Authority): Option[Uri.Authority] = + None + def redactPath(path: Uri.Path): Uri.Path = + path"/REDACTED" + def redactQuery(query: Query): Query = + Query.fromMap(Map("REDACTED" -> Seq("REDACTED"))) + def redactFragment(fragment: Uri.Fragment): Option[Uri.Fragment] = + Some("REDACTED") + } + + val r1 = redactor.redactFull(uri"http://localhost/foo/bar?baz=qux&baz2=qux2#stuff") + assertEquals(r1, uri"http:/REDACTED?REDACTED=REDACTED#REDACTED") + + val r2 = redactor.redactFull(uri"https:") + assertEquals(r2, uri"https:/REDACTED?REDACTED=REDACTED") + } +} diff --git a/trace/core/src/main/scala/org/http4s/otel4s/middleware/trace/HeadersAllowedAsAttributes.scala b/trace/core/src/main/scala/org/http4s/otel4s/middleware/trace/HeadersAllowedAsAttributes.scala new file mode 100644 index 0000000..f1c79fb --- /dev/null +++ b/trace/core/src/main/scala/org/http4s/otel4s/middleware/trace/HeadersAllowedAsAttributes.scala @@ -0,0 +1,190 @@ +/* + * Copyright 2023 http4s.org + * + * 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.http4s.otel4s.middleware.trace + +import org.typelevel.ci.CIString + +/** Request and response headers that are allowed to be made into `Attribute`s. */ +final case class HeadersAllowedAsAttributes( + request: Set[CIString], + response: Set[CIString], +) { + + /** @return the union of the request and response headers allowed by `this` + * and `that` + */ + def union(that: HeadersAllowedAsAttributes): HeadersAllowedAsAttributes = { + val thatReq = that.request + val thatResp = that.response + + if (thatReq.isEmpty && thatResp.isEmpty) this + else { + val thisReq = this.request + val thisResp = this.response + + if ((thisReq eq thisResp) && (thatReq eq thatResp)) { + HeadersAllowedAsAttributes(thisReq | thatReq) + } else { + HeadersAllowedAsAttributes( + request = thisReq | thatReq, + response = thisResp | thatResp, + ) + } + } + } + + /** Alias for [[`union`]] */ + def |(that: HeadersAllowedAsAttributes): HeadersAllowedAsAttributes = + union(that) +} + +object HeadersAllowedAsAttributes { + + /** @return an instance where the same set of headers is allowed for both + * requests and responses + */ + def apply(allowed: Set[CIString]): HeadersAllowedAsAttributes = + apply(allowed, allowed) + + /** The default sets of HTTP headers allowed to be turned into `Attribute`s. */ + lazy val default: HeadersAllowedAsAttributes = + apply(defaultAllowedHttpHeaders) + + /** No headers are allowed to be made into `Attribute`s. */ + val none: HeadersAllowedAsAttributes = apply(Set.empty) + + private[this] lazy val defaultAllowedHttpHeaders: Set[CIString] = Set( + "Accept", + "Accept-CH", + "Accept-Charset", + "Accept-CH-Lifetime", + "Accept-Encoding", + "Accept-Language", + "Accept-Ranges", + "Access-Control-Allow-Credentials", + "Access-Control-Allow-Headers", + "Access-Control-Allow-Origin", + "Access-Control-Expose-Methods", + "Access-Control-Max-Age", + "Access-Control-Request-Headers", + "Access-Control-Request-Method", + "Age", + "Allow", + "Alt-Svc", + "B3", + "Cache-Control", + "Clear-Site-Data", + "Connection", + "Content-Disposition", + "Content-Encoding", + "Content-Language", + "Content-Length", + "Content-Location", + "Content-Range", + "Content-Security-Policy", + "Content-Security-Policy-Report-Only", + "Content-Type", + "Cross-Origin-Embedder-Policy", + "Cross-Origin-Opener-Policy", + "Cross-Origin-Resource-Policy", + "Date", + "Deprecation", + "Device-Memory", + "DNT", + "Early-Data", + "ETag", + "Expect", + "Expect-CT", + "Expires", + "Feature-Policy", + "Forwarded", + "From", + "Host", + "If-Match", + "If-Modified-Since", + "If-None-Match", + "If-Range", + "If-Unmodified-Since", + "Keep-Alive", + "Large-Allocation", + "Last-Modified", + "Link", + "Location", + "Max-Forwards", + "Origin", + "Pragma", + "Proxy-Authenticate", + "Public-Key-Pins", + "Public-Key-Pins-Report-Only", + "Range", + "Referer", + "Referer-Policy", + "Retry-After", + "Save-Data", + "Sec-CH-UA", + "Sec-CH-UA-Arch", + "Sec-CH-UA-Bitness", + "Sec-CH-UA-Full-Version", + "Sec-CH-UA-Full-Version-List", + "Sec-CH-UA-Mobile", + "Sec-CH-UA-Model", + "Sec-CH-UA-Platform", + "Sec-CH-UA-Platform-Version", + "Sec-Fetch-Dest", + "Sec-Fetch-Mode", + "Sec-Fetch-Site", + "Sec-Fetch-User", + "Server", + "Server-Timing", + "SourceMap", + "Strict-Transport-Security", + "TE", + "Timing-Allow-Origin", + "Tk", + "Trailer", + "Transfer-Encoding", + "Upgrade", + "User-Agent", + "Vary", + "Via", + "Viewport-Width", + "Warning", + "Width", + "WWW-Authenticate", + "X-B3-Sampled", + "X-B3-SpanId", + "X-B3-TraceId", + "X-Content-Type-Options", + "X-DNS-Prefetch-Control", + "X-Download-Options", + "X-Forwarded-For", + "X-Forwarded-Host", + "X-Forwarded-Port", + "X-Forwarded-Proto", + "X-Forwarded-Scheme", + "X-Frame-Options", + "X-Permitted-Cross-Domain-Policies", + "X-Powered-By", + "X-Real-Ip", + "X-Request-Id", + "X-Request-Start", + "X-Runtime", + "X-Scheme", + "X-SourceMap", + "X-XSS-Protection", + ).map(CIString(_)) +} diff --git a/trace/core/src/main/scala/org/http4s/otel4s/middleware/trace/package.scala b/trace/core/src/main/scala/org/http4s/otel4s/middleware/trace/package.scala new file mode 100644 index 0000000..c60852d --- /dev/null +++ b/trace/core/src/main/scala/org/http4s/otel4s/middleware/trace/package.scala @@ -0,0 +1,26 @@ +/* + * Copyright 2023 http4s.org + * + * 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.http4s +package otel4s.middleware + +package object trace { + private[trace] def portFromScheme(scheme: Option[Uri.Scheme]): Option[Long] = + scheme.map(_.value.toLowerCase).collect { + case "http" => 80L + case "https" => 443L + } +} diff --git a/trace/core/src/test/scala/org/http4s/otel4s/middleware/trace/HeadersAllowedAsAttributesTest.scala b/trace/core/src/test/scala/org/http4s/otel4s/middleware/trace/HeadersAllowedAsAttributesTest.scala new file mode 100644 index 0000000..ed5ba6e --- /dev/null +++ b/trace/core/src/test/scala/org/http4s/otel4s/middleware/trace/HeadersAllowedAsAttributesTest.scala @@ -0,0 +1,41 @@ +/* + * Copyright 2023 http4s.org + * + * 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.http4s.otel4s.middleware.trace + +import munit.FunSuite +import org.typelevel.ci._ + +class HeadersAllowedAsAttributesTest extends FunSuite { + test("`union` contains all elements of `this` and `that`") { + val a = HeadersAllowedAsAttributes(Set(ci"foo", ci"bar"), Set(ci"baz", ci"qux")) + val b = HeadersAllowedAsAttributes(Set(ci"bar", ci"baz"), Set(ci"foo", ci"baz")) + val c = a | b + assertEquals(c.request, Set(ci"foo", ci"bar", ci"baz")) + assertEquals(c.response, Set(ci"foo", ci"baz", ci"qux")) + } + + test("`union` returns `this` when `that` is empty") { + val a = HeadersAllowedAsAttributes(Set(ci"foo", ci"bar")) + assert((a | HeadersAllowedAsAttributes.none) eq a) + } + + test("`union` deduplicates shared references") { + val a = HeadersAllowedAsAttributes(Set(ci"foo")) | + HeadersAllowedAsAttributes(Set(ci"bar")) + assert(a.request eq a.response) + } +} diff --git a/trace/server/src/main/scala/org/http4s/otel4s/middleware/trace/server/AttributeProvider.scala b/trace/server/src/main/scala/org/http4s/otel4s/middleware/trace/server/AttributeProvider.scala new file mode 100644 index 0000000..0cd148c --- /dev/null +++ b/trace/server/src/main/scala/org/http4s/otel4s/middleware/trace/server/AttributeProvider.scala @@ -0,0 +1,81 @@ +/* + * Copyright 2023 http4s.org + * + * 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.http4s +package otel4s.middleware +package trace.server + +import org.typelevel.ci.CIString +import org.typelevel.otel4s.Attributes + +/** Provides attributes for spans using requests and responses. + * + * @note Implementations MUST NOT access request or response bodies. + */ +trait AttributeProvider { self => + + /** Provides attributes for a span using the given request. + * + * @note Implementation MUST NOT access request body. + */ + def requestAttributes[F[_]]( + request: Request[F], + routeClassifier: RouteClassifier, + redactor: PathAndQueryRedactor, + headersAllowedAsAttributes: Set[CIString], + ): Attributes + + /** Provides attributes for a span using the given response. + * + * @note Implementation MUST NOT access response body. + */ + def responseAttributes[F[_]]( + response: Response[F], + headersAllowedAsAttributes: Set[CIString], + ): Attributes + + /** @return an `AttributeProvider` that provides the attributes from this and + * another `AttributeProvider` + */ + def and(that: AttributeProvider): AttributeProvider = + new AttributeProvider { + def requestAttributes[F[_]]( + request: Request[F], + routeClassifier: RouteClassifier, + redactor: PathAndQueryRedactor, + headersAllowedAsAttributes: Set[CIString], + ): Attributes = + self.requestAttributes( + request, + routeClassifier, + redactor, + headersAllowedAsAttributes, + ) ++ + that.requestAttributes( + request, + routeClassifier, + redactor, + headersAllowedAsAttributes, + ) + + def responseAttributes[F[_]]( + response: Response[F], + headersAllowedAsAttributes: Set[CIString], + ): Attributes = + self.responseAttributes(response, headersAllowedAsAttributes) ++ + that.responseAttributes(response, headersAllowedAsAttributes) + } +} diff --git a/trace/server/src/main/scala/org/http4s/otel4s/middleware/trace/server/OriginalScheme.scala b/trace/server/src/main/scala/org/http4s/otel4s/middleware/trace/server/OriginalScheme.scala new file mode 100644 index 0000000..4f7362f --- /dev/null +++ b/trace/server/src/main/scala/org/http4s/otel4s/middleware/trace/server/OriginalScheme.scala @@ -0,0 +1,49 @@ +/* + * Copyright 2023 http4s.org + * + * 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.http4s +package otel4s.middleware.trace.server + +import org.http4s.headers.Forwarded +import org.http4s.headers.`X-Forwarded-Proto` + +/** The original scheme used by a client in a request. */ +final class OriginalScheme private (val value: Option[Uri.Scheme]) extends AnyVal + +object OriginalScheme { + + /** @param forwarded the `Forwarded` header, if present in the request. + * Because it is used in the creation of several + * `Attribute`s, it is parsed and provided separately. + * If not provided in this parameter, it will not be read + * from `headers`. + * @param headers the request's headers + * @param url the request's URL + * @return the original scheme used by the client, possibly forwarded by + * a `Forwarded` or `X-Forwarded-Proto` header + */ + def apply( + forwarded: Option[Forwarded], + headers: Headers, + url: Uri, + ): OriginalScheme = { + val scheme = forwarded + .flatMap(findFirstInForwarded(_, _.maybeProto)) + .orElse(headers.get[`X-Forwarded-Proto`].map(_.scheme)) + .orElse(url.scheme) + new OriginalScheme(scheme) + } +} diff --git a/trace/server/src/main/scala/org/http4s/otel4s/middleware/trace/server/RouteClassifier.scala b/trace/server/src/main/scala/org/http4s/otel4s/middleware/trace/server/RouteClassifier.scala new file mode 100644 index 0000000..5c2f4b2 --- /dev/null +++ b/trace/server/src/main/scala/org/http4s/otel4s/middleware/trace/server/RouteClassifier.scala @@ -0,0 +1,63 @@ +/* + * Copyright 2023 http4s.org + * + * 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.http4s +package otel4s.middleware.trace.server + +/** Determines the route within the application from a request. + * + * @note This trait is used to provide the value for the `http.route` + * `Attribute`, which is the path template relative to the application + * root. If the application's routes are written as a `PartialFunction`, + * then the precise format or the path template is up to the implementer + * to decide. However, if a library with its own format is used to + * generate the `PartialFunction`, then that library's format should be + * used. The values returned by an implementation should have low + * cardinality. See + * [[https://opentelemetry.io/docs/specs/semconv/http/http-spans/#http-server]] + * for more information. The value of the `http.route` `Attribute` is + * also used as part of the span name. + */ +trait RouteClassifier { + + /** Determines the route within the application from a request. + * A value of `None` indicates that the route could not be determined. + */ + def classify(request: RequestPrelude): Option[String] +} + +object RouteClassifier { + + /** A classifier that is never able to classify any routes. */ + val indeterminate: RouteClassifier = _ => None + + /** Mirrors `HttpRoutes.of` for use with `Http4sDsl`. The `Request` passed to + * the provided `PartialFunction` will only be populated by the values from + * a `RequestPrelude`. + */ + def of[F[_]](pf: PartialFunction[Request[F], String]): RouteClassifier = { + val lifted = pf.lift + (prelude: RequestPrelude) => { + val req = Request[F]( + method = prelude.method, + uri = prelude.uri, + httpVersion = prelude.httpVersion, + headers = prelude.headers, + ) + lifted(req) + } + } +} diff --git a/trace/server/src/main/scala/org/http4s/otel4s/middleware/trace/server/ServerMiddleware.scala b/trace/server/src/main/scala/org/http4s/otel4s/middleware/trace/server/ServerMiddleware.scala deleted file mode 100644 index e888c05..0000000 --- a/trace/server/src/main/scala/org/http4s/otel4s/middleware/trace/server/ServerMiddleware.scala +++ /dev/null @@ -1,284 +0,0 @@ -/* - * Copyright 2023 http4s.org - * - * 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.http4s -package otel4s.middleware -package trace -package server - -import cats.data.Kleisli -import cats.effect.kernel.MonadCancelThrow -import cats.effect.kernel.Outcome -import cats.effect.syntax.monadCancel._ -import cats.syntax.applicative._ -import cats.syntax.flatMap._ -import org.http4s.Status.ServerError -import org.http4s.headers.Host -import org.http4s.headers.`User-Agent` -import org.typelevel.ci.CIString -import org.typelevel.otel4s.Attribute -import org.typelevel.otel4s.Attributes -import org.typelevel.otel4s.KindTransformer -import org.typelevel.otel4s.trace.SpanKind -import org.typelevel.otel4s.trace.StatusCode -import org.typelevel.otel4s.trace.Tracer - -import scala.collection.immutable - -/** Middleware for wrapping an http4s `Server` to add tracing. - * - * @see [[https://opentelemetry.io/docs/specs/semconv/http/http-spans/#http-server]] - */ -object ServerMiddleware { - - /** @return a server middleware builder with default configuration */ - def default[F[_]: Tracer: MonadCancelThrow]: ServerMiddlewareBuilder[F] = - new ServerMiddlewareBuilder[F]( - Defaults.allowedRequestHeaders, - Defaults.allowedResponseHeaders, - Defaults.routeClassifier, - Defaults.serverSpanName, - Defaults.additionalRequestAttributes, - Defaults.additionalResponseAttributes, - Defaults.urlRedactor, - Defaults.shouldTrace, - ) - - /** The default configuration values for a server middleware builder. */ - object Defaults { - def allowedRequestHeaders: Set[CIString] = - TypedAttributes.Headers.defaultAllowedHeaders - def allowedResponseHeaders: Set[CIString] = - TypedAttributes.Headers.defaultAllowedHeaders - val routeClassifier: RequestPrelude => Option[String] = _ => None - val serverSpanName: RequestPrelude => String = - req => s"Http Server - ${req.method}" - val additionalRequestAttributes: RequestPrelude => immutable.Iterable[Attribute[_]] = - _ => Nil - val additionalResponseAttributes: ResponsePrelude => immutable.Iterable[Attribute[_]] = - _ => Nil - def urlRedactor: UriRedactor = UriRedactor.OnlyRedactUserInfo - val shouldTrace: RequestPrelude => ShouldTrace = _ => ShouldTrace.Trace - } - - /** A builder for server middlewares. */ - final class ServerMiddlewareBuilder[F[_]: Tracer: MonadCancelThrow] private[ServerMiddleware] ( - allowedRequestHeaders: Set[CIString], - allowedResponseHeaders: Set[CIString], - routeClassifier: RequestPrelude => Option[String], - serverSpanName: RequestPrelude => String, - additionalRequestAttributes: RequestPrelude => immutable.Iterable[Attribute[_]], - additionalResponseAttributes: ResponsePrelude => immutable.Iterable[Attribute[_]], - urlRedactor: UriRedactor, - shouldTrace: RequestPrelude => ShouldTrace, - ) { - private def copy( - allowedRequestHeaders: Set[CIString] = this.allowedRequestHeaders, - allowedResponseHeaders: Set[CIString] = this.allowedResponseHeaders, - routeClassifier: RequestPrelude => Option[String] = this.routeClassifier, - serverSpanName: RequestPrelude => String = this.serverSpanName, - additionalRequestAttributes: RequestPrelude => immutable.Iterable[Attribute[_]] = - this.additionalRequestAttributes, - additionalResponseAttributes: ResponsePrelude => immutable.Iterable[Attribute[_]] = - this.additionalResponseAttributes, - urlRedactor: UriRedactor = this.urlRedactor, - shouldTrace: RequestPrelude => ShouldTrace = this.shouldTrace, - ): ServerMiddlewareBuilder[F] = - new ServerMiddlewareBuilder[F]( - allowedRequestHeaders, - allowedResponseHeaders, - routeClassifier, - serverSpanName, - additionalRequestAttributes, - additionalResponseAttributes, - urlRedactor, - shouldTrace, - ) - - /** Sets which request headers are allowed to made into `Attribute`s. */ - def withAllowedRequestHeaders(allowedHeaders: Set[CIString]): ServerMiddlewareBuilder[F] = - copy(allowedRequestHeaders = allowedHeaders) - - /** Sets which response headers are allowed to made into `Attribute`s. */ - def withAllowedResponseHeaders(allowedHeaders: Set[CIString]): ServerMiddlewareBuilder[F] = - copy(allowedResponseHeaders = allowedHeaders) - - /** Sets how to determine the route within the application from a request. - * A value of `None` returned by the given function indicates that the - * route could not be determined. - */ - def withRouteClassifier( - routeClassifier: RequestPrelude => Option[String] - ): ServerMiddlewareBuilder[F] = - copy(routeClassifier = routeClassifier) - - /** Sets how to derive the name of a server span from a request. */ - def withServerSpanName(serverSpanName: RequestPrelude => String): ServerMiddlewareBuilder[F] = - copy(serverSpanName = serverSpanName) - - /** Sets how to derive additional `Attribute`s from a request to add to the - * server span. - */ - def withAdditionalRequestAttributes( - additionalRequestAttributes: RequestPrelude => immutable.Iterable[Attribute[_]] - ): ServerMiddlewareBuilder[F] = - copy(additionalRequestAttributes = additionalRequestAttributes) - - /** Sets how to derive additional `Attribute`s from a response to add to the - * server span. - */ - def withAdditionalResponseAttributes( - additionalResponseAttributes: ResponsePrelude => immutable.Iterable[Attribute[_]] - ): ServerMiddlewareBuilder[F] = - copy(additionalResponseAttributes = additionalResponseAttributes) - - /** Sets how to redact URLs before turning them into `Attribute`s. */ - def withUrlRedactor(urlRedactor: UriRedactor): ServerMiddlewareBuilder[F] = - copy(urlRedactor = urlRedactor) - - /** Sets how to determine when to trace a request and its response. */ - def withShouldTrace(shouldTrace: RequestPrelude => ShouldTrace): ServerMiddlewareBuilder[F] = - copy(shouldTrace = shouldTrace) - - /** Returns a middleware in a way that abstracts over - * [[org.http4s.HttpApp `HttpApp`]] and - * [[org.http4s.HttpRoutes `HttpRoutes`]]. In most cases, it is preferable - * to use the methods that directly build the specific desired type. - * - * @see [[buildHttpApp]] - * @see [[buildHttpRoutes]] - */ - def buildGenericTracedHttp[G[_]: MonadCancelThrow]( - f: Http[G, F] - )(implicit kt: KindTransformer[F, G]): Http[G, F] = - Kleisli { (req: Request[F]) => - val reqPrelude = req.requestPrelude - if ( - !shouldTrace(reqPrelude).shouldTrace || - !Tracer[F].meta.isEnabled - ) { - f(req) - } else { - val init = - request( - req, - allowedRequestHeaders, - routeClassifier, - urlRedactor, - ) ++ additionalRequestAttributes(reqPrelude) - MonadCancelThrow[G].uncancelable { poll => - val tracerG = Tracer[F].mapK[G] - tracerG.joinOrRoot(req.headers) { - tracerG - .spanBuilder(serverSpanName(reqPrelude)) - .withSpanKind(SpanKind.Server) - .addAttributes(init) - .build - .use { span => - poll(f.run(req)) - .guaranteeCase { - case Outcome.Succeeded(fa) => - fa.flatMap { resp => - val out = - response( - resp, - allowedResponseHeaders, - ) ++ additionalResponseAttributes(resp.responsePrelude) - - span.addAttributes(out) >> span - .setStatus(StatusCode.Error) - .unlessA(resp.status.isSuccess) - } - case Outcome.Errored(e) => - span.addAttributes(TypedAttributes.errorType(e)) - case Outcome.Canceled() => - MonadCancelThrow[G].unit - } - } - } - } - } - } - - /** @return a configured middleware for `HttpApp` */ - def buildHttpApp(f: HttpApp[F]): HttpApp[F] = - buildGenericTracedHttp(f) - - /** @return a configured middleware for `HttpRoutes` */ - def buildHttpRoutes(f: HttpRoutes[F]): HttpRoutes[F] = - buildGenericTracedHttp(f) - } - - /** @return the default `Attribute`s for a request */ - private def request[F[_]]( - request: Request[F], - allowedHeaders: Set[CIString], - routeClassifier: RequestPrelude => Option[String], - urlRedactor: UriRedactor, - ): Attributes = { - val builder = Attributes.newBuilder - builder += TypedAttributes.httpRequestMethod(request.method) - builder ++= TypedAttributes.url(request.uri, urlRedactor) - val host = request.headers.get[Host].getOrElse { - val authority = request.uri.authority.getOrElse(Uri.Authority()) - Host(authority.host.value, authority.port) - } - builder += TypedAttributes.serverAddress(host) - request.headers - .get[`User-Agent`] - .foreach(ua => builder += TypedAttributes.userAgentOriginal(ua)) - - routeClassifier(request.requestPrelude).foreach(route => - builder += TypedServerAttributes.httpRoute(route) - ) - - request.remote.foreach { socketAddress => - builder += - TypedAttributes.networkPeerAddress(socketAddress.host) - - builder += - TypedServerAttributes.clientPort(socketAddress.port) - } - - TypedServerAttributes.clientAddress(request).foreach(builder += _) - builder ++= - TypedAttributes.Headers.request(request.headers, allowedHeaders) - - builder.result() - } - - /** @return the default `Attribute`s for a response */ - private def response[F[_]](response: Response[F], allowedHeaders: Set[CIString]): Attributes = { - val builder = Attributes.newBuilder - - builder += TypedAttributes.httpResponseStatusCode(response.status) - builder ++= TypedAttributes.Headers.response(response.headers, allowedHeaders) - - // https://opentelemetry.io/docs/specs/semconv/http/http-spans/#http-server-semantic-conventions - // [5]: If response status code was sent or received and status indicates an error according - // to HTTP span status definition, `error.type` SHOULD be set to the status code number (represented as a string), - // an exception type (if thrown) or a component-specific error identifier. - // - // For HTTP status codes in the 4xx range span status MUST be left unset in case of SpanKind.SERVER - // and SHOULD be set to Error in case of SpanKind.CLIENT. - // For HTTP status codes in the 5xx range, as well as any other code the client failed to interpret, - // span status SHOULD be set to Error. - if (response.status.responseClass == ServerError) - builder += TypedAttributes.errorType(response.status) - - builder.result() - } -} diff --git a/trace/server/src/main/scala/org/http4s/otel4s/middleware/trace/server/ServerMiddlewareBuilder.scala b/trace/server/src/main/scala/org/http4s/otel4s/middleware/trace/server/ServerMiddlewareBuilder.scala new file mode 100644 index 0000000..f016201 --- /dev/null +++ b/trace/server/src/main/scala/org/http4s/otel4s/middleware/trace/server/ServerMiddlewareBuilder.scala @@ -0,0 +1,174 @@ +/* + * Copyright 2023 http4s.org + * + * 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.http4s +package otel4s.middleware +package trace +package server + +import cats.data.Kleisli +import cats.effect.kernel.MonadCancelThrow +import cats.effect.kernel.Outcome +import cats.effect.syntax.monadCancel._ +import cats.syntax.applicative._ +import cats.syntax.flatMap._ +import fs2.Stream +import org.typelevel.otel4s.KindTransformer +import org.typelevel.otel4s.trace.SpanKind +import org.typelevel.otel4s.trace.StatusCode +import org.typelevel.otel4s.trace.Tracer + +/** Middleware builder for wrapping an http4s `Server` to add tracing. + * + * @see [[https://opentelemetry.io/docs/specs/semconv/http/http-spans/#http-server]] + */ +class ServerMiddlewareBuilder[F[_]: Tracer: MonadCancelThrow] private ( + redactor: PathAndQueryRedactor, // cannot safely have default value + spanDataProvider: SpanDataProvider, + routeClassifier: RouteClassifier, + headersAllowedAsAttributes: HeadersAllowedAsAttributes, + shouldTrace: RequestPrelude => ShouldTrace, +) { + private def copy( + spanDataProvider: SpanDataProvider = this.spanDataProvider, + routeClassifier: RouteClassifier = this.routeClassifier, + headersAllowedAsAttributes: HeadersAllowedAsAttributes = this.headersAllowedAsAttributes, + shouldTrace: RequestPrelude => ShouldTrace = this.shouldTrace, + ): ServerMiddlewareBuilder[F] = + new ServerMiddlewareBuilder[F]( + this.redactor, + spanDataProvider, + routeClassifier, + headersAllowedAsAttributes, + shouldTrace, + ) + + /** Sets how a span's name and `Attributes` are derived from a request and + * response. + */ + def withSpanDataProvider(spanDataProvider: SpanDataProvider): ServerMiddlewareBuilder[F] = + copy(spanDataProvider = spanDataProvider) + + /** Sets how to determine the route within the application from a request. */ + def withRouteClassifier(routeClassifier: RouteClassifier): ServerMiddlewareBuilder[F] = + copy(routeClassifier = routeClassifier) + + /** Sets which headers are allowed to be made into `Attribute`s. */ + def withHeadersAllowedAsAttributes( + headersAllowedAsAttributes: HeadersAllowedAsAttributes + ): ServerMiddlewareBuilder[F] = + copy(headersAllowedAsAttributes = headersAllowedAsAttributes) + + /** Sets how to determine when to trace a request and its response. */ + def withShouldTrace(shouldTrace: RequestPrelude => ShouldTrace): ServerMiddlewareBuilder[F] = + copy(shouldTrace = shouldTrace) + + /** Returns a middleware in a way that abstracts over + * [[org.http4s.HttpApp `HttpApp`]] and + * [[org.http4s.HttpRoutes `HttpRoutes`]]. In most cases, it is preferable + * to use the methods that directly build the specific desired type. + * + * @see [[buildHttpApp]] + * @see [[buildHttpRoutes]] + */ + def buildGenericTracedHttp[G[_]: MonadCancelThrow]( + f: Http[G, F] + )(implicit kt: KindTransformer[F, G]): Http[G, F] = + Kleisli { (req: Request[F]) => + if ( + !shouldTrace(req.requestPrelude).shouldTrace || + !Tracer[F].meta.isEnabled + ) { + f(req) + } else { + val reqNoBody = req.withBodyStream(Stream.empty) + val shared = + spanDataProvider.processSharedData(reqNoBody, routeClassifier, redactor) + val spanName = + spanDataProvider.spanName(reqNoBody, routeClassifier, redactor, shared) + val reqAttributes = + spanDataProvider.requestAttributes( + reqNoBody, + routeClassifier, + redactor, + shared, + headersAllowedAsAttributes.request, + ) + MonadCancelThrow[G].uncancelable { poll => + val tracerG = Tracer[F].mapK[G] + tracerG.joinOrRoot(req.headers) { + tracerG + .spanBuilder(spanName) + .withSpanKind(SpanKind.Server) + .addAttributes(reqAttributes) + .build + .use { span => + poll(f.run(req)) + .guaranteeCase { + case Outcome.Succeeded(fa) => + fa.flatMap { resp => + val respAttributes = + spanDataProvider.responseAttributes( + resp.withBodyStream(Stream.empty), + headersAllowedAsAttributes.response, + ) + span.addAttributes(respAttributes) >> span + .setStatus(StatusCode.Error) + .unlessA(resp.status.isSuccess) + } + case Outcome.Errored(e) => + span.addAttributes(TypedAttributes.errorType(e)) + case Outcome.Canceled() => + MonadCancelThrow[G].unit + } + } + } + } + } + } + + /** @return a configured middleware for `HttpApp` */ + def buildHttpApp(f: HttpApp[F]): HttpApp[F] = + buildGenericTracedHttp(f) + + /** @return a configured middleware for `HttpRoutes` */ + def buildHttpRoutes(f: HttpRoutes[F]): HttpRoutes[F] = + buildGenericTracedHttp(f) +} + +object ServerMiddlewareBuilder { + + /** @return a server middleware builder with default configuration */ + def default[F[_]: Tracer: MonadCancelThrow]( + redactor: PathAndQueryRedactor + ): ServerMiddlewareBuilder[F] = + new ServerMiddlewareBuilder[F]( + redactor, + Defaults.spanDataProvider, + Defaults.routeClassifier, + Defaults.headersAllowedAsAttributes, + Defaults.shouldTrace, + ) + + /** The default configuration values for a server middleware builder. */ + object Defaults { + def spanDataProvider: SpanDataProvider = SpanDataProvider.default + val routeClassifier: RouteClassifier = RouteClassifier.indeterminate + def headersAllowedAsAttributes: HeadersAllowedAsAttributes = + HeadersAllowedAsAttributes.default + val shouldTrace: RequestPrelude => ShouldTrace = _ => ShouldTrace.Trace + } +} diff --git a/trace/server/src/main/scala/org/http4s/otel4s/middleware/trace/server/SpanDataProvider.scala b/trace/server/src/main/scala/org/http4s/otel4s/middleware/trace/server/SpanDataProvider.scala new file mode 100644 index 0000000..87e436a --- /dev/null +++ b/trace/server/src/main/scala/org/http4s/otel4s/middleware/trace/server/SpanDataProvider.scala @@ -0,0 +1,245 @@ +/* + * Copyright 2023 http4s.org + * + * 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.http4s +package otel4s.middleware +package trace.server + +import org.http4s.headers.Forwarded +import org.typelevel.ci.CIString +import org.typelevel.otel4s.Attribute +import org.typelevel.otel4s.Attributes + +/** Provides a name and attributes for spans using requests and responses. + * + * @note Implementations MUST NOT access request or response bodies. + */ +trait SpanDataProvider extends AttributeProvider { self => + + /** The type of shared processed data used to provide both the span name and + * request `Attributes`. + */ + type Shared + + /** Process data used to provide both the span name and request `Attributes`. + * + * @note Implementation MUST NOT access request body. + */ + def processSharedData[F[_]]( + request: Request[F], + routeClassifier: RouteClassifier, + redactor: PathAndQueryRedactor, + ): Shared + + /** Provides the name for a span using the given request. + * + * @note Implementation MUST NOT access request body. + */ + def spanName[F[_]]( + request: Request[F], + routeClassifier: RouteClassifier, + redactor: PathAndQueryRedactor, + sharedProcessedData: Shared, + ): String + + /** Provides attributes for a span using the given request. + * + * @note Implementation MUST NOT access request body. + */ + def requestAttributes[F[_]]( + request: Request[F], + routeClassifier: RouteClassifier, + redactor: PathAndQueryRedactor, + sharedProcessedData: Shared, + headersAllowedAsAttributes: Set[CIString], + ): Attributes + + final def requestAttributes[F[_]]( + request: Request[F], + routeClassifier: RouteClassifier, + redactor: PathAndQueryRedactor, + headersAllowedAsAttributes: Set[CIString], + ): Attributes = + requestAttributes( + request, + routeClassifier, + redactor, + processSharedData(request, routeClassifier, redactor), + headersAllowedAsAttributes, + ) + + /** Returns an `AttributeProvider` that provides the attributes from this and + * another `AttributeProvider`. + * + * If `that` is a `SpanAndAttributeProvider`, it will not be used to provide + * span names. + */ + override def and(that: AttributeProvider): SpanDataProvider = + new SpanDataProvider { + type Shared = self.Shared + + def processSharedData[F[_]]( + request: Request[F], + routeClassifier: RouteClassifier, + redactor: PathAndQueryRedactor, + ): Shared = + self.processSharedData(request, routeClassifier, redactor) + + def spanName[F[_]]( + request: Request[F], + routeClassifier: RouteClassifier, + redactor: PathAndQueryRedactor, + sharedProcessedData: Shared, + ): String = + self.spanName(request, routeClassifier, redactor, sharedProcessedData) + + def requestAttributes[F[_]]( + request: Request[F], + routeClassifier: RouteClassifier, + redactor: PathAndQueryRedactor, + sharedProcessedData: Shared, + headersAllowedAsAttributes: Set[CIString], + ): Attributes = + self.requestAttributes( + request, + routeClassifier, + redactor, + sharedProcessedData, + headersAllowedAsAttributes, + ) ++ + that.requestAttributes( + request, + routeClassifier, + redactor, + headersAllowedAsAttributes, + ) + + def responseAttributes[F[_]]( + response: Response[F], + headersAllowedAsAttributes: Set[CIString], + ): Attributes = + self.responseAttributes(response, headersAllowedAsAttributes) ++ + that.responseAttributes(response, headersAllowedAsAttributes) + } +} + +object SpanDataProvider { + + /** The default provider, which follows OpenTelemetry semantic conventions. */ + def default: SpanDataProvider = openTelemetry + + /** A `SpanAndAttributeProvider` following OpenTelemetry semantic conventions. */ + val openTelemetry: SpanDataProvider = { + final case class Data( + httpRequestMethod: Attribute[String], + httpRoute: Option[Attribute[String]], + ) { + val requestMethodIsUnknown: Boolean = + httpRequestMethod == TypedAttributes.httpRequestMethodOther + } + + new SpanDataProvider { + // https://opentelemetry.io/docs/specs/semconv/http/http-spans/#http-server-semantic-conventions + + type Shared = Data + + def processSharedData[F[_]]( + request: Request[F], + routeClassifier: RouteClassifier, + redactor: PathAndQueryRedactor, + ): Data = + Data( + httpRequestMethod = TypedAttributes.httpRequestMethod(request.method), + httpRoute = TypedServerAttributes.httpRoute(request, routeClassifier), + ) + + def spanName[F[_]]( + request: Request[F], + routeClassifier: RouteClassifier, + redactor: PathAndQueryRedactor, + sharedProcessedData: Data, + ): String = { + val method = + if (sharedProcessedData.requestMethodIsUnknown) "HTTP" + else sharedProcessedData.httpRequestMethod.value + sharedProcessedData.httpRoute + .fold(method)(attr => s"$method ${attr.value}") + } + + def requestAttributes[F[_]]( + request: Request[F], + routeClassifier: RouteClassifier, + redactor: PathAndQueryRedactor, + sharedProcessedData: Data, + headersAllowedAsAttributes: Set[CIString], + ): Attributes = { + val b = Attributes.newBuilder + val forwarded = request.headers.get[Forwarded] + val scheme = OriginalScheme(forwarded, request.headers, request.uri) + + b += sharedProcessedData.httpRequestMethod // http4s does not support unknown request methods + b ++= TypedServerAttributes.urlPath(request.uri.path, redactor) + b ++= TypedServerAttributes.urlScheme(scheme) + // `error.type` handled by `responseAttributes` + if (sharedProcessedData.requestMethodIsUnknown) { + b += TypedAttributes.httpRequestMethodOriginal(request.method) + } + // `http.response.status_code` handled by `responseAttributes` + b ++= sharedProcessedData.httpRoute + // `network.protocol.name` not required because http4s only supports http + // and `Request#httpVersion` is always populated/available + // `server.port` handled later with server.address + b ++= TypedServerAttributes.urlQuery(request.uri.query, redactor) + // `client.port` handled here (see below) + TypedServerAttributes.clientAddressAndPortForBuilder(request, forwarded)(b) + request.remote.foreach { socketAddress => + b += TypedAttributes.networkPeerAddress(socketAddress.host) + b += TypedAttributes.networkPeerPort(socketAddress.port) + } + b += TypedAttributes.networkProtocolVersion(request.httpVersion) + // `server.port` handled here (see above) + TypedServerAttributes.serverAddressAndPortForBuilder(request, forwarded, scheme)(b) + b ++= TypedAttributes.userAgentOriginal(request.headers) + // `client.port` handled earlier with client.address + TypedAttributes.httpRequestHeadersForBuilder(request.headers, headersAllowedAsAttributes)(b) + // `http.response.header.`s handled by `responseAttributes` + // `network.local.address` not opted into at this time + // `network.local.port` not opted into at this time + // `network.transport` not opted into at this time + + b.result() + } + + def responseAttributes[F[_]]( + response: Response[F], + headersAllowedAsAttributes: Set[CIString], + ): Attributes = { + val b = Attributes.newBuilder + + if (response.status.responseClass == Status.ServerError) { + // setting `error.type` for a `Throwable` must be done in the middleware + b += TypedAttributes.errorType(response.status) + } + b += TypedAttributes.httpResponseStatusCode(response.status) + TypedAttributes.httpResponseHeadersForBuilder(response.headers, headersAllowedAsAttributes)( + b + ) + + b.result() + } + } + } +} diff --git a/trace/server/src/main/scala/org/http4s/otel4s/middleware/trace/server/TypedServerAttributes.scala b/trace/server/src/main/scala/org/http4s/otel4s/middleware/trace/server/TypedServerAttributes.scala index 1278e1c..2ef10b6 100644 --- a/trace/server/src/main/scala/org/http4s/otel4s/middleware/trace/server/TypedServerAttributes.scala +++ b/trace/server/src/main/scala/org/http4s/otel4s/middleware/trace/server/TypedServerAttributes.scala @@ -15,38 +15,197 @@ */ package org.http4s -package otel4s.middleware.trace.server +package otel4s.middleware +package trace +package server -import com.comcast.ip4s.Port +import com.comcast.ip4s.IpAddress +import org.http4s.headers.Forwarded +import org.http4s.headers.Host import org.http4s.headers.`X-Forwarded-For` +import org.typelevel.ci._ import org.typelevel.otel4s.Attribute +import org.typelevel.otel4s.Attributes import org.typelevel.otel4s.semconv.attributes.ClientAttributes import org.typelevel.otel4s.semconv.attributes.HttpAttributes +import org.typelevel.otel4s.semconv.attributes.ServerAttributes +import org.typelevel.otel4s.semconv.attributes.UrlAttributes /** Methods for creating appropriate `Attribute`s from typed HTTP objects * within an HTTP server. */ object TypedServerAttributes { - /** @return the `client.address` `Attribute` */ - def clientAddress[F[_]](request: Request[F]): Option[Attribute[String]] = - request.headers - .get[`X-Forwarded-For`] - .fold(request.remoteAddr)(_.values.head) - .map(ip => ClientAttributes.ClientAddress(ip.toString)) + // TODO: remove once added to http4s core + private[this] final case class `X-Forwarded-Host`(host: String, port: Option[Int] = None) - /** @return the `client.port` `Attribute` */ - def clientPort(port: Port): Attribute[Long] = - ClientAttributes.ClientPort(port.value.toLong) + implicit private[this] val xForwardedHostHeader: Header[`X-Forwarded-Host`, Header.Single] = + Header.createRendered[`X-Forwarded-Host`, Header.Single, Host]( + ci"X-Forwarded-Host", + xfh => Host(xfh.host, xfh.port), + Host.parse(_).map(h => `X-Forwarded-Host`(h.host, h.port)), + ) - /** Returns the `http.route` `Attribute`. + private[this] def addressFromNodeName(nodeName: Forwarded.Node.Name): Option[IpAddress] = + nodeName match { + case Forwarded.Node.Name.Ipv4(address) => Some(address) + case Forwarded.Node.Name.Ipv6(address) => Some(address) + case _ => None + } + + private[this] def clientAddress(address: IpAddress): Attribute[String] = + ClientAttributes.ClientAddress(address.toString) + + /** Adds the `client.address` and `client.port` `Attribute`s to the provided + * builder. + * + * @param request the client's request + * @param forwarded the `Forwarded` header, if present in the request. + * Because it is used in the creation of several + * `Attribute`s, it is parsed and provided separately. + * If not provided in this parameter, it will not be read + * from `request`. + * @param b the builder to which to append `Attribute`s + */ + def clientAddressAndPortForBuilder[F[_]]( + request: Request[F], + forwarded: Option[Forwarded], + )(b: Attributes.Builder): b.type = + forwarded + .flatMap(findFirstInForwarded(_, _.maybeFor)) + .map[b.type] { node => + b ++= addressFromNodeName(node.nodeName).map(clientAddress) + b ++= node.nodePort + .collect { case Forwarded.Node.Port.Numeric(port) => + ClientAttributes.ClientPort(port.toLong) + } + } + .orElse[b.type] { + request.headers + .get[`X-Forwarded-For`] + .map(b ++= _.values.head.map(clientAddress)) + } + .getOrElse { + b ++= request.remoteAddr.map(clientAddress) + b ++= request.remotePort + .map(port => ClientAttributes.ClientPort(port.value.toLong)) + } + + /** @param request the client's request + * @param forwarded the `Forwarded` header, if present in the request. + * Because it is used in the creation of several + * `Attribute`s, it is parsed and provided separately. + * If not provided in this parameter, it will not be read + * from `request`. + * @return the `client.address` and `client.port` `Attributes` + */ + def clientAddressAndPort[F[_]]( + request: Request[F], + forwarded: Option[Forwarded], + ): Attributes = + clientAddressAndPortForBuilder(request, forwarded)(Attributes.newBuilder).result() + + /** @return the `http.route` `Attribute` */ + def httpRoute(request: RequestPrelude, classifier: RouteClassifier): Option[Attribute[String]] = + classifier.classify(request).map(HttpAttributes.HttpRoute.apply) + + /** @return the `http.route` `Attribute` */ + def httpRoute[F[_]](request: Request[F], classifier: RouteClassifier): Option[Attribute[String]] = + httpRoute(request.requestPrelude, classifier) + + /** Adds the `server.address` and `server.port` `Attribute`s to the provided + * builder. * - * Unfortunately, as this `Attribute` represents the route from the - * application root, its value cannot be derived generically, so it is - * private. Hopefully at some point we can develop a typed/type-safe API - * for deriving the route from the URL or similar, and expose this method - * at that time. + * @param request the client's request + * @param forwarded the `Forwarded` header, if present in the request. + * Because it is used in the creation of several + * `Attribute`s, it is parsed and provided separately. + * If not provided in this parameter, it will not be read + * from `request`. + * @param scheme the original scheme of the request + * @param b the builder to which to append `Attribute`s + */ + def serverAddressAndPortForBuilder[F[_]]( + request: Request[F], + forwarded: Option[Forwarded], + scheme: OriginalScheme, + )(b: Attributes.Builder): b.type = { + // https://opentelemetry.io/docs/specs/semconv/http/http-spans/#setting-serveraddress-and-serverport-attributes + + def serverPort(maybePort: Option[Int]): Option[Attribute[Long]] = + maybePort + .map(_.toLong) + .orElse(portFromScheme(scheme.value)) + .map(ServerAttributes.ServerPort.apply) + + forwarded + .flatMap(findFirstInForwarded(_, _.maybeHost)) + .map[b.type] { host => + b += ServerAttributes.ServerAddress(host.host.value) + b ++= serverPort(host.port) + } + .orElse[b.type] { + // parsing not currently supported, but if we know it exists then we + // know not to keep checking other things + request.headers + .get[`X-Forwarded-Host`] + .map { xfh => + b += ServerAttributes.ServerAddress(xfh.host) + b ++= serverPort(xfh.port) + } + } + .orElse[b.type] { + request.httpVersion.major match { + case 2 | 3 /* in case eventually supported by http4s */ => + // in HTTP/2 implementation, :authority pseudo-header is used to + // populate `Request#uri.authority` (at least by ember) + request.uri.authority.map { authority => + b += ServerAttributes.ServerAddress(authority.host.value) + b ++= serverPort(authority.port) + } + case _ => None + } + } + .orElse[b.type] { + request.headers + .get[Host] + .map { host => + b += ServerAttributes.ServerAddress(host.host) + b ++= serverPort(host.port) + } + } + .getOrElse(b) + } + + /** @param request the client's request + * @param forwarded the `Forwarded` header, if present in the request. + * Because it is used in the creation of several + * `Attribute`s, it is parsed and provided separately. + * If not provided in this parameter, it will not be read + * from `request`. + * @param scheme the original scheme of the request + * @return the `server.address` and `server.port` `Attributes` */ - private[middleware] def httpRoute(classifiedRoute: String): Attribute[String] = - HttpAttributes.HttpRoute(classifiedRoute) + def serverAddressAndPort[F[_]]( + request: Request[F], + forwarded: Option[Forwarded], + scheme: OriginalScheme, + ): Attributes = + serverAddressAndPortForBuilder(request, forwarded, scheme)(Attributes.newBuilder).result() + + /** @return the `url.path` `Attribute` */ + def urlPath(unredacted: Uri.Path, redactor: redact.PathRedactor): Option[Attribute[String]] = { + val path = redactor.redactPath(unredacted) + Option.unless(path == Uri.Path.empty)(UrlAttributes.UrlPath(path.renderString)) + } + + /** @return the `url.query` `Attribute` */ + def urlQuery(unredacted: Query, redactor: redact.QueryRedactor): Option[Attribute[String]] = { + val query = redactor.redactQuery(unredacted) + Option.unless(query.isEmpty)(UrlAttributes.UrlQuery(query.renderString)) + } + + /** @return the `url.scheme` `Attribute` */ + def urlScheme(scheme: OriginalScheme): Option[Attribute[String]] = + scheme.value.map(s => UrlAttributes.UrlScheme(s.value)) } diff --git a/trace/server/src/main/scala/org/http4s/otel4s/middleware/trace/server/package.scala b/trace/server/src/main/scala/org/http4s/otel4s/middleware/trace/server/package.scala new file mode 100644 index 0000000..c6f4943 --- /dev/null +++ b/trace/server/src/main/scala/org/http4s/otel4s/middleware/trace/server/package.scala @@ -0,0 +1,38 @@ +/* + * Copyright 2023 http4s.org + * + * 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.http4s.otel4s.middleware.trace + +import org.http4s.headers.Forwarded +import org.http4s.otel4s.middleware.redact.PathRedactor +import org.http4s.otel4s.middleware.redact.QueryRedactor + +package object server { + + /** Redacts the path and query of a request. */ + type PathAndQueryRedactor = PathRedactor with QueryRedactor + + /** @return the first value of a particular directive for the `Forwarded` + * header, if present + */ + private[server] def findFirstInForwarded[A]( + forwarded: Forwarded, + directive: Forwarded.Element => Option[A], + ): Option[A] = + forwarded.values.toList + .flatMap(directive) + .headOption +} diff --git a/trace/server/src/test/scala/org/http4s/otel4s/middleware/trace/server/OriginalSchemeTest.scala b/trace/server/src/test/scala/org/http4s/otel4s/middleware/trace/server/OriginalSchemeTest.scala new file mode 100644 index 0000000..4f8cd6c --- /dev/null +++ b/trace/server/src/test/scala/org/http4s/otel4s/middleware/trace/server/OriginalSchemeTest.scala @@ -0,0 +1,60 @@ +/* + * Copyright 2023 http4s.org + * + * 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.http4s +package otel4s.middleware.trace.server + +import munit.FunSuite +import munit.Location +import org.http4s.headers.Forwarded +import org.http4s.syntax.literals._ +import org.typelevel.ci.CIStringSyntax + +class OriginalSchemeTest extends FunSuite { + test("OriginalScheme.apply") { + def check( + headers: Headers, + url: Uri, + expected: Option[Uri.Scheme], + )(implicit loc: Location): Unit = + assertEquals(OriginalScheme(headers.get[Forwarded], headers, url).value, expected) + + val f1 = Header.Raw(ci"Forwarded", "proto=http") + val f2 = Header.Raw(ci"Forwarded", "proto=https") + val f3 = Header.Raw(ci"Forwarded", "by=\"_example\"") + val f4 = Header.Raw(ci"Forwarded", "by=\"_example\";proto=https") + val xfp1 = Header.Raw(ci"X-Forwarded-Proto", "http") + val xfp2 = Header.Raw(ci"X-Forwarded-Proto", "https") + val u1 = uri"http:" + val u2 = uri"https:" + val u3 = Uri() + + check(Headers(f1), u3, Some(Uri.Scheme.http)) + check(Headers(f2), u3, Some(Uri.Scheme.https)) + check(Headers(f3), u3, None) + check(Headers(f4), u3, Some(Uri.Scheme.https)) + check(Headers(xfp1), u3, Some(Uri.Scheme.http)) + check(Headers(xfp2), u3, Some(Uri.Scheme.https)) + check(Headers.empty, u1, Some(Uri.Scheme.http)) + check(Headers.empty, u2, Some(Uri.Scheme.https)) + check(Headers.empty, u3, None) + + // combinations + check(Headers(f1, xfp2), u2, Some(Uri.Scheme.http)) + check(Headers(f3, xfp1), u2, Some(Uri.Scheme.http)) + check(Headers(xfp2), u1, Some(Uri.Scheme.https)) + } +} diff --git a/trace/server/src/test/scala/org/http4s/otel4s/middleware/trace/server/ServerMiddlewareTests.scala b/trace/server/src/test/scala/org/http4s/otel4s/middleware/trace/server/ServerMiddlewareTests.scala index 1999ece..885d8e8 100644 --- a/trace/server/src/test/scala/org/http4s/otel4s/middleware/trace/server/ServerMiddlewareTests.scala +++ b/trace/server/src/test/scala/org/http4s/otel4s/middleware/trace/server/ServerMiddlewareTests.scala @@ -15,11 +15,14 @@ */ package org.http4s -package otel4s.middleware.trace.server +package otel4s.middleware.trace +package server import cats.effect.IO import cats.effect.testkit.TestControl import munit.CatsEffectSuite +import org.http4s.otel4s.middleware.redact.PathRedactor +import org.http4s.otel4s.middleware.redact.QueryRedactor import org.http4s.syntax.literals._ import org.typelevel.ci.CIStringSyntax import org.typelevel.otel4s.Attribute @@ -38,6 +41,7 @@ import scala.concurrent.duration.Duration import scala.util.control.NoStackTrace class ServerMiddlewareTests extends CatsEffectSuite { + import ServerMiddlewareTests.NoopRedactor private val spanLimits = SpanLimits.default @@ -53,10 +57,14 @@ class ServerMiddlewareTests extends CatsEffectSuite { Headers(Header.Raw(ci"foo", "bar"), Header.Raw(ci"baz", "qux")) val response = Response[IO](Status.Ok).withHeaders(headers) val tracedServer = - ServerMiddleware - .default[IO] - .withAllowedRequestHeaders(Set(ci"foo")) - .withAllowedResponseHeaders(Set(ci"baz")) + ServerMiddlewareBuilder + .default[IO](NoopRedactor) + .withHeadersAllowedAsAttributes( + HeadersAllowedAsAttributes( + request = Set(ci"foo"), + response = Set(ci"baz"), + ) + ) .buildHttpApp(HttpApp[IO](_.body.compile.drain.as(response))) val request = @@ -68,24 +76,22 @@ class ServerMiddlewareTests extends CatsEffectSuite { } yield { assertEquals(spans.length, 1) val span = spans.head - assertEquals(span.name, "Http Server - GET") + assertEquals(span.name, "GET") assertEquals(span.kind, SpanKind.Server) assertEquals(span.status, StatusData.Unset) val attributes = span.attributes.elements - assertEquals(attributes.size, 10) + assertEquals(attributes.size, 8) def getAttr[A: AttributeKey.KeySelect](name: String): Option[A] = attributes.get[A](name).map(_.value) assertEquals(getAttr[String]("http.request.method"), Some("GET")) assertEquals(getAttr[Seq[String]]("http.request.header.foo"), Some(Seq("bar"))) assertEquals(getAttr[Seq[String]]("http.request.header.baz"), None) - assertEquals(getAttr[String]("url.full"), Some("http://localhost/?#")) + assertEquals(getAttr[String]("network.protocol.version"), Some("1.1")) assertEquals(getAttr[String]("url.scheme"), Some("http")) assertEquals(getAttr[String]("url.path"), Some("/")) assertEquals(getAttr[String]("url.query"), Some("")) - assertEquals(getAttr[String]("url.fragment"), Some("")) - assertEquals(getAttr[String]("server.address"), Some("localhost")) assertEquals(getAttr[Long]("http.response.status_code"), Some(200L)) assertEquals(getAttr[Seq[String]]("http.response.header.foo"), None) assertEquals(getAttr[Seq[String]]("http.response.header.baz"), Some(Seq("qux"))) @@ -101,8 +107,8 @@ class ServerMiddlewareTests extends CatsEffectSuite { testkit.tracerProvider.get("tracer").flatMap { implicit tracer => val error = new RuntimeException("oops") with NoStackTrace {} - val tracedServer = ServerMiddleware - .default[IO] + val tracedServer = ServerMiddlewareBuilder + .default[IO](NoopRedactor) .buildHttpApp(HttpApp[IO](_ => IO.raiseError(error))) val request = Request[IO](Method.GET, uri"http://localhost/") @@ -120,12 +126,11 @@ class ServerMiddlewareTests extends CatsEffectSuite { val status = StatusData(StatusCode.Error) val attributes = Attributes( + Attribute("error.type", error.getClass.getName), Attribute("http.request.method", "GET"), + Attribute("network.protocol.version", "1.1"), Attribute("url.path", "/"), - Attribute("url.full", "http://localhost/"), Attribute("url.scheme", "http"), - Attribute("server.address", "localhost"), - Attribute("error.type", error.getClass.getName), ) for { @@ -147,21 +152,20 @@ class ServerMiddlewareTests extends CatsEffectSuite { .inMemory[IO]() .use { testkit => testkit.tracerProvider.get("tracer").flatMap { implicit tracer => - val tracedServer = ServerMiddleware - .default[IO] + val tracedServer = ServerMiddlewareBuilder + .default[IO](NoopRedactor) .buildHttpApp(HttpApp[IO](_ => IO.pure(Response[IO](Status.InternalServerError)))) val request = Request[IO](Method.GET, uri"http://localhost/") val status = StatusData(StatusCode.Error) val attributes = Attributes( + Attribute("error.type", "500"), Attribute("http.request.method", "GET"), Attribute("http.response.status_code", 500L), + Attribute("network.protocol.version", "1.1"), Attribute("url.path", "/"), - Attribute("url.full", "http://localhost/"), Attribute("url.scheme", "http"), - Attribute("server.address", "localhost"), - Attribute("error.type", "500"), ) for { @@ -182,8 +186,8 @@ class ServerMiddlewareTests extends CatsEffectSuite { .inMemory[IO]() .use { testkit => testkit.tracerProvider.get("tracer").flatMap { implicit tracer => - val tracedServer = ServerMiddleware - .default[IO] + val tracedServer = ServerMiddlewareBuilder + .default[IO](NoopRedactor) .buildHttpApp(HttpApp[IO](_ => IO.canceled.as(Response[IO](Status.Ok)))) val request = Request[IO](Method.GET, uri"http://localhost/") @@ -192,10 +196,9 @@ class ServerMiddlewareTests extends CatsEffectSuite { val attributes = Attributes( Attribute("http.request.method", "GET"), + Attribute("network.protocol.version", "1.1"), Attribute("url.path", "/"), - Attribute("url.full", "http://localhost/"), Attribute("url.scheme", "http"), - Attribute("server.address", "localhost"), ) for { @@ -213,3 +216,7 @@ class ServerMiddlewareTests extends CatsEffectSuite { } } + +object ServerMiddlewareTests { + object NoopRedactor extends PathRedactor.NeverRedact with QueryRedactor.NeverRedact +} diff --git a/trace/server/src/test/scala/org/http4s/otel4s/middleware/trace/server/TypedServerAttributesTest.scala b/trace/server/src/test/scala/org/http4s/otel4s/middleware/trace/server/TypedServerAttributesTest.scala new file mode 100644 index 0000000..b2ab98f --- /dev/null +++ b/trace/server/src/test/scala/org/http4s/otel4s/middleware/trace/server/TypedServerAttributesTest.scala @@ -0,0 +1,415 @@ +/* + * Copyright 2023 http4s.org + * + * 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.http4s +package otel4s.middleware.trace.server + +import cats.effect.IO +import com.comcast.ip4s.IpAddress +import com.comcast.ip4s.Ipv4Address +import com.comcast.ip4s.Ipv6Address +import com.comcast.ip4s.Port +import com.comcast.ip4s.SocketAddress +import munit.FunSuite +import munit.Location +import org.http4s.dsl.Http4sDsl +import org.http4s.headers.Forwarded +import org.http4s.otel4s.middleware.redact +import org.http4s.syntax.literals._ +import org.typelevel.ci.CIStringSyntax +import org.typelevel.otel4s.Attribute +import org.typelevel.otel4s.Attributes + +class TypedServerAttributesTest extends FunSuite { + private[this] def checkOpt[A]( + attr: TypedServerAttributes.type => Option[Attribute[A]], + expected: Option[Attribute[A]], + )(implicit loc: Location): Unit = + assertEquals(attr(TypedServerAttributes), expected) + + private[this] def checkAttr( + attr: TypedServerAttributes.type => Attributes, + expected: Attributes, + )(implicit loc: Location): Unit = + assertEquals(attr(TypedServerAttributes), expected) + + test("clientAddressAndPort") { + def check( + headers: Headers, + remote: Option[SocketAddress[IpAddress]], + expected: Attributes, + )(implicit loc: Location): Unit = { + val conn = remote.map { addr => + Request.Connection( + local = SocketAddress(Ipv4Address.fromBytes(127, 0, 0, 1), Port.fromInt(4321).get), + remote = addr, + secure = true, // ignored + ) + } + val reqNoAttr = Request(headers = headers) + val req = conn + .fold(reqNoAttr)(reqNoAttr.withAttribute(Request.Keys.ConnectionInfo, _)) + checkAttr(_.clientAddressAndPort(req, headers.get[Forwarded]), expected) + } + + val f1 = Header.Raw(ci"Forwarded", "for=10.0.0.5") + val f2 = Header.Raw(ci"Forwarded", "for=\"10.0.0.5:8080\"") + val f3 = Header.Raw(ci"Forwarded", "for=\"[2001:db8:cafe::17]\"") + val f4 = Header.Raw(ci"Forwarded", "for=\"[2001:db8:cafe::17]:4711\"") + val f5 = Header.Raw(ci"Forwarded", "for=10.0.0.5, for=\"[2001:db8:cafe::17]\"") + val f6 = Header.Raw(ci"Forwarded", "for=\"10.0.0.5:8080\", for=\"[2001:db8:cafe::17]\"") + val f7 = Header.Raw(ci"Forwarded", "for=10.0.0.5, for=\"[2001:db8:cafe::17]:4711\"") + val f8 = Header.Raw(ci"Forwarded", "for=\"_example\", for=10.0.0.5") + val f9 = Header.Raw(ci"Forwarded", "for=\"_example\", for=\"10.0.0.5:8080\"") + val f10 = Header.Raw(ci"Forwarded", "by=\"10.0.0.5:8080\"") + val f11 = Header.Raw(ci"Forwarded", "by=192.168.3.3;for=10.0.0.5") + val f12 = Header.Raw(ci"Forwarded", "by=\"192.168.3.3:42\";for=\"10.0.0.5:8080\"") + val xff1 = Header.Raw(ci"X-Forwarded-For", "10.0.0.5") + val xff2 = Header.Raw(ci"X-Forwarded-For", "2001:db8:cafe::17") + val xff3 = Header.Raw(ci"X-Forwarded-For", "10.0.0.5, 2001:db8:cafe::17") + val xff4 = Header.Raw(ci"X-Forwarded-For", "unknown, 10.0.0.5") + val a1 = SocketAddress(Ipv4Address.fromBytes(192, 168, 1, 1), Port.fromInt(1234).get) + val a2 = SocketAddress(Ipv6Address.fromString("bad::cafe").get, Port.fromInt(5678).get) + + check(Headers(f1), None, Attributes(Attribute("client.address", "10.0.0.5"))) + check( + Headers(f2), + None, + Attributes(Attribute("client.address", "10.0.0.5"), Attribute("client.port", 8080L)), + ) + check(Headers(f3), None, Attributes(Attribute("client.address", "2001:db8:cafe::17"))) + check( + Headers(f4), + None, + Attributes(Attribute("client.address", "2001:db8:cafe::17"), Attribute("client.port", 4711L)), + ) + check(Headers(f5), None, Attributes(Attribute("client.address", "10.0.0.5"))) + check( + Headers(f6), + None, + Attributes(Attribute("client.address", "10.0.0.5"), Attribute("client.port", 8080L)), + ) + check(Headers(f7), None, Attributes(Attribute("client.address", "10.0.0.5"))) + check(Headers(f8), None, Attributes.empty) + check(Headers(f8), Some(a1), Attributes.empty) + check(Headers(f9), None, Attributes.empty) + check(Headers(f9), Some(a2), Attributes.empty) + check(Headers(f10), None, Attributes.empty) + check( + Headers(f10), + Some(a1), + Attributes(Attribute("client.address", "192.168.1.1"), Attribute("client.port", 1234L)), + ) + check(Headers(f11), None, Attributes(Attribute("client.address", "10.0.0.5"))) + check( + Headers(f12), + None, + Attributes(Attribute("client.address", "10.0.0.5"), Attribute("client.port", 8080L)), + ) + check(Headers(xff1), None, Attributes(Attribute("client.address", "10.0.0.5"))) + check(Headers(xff2), None, Attributes(Attribute("client.address", "2001:db8:cafe::17"))) + check(Headers(xff3), None, Attributes(Attribute("client.address", "10.0.0.5"))) + check(Headers(xff4), None, Attributes.empty) + check(Headers(xff4), Some(a1), Attributes.empty) + check( + Headers.empty, + Some(a1), + Attributes(Attribute("client.address", "192.168.1.1"), Attribute("client.port", 1234L)), + ) + check( + Headers.empty, + Some(a2), + Attributes(Attribute("client.address", "bad::cafe"), Attribute("client.port", 5678L)), + ) + + // combinations + check(Headers.empty, None, Attributes.empty) + check(Headers(f1, xff2), Some(a1), Attributes(Attribute("client.address", "10.0.0.5"))) + check(Headers(f3, xff1), Some(a2), Attributes(Attribute("client.address", "2001:db8:cafe::17"))) + check(Headers(xff1), Some(a1), Attributes(Attribute("client.address", "10.0.0.5"))) + } + + test("httpRoute") { + val classifier = locally { + val http4sDsl = Http4sDsl[IO] + import http4sDsl._ + RouteClassifier.of[IO] { + case POST -> Root / "users" => "/users" + case (GET | PUT) -> Root / "users" / UUIDVar(_) / "profile" => + "/users/{userId}/profile" + case (HEAD | DELETE) -> Root / "users" / UUIDVar(_) => + "/users/{userId}" + } + } + def check(method: Method, uri: Uri, expected: Option[String]): Unit = + checkOpt( + _.httpRoute(Request(method, uri), classifier), + expected.map(Attribute("http.route", _)), + ) + + check(Method.POST, uri"/users", Some("/users")) + check(Method.POST, uri"/users/295472d0-ef9e-48a7-84bd-100a4672ff87", None) + check(Method.POST, uri"/users/295472d0-ef9e-48a7-84bd-100a4672ff87/profile", None) + check( + Method.GET, + uri"/users/295472d0-ef9e-48a7-84bd-100a4672ff87/profile?foo=bar", + Some("/users/{userId}/profile"), + ) + check(Method.GET, uri"/users/not-a-uuid/profile", None) + check(Method.GET, uri"/users/295472d0-ef9e-48a7-84bd-100a4672ff87", None) + check( + Method.PUT, + uri"/users/295472d0-ef9e-48a7-84bd-100a4672ff87/profile", + Some("/users/{userId}/profile"), + ) + check(Method.PUT, uri"/users/not-a-uuid/profile", None) + check(Method.PUT, uri"/users/295472d0-ef9e-48a7-84bd-100a4672ff87", None) + check(Method.HEAD, uri"/users/295472d0-ef9e-48a7-84bd-100a4672ff87", Some("/users/{userId}")) + check(Method.HEAD, uri"/users/not-a-uuid", None) + check(Method.HEAD, uri"/users/295472d0-ef9e-48a7-84bd-100a4672ff87/profile", None) + check(Method.HEAD, uri"/users", None) + check(Method.DELETE, uri"/users/295472d0-ef9e-48a7-84bd-100a4672ff87", Some("/users/{userId}")) + check(Method.DELETE, uri"/users/not-a-uuid", None) + check(Method.DELETE, uri"/users/295472d0-ef9e-48a7-84bd-100a4672ff87/profile", None) + check(Method.DELETE, uri"/users", None) + } + + test("serverAddressAndPort") { + def check( + headers: Headers, + uri: Uri, + httpVersion: HttpVersion, + scheme: OriginalScheme, + expected: Attributes, + )(implicit loc: Location): Unit = { + val req = Request(headers = headers, uri = uri, httpVersion = httpVersion) + checkAttr(_.serverAddressAndPort(req, headers.get[Forwarded], scheme), expected) + } + + val f1 = Header.Raw(ci"Forwarded", "host=example.com") + val f2 = Header.Raw(ci"Forwarded", "host=\"example.com:8080\"") + val f3 = Header.Raw(ci"Forwarded", "by=\"_example\";host=example.com") + val f4 = Header.Raw(ci"Forwarded", "by=\"_example\";host=\"example.com:8080\"") + val f5 = Header.Raw(ci"Forwarded", "by=\"_example\"") + val xfh1 = Header.Raw(ci"X-Forwarded-Host", "typelevel.org") + val xfh2 = Header.Raw(ci"X-Forwarded-Host", "typelevel.org:4140") + val h1 = Header.Raw(ci"Host", "http4s.org") + val h2 = Header.Raw(ci"Host", "http4s.org:25565") + val u1 = uri"http://opentelemetry.io" + val u2 = uri"https://opentelemetry.io" + val u3 = uri"https://opentelemetry.io:1080" + val os1 = OriginalScheme(None, Headers.empty, Uri()) + val os2 = OriginalScheme(None, Headers.empty, uri"http:") + val os3 = OriginalScheme(None, Headers.empty, uri"https:") + + check( + Headers(f1), + Uri(), + HttpVersion.`HTTP/1.1`, + os1, + Attributes(Attribute("server.address", "example.com")), + ) + check( + Headers(f2), + Uri(), + HttpVersion.`HTTP/1.1`, + os1, + Attributes(Attribute("server.address", "example.com"), Attribute("server.port", 8080L)), + ) + check( + Headers(f3), + Uri(), + HttpVersion.`HTTP/1.1`, + os1, + Attributes(Attribute("server.address", "example.com")), + ) + check( + Headers(f4), + Uri(), + HttpVersion.`HTTP/1.1`, + os1, + Attributes(Attribute("server.address", "example.com"), Attribute("server.port", 8080L)), + ) + check(Headers(f5), Uri(), HttpVersion.`HTTP/1.1`, os1, Attributes.empty) + check( + Headers(xfh1), + Uri(), + HttpVersion.`HTTP/1.1`, + os1, + Attributes(Attribute("server.address", "typelevel.org")), + ) + check( + Headers(xfh2), + Uri(), + HttpVersion.`HTTP/1.1`, + os1, + Attributes(Attribute("server.address", "typelevel.org"), Attribute("server.port", 4140L)), + ) + check( + Headers(h1), + Uri(), + HttpVersion.`HTTP/1.1`, + os1, + Attributes(Attribute("server.address", "http4s.org")), + ) + check( + Headers(h2), + Uri(), + HttpVersion.`HTTP/1.1`, + os1, + Attributes(Attribute("server.address", "http4s.org"), Attribute("server.port", 25565L)), + ) + check( + Headers.empty, + u1, + HttpVersion.`HTTP/2`, + os2, // can't ever get `os1` in actuality, so not testing it + Attributes(Attribute("server.address", "opentelemetry.io"), Attribute("server.port", 80L)), + ) + check( + Headers.empty, + u2, + HttpVersion.`HTTP/2`, + os3, // can't ever get `os1` in actuality, so not testing it + Attributes(Attribute("server.address", "opentelemetry.io"), Attribute("server.port", 443L)), + ) + check( + Headers.empty, + u3, + HttpVersion.`HTTP/2`, + os1, + Attributes(Attribute("server.address", "opentelemetry.io"), Attribute("server.port", 1080L)), + ) + + // combinations + check( + Headers(f1, xfh1, h1), + Uri(), + HttpVersion.`HTTP/1.1`, + os2, + Attributes(Attribute("server.address", "example.com"), Attribute("server.port", 80L)), + ) + check( + Headers(f3, h2), + Uri(), + HttpVersion.`HTTP/2`, + os3, + Attributes(Attribute("server.address", "example.com"), Attribute("server.port", 443L)), + ) + check( + Headers(f2, xfh2), + u1, + HttpVersion.`HTTP/2`, + os1, + Attributes(Attribute("server.address", "example.com"), Attribute("server.port", 8080L)), + ) + check( + Headers(xfh1, h2), + u1, + HttpVersion.`HTTP/1.1`, + os2, + Attributes(Attribute("server.address", "typelevel.org"), Attribute("server.port", 80L)), + ) + check( + Headers(h1), + u3, + HttpVersion.`HTTP/1.1`, + os3, + Attributes(Attribute("server.address", "http4s.org"), Attribute("server.port", 443L)), + ) + check( + Headers(h1), + u3, + HttpVersion.`HTTP/2`, + os3, + Attributes(Attribute("server.address", "opentelemetry.io"), Attribute("server.port", 1080L)), + ) + } + + test("urlPath") { + val redactor = new redact.PathRedactor { + private[this] val allowedSegments = Set("users", "profile") + private[this] val redactedSegment = Uri.Path.Segment(redact.REDACTED) + def redactPath(path: Uri.Path): Uri.Path = + Uri.Path( + segments = path.segments.map { s => + if (allowedSegments.contains(s.encoded)) s else redactedSegment + }, + absolute = path.absolute, + endsWithSlash = path.endsWithSlash, + ) + } + def check(path: Uri.Path, expected: Option[String]): Unit = + checkOpt(_.urlPath(path, redactor), expected.map(Attribute("url.path", _))) + + check(Uri.Path.empty, None) + check(Uri.Path.Root, Some("/")) + check(Uri.Path.Root / "users", Some("/users")) + check(Uri.Path.Root / "users" / "295472d0-ef9e-48a7-84bd-100a4672ff87", Some("/users/REDACTED")) + check( + Uri.Path.Root / "users" / "295472d0-ef9e-48a7-84bd-100a4672ff87" / "profile", + Some("/users/REDACTED/profile"), + ) + } + + test("urlQuery") { + val redactor = new redact.QueryRedactor { + private[this] val forbiddenParams = Set("token") + private[this] val someRedacted = Some(redact.REDACTED) + def redactQuery(query: Query): Query = + Query.fromVector { + query.pairs.map { + case (key, _: Some[_]) if forbiddenParams.contains(key) => + key -> someRedacted + case p => p + } + } + } + def check(query: Query, expected: Option[String]): Unit = + checkOpt(_.urlQuery(query, redactor), expected.map(Attribute("url.query", _))) + + check(Query.empty, None) + check(Query.blank, Some("")) + check( + Query.fromPairs("userId" -> "295472d0-ef9e-48a7-84bd-100a4672ff87"), + Some("userId=295472d0-ef9e-48a7-84bd-100a4672ff87"), + ) + check(Query("token" -> None), Some("token")) + check( + Query("token" -> Some("46b76633577e52635123bd66b5341476cefeb046487ea85d6a7c2a38d3febfc0")), + Some("token=REDACTED"), + ) + check( + Query.fromPairs( + "userId" -> "295472d0-ef9e-48a7-84bd-100a4672ff87", + "token" -> "46b76633577e52635123bd66b5341476cefeb046487ea85d6a7c2a38d3febfc0", + ), + Some("userId=295472d0-ef9e-48a7-84bd-100a4672ff87&token=REDACTED"), + ) + } + + test("urlScheme") { + def check(uri: Uri, expected: Option[String])(implicit loc: Location): Unit = + checkOpt( + _.urlScheme(OriginalScheme(None, Headers.empty, uri)), + expected.map(Attribute("url.scheme", _)), + ) + + check(Uri(), None) + check(uri"http:", Some("http")) + check(uri"https:", Some("https")) + } +}