Skip to content

Commit a88fc8b

Browse files
committed
Updated otel4s to the latest version
1 parent d21407b commit a88fc8b

4 files changed

Lines changed: 172 additions & 86 deletions

File tree

README.md

Lines changed: 35 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -26,32 +26,32 @@ Either:
2626
EmberClientBuilder
2727
.default[IO]
2828
.build
29-
.use: client =>
29+
.use: client =>
3030
given Backend[IO] = Http4sHttpBackend[IO](client)
31-
val fetchClient: FetchClient[IO, StarWars] =
31+
val fetchClient: FetchClient[IO, StarWars] =
3232
Http4sHttpClient.of[IO, StarWars]("https://starwars.com/graphql")
33-
33+
3434
// Scala JVM with JDK WS client behind http4s
3535
import import org.http4s.jdkhttpclient.JdkWSClient
3636

3737
JdkWSClient
3838
.simple[IO]
3939
.use: client =>
4040
given StreamingBackend[IO] = Http4sWebSocketBackend[IO](client)
41-
val streamingClient: StreamingClient[IO, StarWars] =
41+
val streamingClient: StreamingClient[IO, StarWars] =
4242
Http4sWebSocketClient.of[IO, StarWars]("wss://starwars.com/graphql")
4343

4444

4545
// Scala.js with default fetch/WS client
4646
import clue.js.*
4747

4848
given Backend[IO] = AjaxJSBackend[IO]
49-
val fetchClient: FetchClient[IO, StarWars] =
49+
val fetchClient: FetchClient[IO, StarWars] =
5050
FetchJsClient.of[IO, StarWars]("https://starwars.com/graphql")
5151

5252
// Streaming doesn't require Apollo, it just follows the Apollo protocol for GraphQL over WS
5353
given StreamingBackend[IO] = WebSocketJsBackend[IO]
54-
val streamingClient: StreamingClient[IO, StarWars] =
54+
val streamingClient: StreamingClient[IO, StarWars] =
5555
ApolloStreamingClient.of[IO, StarWars]("wss://starwars.com/graphql")
5656
```
5757

@@ -108,4 +108,32 @@ fetchClient.request(CharacterQuery)(CharacterQuery.Variables("0001"))
108108
.forEach(println).unsafeRunSync()
109109

110110
# Data(Some(Character("0001", Some("Luke"))))
111-
```
111+
```
112+
113+
### Tracing with otel4s
114+
115+
The `clue-otel4s` module wraps any `FetchClientWithPars` or `StreamingClient` with OpenTelemetry tracing via [otel4s](https://typelevel.org/otel4s/).
116+
A client-kind span is emitted per request; subscriptions get a single span covering the subscription lifetime.
117+
118+
``` scala
119+
import clue.otel4s.Otel4sMiddleware
120+
import clue.otel4s.http4s.*
121+
import org.typelevel.otel4s.Attribute
122+
import org.typelevel.otel4s.trace.Tracer
123+
124+
given Tracer[IO] = ??? // from your otel4s setup
125+
126+
val traced: IO[FetchClient[IO, StarWars]] =
127+
Http4sHttpClient.of[IO, StarWars]("https://starwars.com/graphql").traced
128+
129+
// Or with additional attributes / SpanBuilder customization:
130+
val tracedWithExtras: IO[FetchClient[IO, StarWars]] =
131+
Http4sHttpClient
132+
.of[IO, StarWars]("https://starwars.com/graphql")
133+
.tracedWith(
134+
spanBuilderMod = _.addAttribute(Attribute("deployment.env", "prod")),
135+
additionalAttributesF = (_, _) => IO.pure(List(Attribute("tenant", "acme")))
136+
)
137+
```
138+
139+
Span attributes recorded automatically: `clue.version`, `http.request.method`, `graphql.operation.type`, `graphql.operation.name`, `graphql.document`, `clue.response.hasData`, and on errors `clue.response.hasErrors`, `clue.response.errorCount`, `clue.response.errors`. Subscriptions add `clue.exitCase`; failed streams set `StatusCode.Error`.

build.sbt

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,11 @@ lazy val otel4s =
129129
.in(file("otel4s"))
130130
.settings(
131131
moduleName := "clue-otel4s",
132-
libraryDependencies ++= Settings.Libraries.Otel4s.value
132+
libraryDependencies ++=
133+
Settings.Libraries.Otel4s.value ++
134+
Settings.Libraries.Otel4sTestkit.value ++
135+
Settings.Libraries.MUnitCatsEffect.value ++
136+
Settings.Libraries.MUnit.value
133137
)
134138
.dependsOn(core)
135139

otel4s/src/main/scala/clue/otel4s/Otel4sMiddleware.scala

Lines changed: 125 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,11 @@ import clue.model.GraphQLResponse
1515
import io.circe.Decoder
1616
import io.circe.JsonObject
1717
import org.typelevel.otel4s.Attribute
18-
import org.typelevel.otel4s.trace.Tracer
18+
import org.typelevel.otel4s.trace.Span
19+
import org.typelevel.otel4s.trace.SpanBuilder
20+
import org.typelevel.otel4s.trace.SpanKind
1921
import org.typelevel.otel4s.trace.StatusCode
22+
import org.typelevel.otel4s.trace.Tracer
2023

2124
object Otel4sMiddleware:
2225
private[otel4s] def extractOperationType(query: String): Option[String] =
@@ -25,90 +28,152 @@ object Otel4sMiddleware:
2528
else if trimmed.startsWith("mutation") then Some("mutation")
2629
else if trimmed.startsWith("subscription") then Some("subscription")
2730
else None
31+
32+
type SpanBuilderMod[F[_]] = SpanBuilder[F] => SpanBuilder[F]
33+
34+
private def identityMod[F[_]]: SpanBuilderMod[F] = identity
35+
36+
private def emptyAttrs[F[_]: Applicative]
37+
: (GraphQLQuery, Option[JsonObject]) => F[List[Attribute[?]]] =
38+
(_, _) => List.empty[Attribute[?]].pure[F]
39+
40+
// Fetch client overloads
41+
def apply[F[_]: Tracer: MonadCancelThrow, P, S](
42+
client: FetchClientWithPars[F, P, S],
43+
spanBuilderMod: SpanBuilderMod[F],
44+
additionalAttributesF: (GraphQLQuery, Option[JsonObject]) => F[List[Attribute[?]]]
45+
): FetchClientWithPars[F, P, S] =
46+
Otel4sFetchClient[F, P, S](client, spanBuilderMod, additionalAttributesF)
47+
2848
def apply[F[_]: Tracer: MonadCancelThrow, P, S](
2949
client: FetchClientWithPars[F, P, S],
3050
additionalAttributesF: (GraphQLQuery, Option[JsonObject]) => F[List[Attribute[?]]]
3151
): FetchClientWithPars[F, P, S] =
32-
Otel4sFetchClient[F, P, S](client, additionalAttributesF)
52+
apply(client, identityMod[F], additionalAttributesF)
3353

3454
def apply[F[_]: Tracer: MonadCancelThrow, P, S](
3555
client: FetchClientWithPars[F, P, S]
3656
): FetchClientWithPars[F, P, S] =
37-
apply(client, (_: GraphQLQuery, _: Option[JsonObject]) => List.empty[Attribute[?]].pure[F])
57+
apply(client, identityMod[F], emptyAttrs[F])
58+
59+
def withAttributes[F[_]: Tracer: MonadCancelThrow, P, S](
60+
client: FetchClientWithPars[F, P, S],
61+
spanBuilderMod: SpanBuilderMod[F]
62+
)(additionalAttributes: Attribute[?]*): FetchClientWithPars[F, P, S] =
63+
apply(client, spanBuilderMod, (_, _) => additionalAttributes.toList.pure[F])
3864

3965
def withAttributes[F[_]: Tracer: MonadCancelThrow, P, S](
4066
client: FetchClientWithPars[F, P, S]
41-
)(
42-
additionalAttributes: Attribute[?]*
43-
): FetchClientWithPars[F, P, S] =
44-
apply(
45-
client,
46-
(_: GraphQLQuery, _: Option[JsonObject]) => additionalAttributes.toList.pure[F]
47-
)
67+
)(additionalAttributes: Attribute[?]*): FetchClientWithPars[F, P, S] =
68+
apply(client, identityMod[F], (_, _) => additionalAttributes.toList.pure[F])
69+
70+
// Streaming client overloads
71+
72+
def apply[F[_]: Tracer: MonadCancelThrow, S](
73+
client: StreamingClient[F, S],
74+
spanBuilderMod: SpanBuilderMod[F],
75+
additionalAttributesF: (GraphQLQuery, Option[JsonObject]) => F[List[Attribute[?]]]
76+
): StreamingClient[F, S] =
77+
Otel4sStreamingClient(client, spanBuilderMod, additionalAttributesF)
4878

4979
def apply[F[_]: Tracer: MonadCancelThrow, S](
5080
client: StreamingClient[F, S],
5181
additionalAttributesF: (GraphQLQuery, Option[JsonObject]) => F[List[Attribute[?]]]
5282
): StreamingClient[F, S] =
53-
Otel4sStreamingClient(client, additionalAttributesF)
83+
apply(client, identityMod[F], additionalAttributesF)
5484

5585
def apply[F[_]: Tracer: MonadCancelThrow, S](
5686
client: StreamingClient[F, S]
5787
): StreamingClient[F, S] =
58-
apply(client, (_: GraphQLQuery, _: Option[JsonObject]) => List.empty[Attribute[?]].pure[F])
88+
apply(client, identityMod[F], emptyAttrs[F])
5989

60-
def withAttributes[F[_]: Tracer: MonadCancelThrow, P, S](
90+
def withAttributes[F[_]: Tracer: MonadCancelThrow, S](
91+
client: StreamingClient[F, S],
92+
spanBuilderMod: SpanBuilderMod[F]
93+
)(additionalAttributes: Attribute[?]*): StreamingClient[F, S] =
94+
apply(client, spanBuilderMod, (_, _) => additionalAttributes.toList.pure[F])
95+
96+
def withAttributes[F[_]: Tracer: MonadCancelThrow, S](
6197
client: StreamingClient[F, S]
6298
)(additionalAttributes: Attribute[?]*): StreamingClient[F, S] =
63-
apply(
64-
client,
65-
(_: GraphQLQuery, _: Option[JsonObject]) => additionalAttributes.toList.pure[F]
99+
apply(client, identityMod[F], (_, _) => additionalAttributes.toList.pure[F])
100+
101+
private[otel4s] def commonAttributes(
102+
document: GraphQLQuery,
103+
operationName: Option[String]
104+
): List[Attribute[?]] =
105+
val base = List[Attribute[?]](
106+
Attribute("clue.version", BuildInfo.version),
107+
Attribute("http.request.method", "POST"),
108+
Attribute("graphql.document", document.value)
66109
)
110+
val opType = extractOperationType(document.value)
111+
.map(t => Attribute("graphql.operation.type", t))
112+
.toList
113+
val opName = operationName.map(n => Attribute("graphql.operation.name", n)).toList
114+
base ++ opType ++ opName
115+
116+
private[otel4s] def recordResponseAttributes[F[_]: Applicative, D](
117+
span: Span[F],
118+
result: GraphQLResponse[D]
119+
): F[Unit] =
120+
span.addAttribute(Attribute("clue.response.hasData", result.data.isDefined)) *>
121+
result.errors.fold(Applicative[F].unit): errs =>
122+
val errorsStr = errs.toList.mkString("[", ", ", "]")
123+
span.addAttributes(
124+
Attribute("clue.response.hasErrors", true),
125+
Attribute("clue.response.errorCount", errs.length.toLong),
126+
Attribute("clue.response.errors", errorsStr)
127+
) *> span.setStatus(StatusCode.Error, "GraphQL request returned errors")
67128

68-
// Extension methods for convenient tracing
69129
object http4s:
130+
import Otel4sMiddleware.SpanBuilderMod
131+
70132
extension [F[_], P, S](client: F[FetchClientWithPars[F, P, S]]) {
71133
@scala.annotation.targetName("tracedFetchClient")
72134
def traced(using
73-
org.typelevel.otel4s.trace.Tracer[F],
74-
cats.effect.MonadCancelThrow[F],
135+
Tracer[F],
136+
MonadCancelThrow[F],
75137
cats.Functor[F]
76138
): F[FetchClientWithPars[F, P, S]] =
77139
client.map(Otel4sMiddleware(_))
78140

79141
@scala.annotation.targetName("tracedWithFetchClient")
80142
def tracedWith(
81-
additionalAttributesF: (clue.model.GraphQLQuery, Option[io.circe.JsonObject]) => F[List[org.typelevel.otel4s.Attribute[?]]]
143+
spanBuilderMod: SpanBuilderMod[F],
144+
additionalAttributesF: (GraphQLQuery, Option[JsonObject]) => F[List[Attribute[?]]]
82145
)(using
83-
org.typelevel.otel4s.trace.Tracer[F],
84-
cats.effect.MonadCancelThrow[F],
146+
Tracer[F],
147+
MonadCancelThrow[F],
85148
cats.Functor[F]
86149
): F[FetchClientWithPars[F, P, S]] =
87-
client.map(Otel4sMiddleware(_, additionalAttributesF))
150+
client.map(Otel4sMiddleware(_, spanBuilderMod, additionalAttributesF))
88151
}
89152

90153
extension [F[_], S](client: F[StreamingClient[F, S]]) {
91154
@scala.annotation.targetName("tracedStreamingClient")
92155
def traced(using
93-
org.typelevel.otel4s.trace.Tracer[F],
94-
cats.effect.MonadCancelThrow[F],
156+
Tracer[F],
157+
MonadCancelThrow[F],
95158
cats.Functor[F]
96159
): F[StreamingClient[F, S]] =
97160
client.map(Otel4sMiddleware(_))
98161

99162
@scala.annotation.targetName("tracedWithStreamingClient")
100163
def tracedWith(
101-
additionalAttributesF: (clue.model.GraphQLQuery, Option[io.circe.JsonObject]) => F[List[org.typelevel.otel4s.Attribute[?]]]
164+
spanBuilderMod: SpanBuilderMod[F],
165+
additionalAttributesF: (GraphQLQuery, Option[JsonObject]) => F[List[Attribute[?]]]
102166
)(using
103-
org.typelevel.otel4s.trace.Tracer[F],
104-
cats.effect.MonadCancelThrow[F],
167+
Tracer[F],
168+
MonadCancelThrow[F],
105169
cats.Functor[F]
106170
): F[StreamingClient[F, S]] =
107-
client.map(Otel4sMiddleware(_, additionalAttributesF))
171+
client.map(Otel4sMiddleware(_, spanBuilderMod, additionalAttributesF))
108172
}
109173

110174
class Otel4sFetchClient[F[_]: Tracer: MonadCancelThrow, P, S](
111175
wrapped: FetchClientWithPars[F, P, S],
176+
spanBuilderMod: Otel4sMiddleware.SpanBuilderMod[F],
112177
additionalAttributesF: (GraphQLQuery, Option[JsonObject]) => F[List[Attribute[?]]]
113178
) extends FetchClientWithPars[F, P, S]:
114179
override protected[clue] def requestInternal[D: Decoder](
@@ -118,63 +183,46 @@ class Otel4sFetchClient[F[_]: Tracer: MonadCancelThrow, P, S](
118183
modParams: P => P
119184
): F[GraphQLResponse[D]] =
120185
MonadCancelThrow[F].uncancelable: poll =>
121-
Tracer[F].span(s"clue-client-request-${document.querySummary}").use: span =>
186+
spanBuilderMod(
187+
Tracer[F]
188+
.spanBuilder(s"clue-client-request-${document.querySummary}")
189+
.withSpanKind(SpanKind.Client)
190+
.addAttributes(Otel4sMiddleware.commonAttributes(document, operationName)*)
191+
).build.use: span =>
122192
for
123-
// Add clue library version
124-
_ <- span.addAttribute(Attribute("clue.version", BuildInfo.version))
125-
// Add standard OpenTelemetry semantic convention attributes
126-
_ <- span.addAttribute(Attribute("http.request.method", "POST"))
127-
_ <- Otel4sMiddleware.extractOperationType(document.value).traverse(opType =>
128-
span.addAttribute(Attribute("graphql.operation.type", opType)))
129-
_ <- operationName.traverse(name => span.addAttribute(Attribute("graphql.operation.name", name)))
130-
_ <- span.addAttribute(Attribute("graphql.document", document.value))
131-
// Add user-provided additional attributes
132-
additionalAttributes <- additionalAttributesF(document, variables)
133-
_ <- additionalAttributes.traverse(span.addAttribute)
134-
result <- poll:
135-
wrapped.requestInternal[D](document, operationName, variables, modParams)
136-
// Add response attributes and handle errors
137-
_ <- span.addAttribute:
138-
Attribute("clue.response.hasData", result.data.isDefined)
139-
_ <- result.errors.fold(Applicative[F].unit): errs =>
140-
for
141-
_ <- span.addAttribute(Attribute("clue.response.hasErrors", true))
142-
_ <- span.addAttribute(Attribute("clue.response.errorCount", errs.length.toLong))
143-
_ <- span.addAttribute(Attribute("clue.response.errors", errs.toList.mkString("[", ", ", "]")))
144-
_ <- span.setStatus(StatusCode.Error, "GraphQL request returned errors")
145-
yield ()
193+
additional <- additionalAttributesF(document, variables)
194+
_ <- span.addAttributes(additional*)
195+
result <- poll(wrapped.requestInternal[D](document, operationName, variables, modParams))
196+
_ <- Otel4sMiddleware.recordResponseAttributes(span, result)
146197
yield result
147198

148199
class Otel4sStreamingClient[F[_]: Tracer: MonadCancelThrow, S](
149200
wrapped: StreamingClient[F, S],
201+
spanBuilderMod: Otel4sMiddleware.SpanBuilderMod[F],
150202
additionalAttributesF: (GraphQLQuery, Option[JsonObject]) => F[List[Attribute[?]]]
151-
) extends Otel4sFetchClient[F, Unit, S](wrapped, additionalAttributesF)
203+
) extends Otel4sFetchClient[F, Unit, S](wrapped, spanBuilderMod, additionalAttributesF)
152204
with StreamingClient[F, S]:
153205
protected[clue] def subscribeInternal[D: Decoder](
154206
document: GraphQLQuery,
155207
operationName: Option[String] = none,
156208
variables: Option[JsonObject] = none
157209
): Resource[F, fs2.Stream[F, GraphQLResponse[D]]] =
158-
val resource: Resource[F, fs2.Stream[F, GraphQLResponse[D]]] =
159-
Resource.applyFull: poll =>
160-
Tracer[F].span(s"clue-client-start-${document.querySummary}").use: span =>
161-
for
162-
// Add clue library version
163-
_ <- span.addAttribute(Attribute("clue.version", BuildInfo.version))
164-
// Add standard OpenTelemetry semantic convention attributes
165-
_ <- span.addAttribute(Attribute("http.request.method", "POST"))
166-
_ <- Otel4sMiddleware.extractOperationType(document.value).traverse(opType =>
167-
span.addAttribute(Attribute("graphql.operation.type", opType)))
168-
_ <- operationName.traverse(name => span.addAttribute(Attribute("graphql.operation.name", name)))
169-
_ <- span.addAttribute(Attribute("graphql.document", document.value))
170-
// Add user-provided additional attributes
171-
additionalAttributes <- additionalAttributesF(document, variables)
172-
_ <- additionalAttributes.traverse(span.addAttribute)
173-
result <- poll:
174-
wrapped
175-
.subscribeInternal[D](document, operationName, variables)
176-
.allocatedCase
177-
yield result
178-
resource.onFinalizeCase: exitCase =>
179-
Tracer[F].span(s"clue-client-end-${document.querySummary}").use: span =>
180-
span.addAttribute(Attribute("clue.exitCase", exitCase.toOutcome.toString))
210+
for
211+
res <- spanBuilderMod(
212+
Tracer[F]
213+
.spanBuilder(s"clue-client-subscribe-${document.querySummary}")
214+
.withSpanKind(SpanKind.Client)
215+
.addAttributes(Otel4sMiddleware.commonAttributes(document, operationName)*)
216+
).build.resource
217+
span = res.span
218+
_ <- Resource.eval:
219+
additionalAttributesF(document, variables).flatMap: attrs =>
220+
span.addAttributes(attrs*)
221+
stream <- wrapped.subscribeInternal[D](document, operationName, variables)
222+
yield stream.onFinalizeCase: exitCase =>
223+
span.addAttribute(Attribute("clue.exitCase", exitCase.toOutcome.toString)) *>
224+
(exitCase match
225+
case Resource.ExitCase.Errored(e) =>
226+
span.setStatus(StatusCode.Error, Option(e.getMessage).getOrElse(e.toString))
227+
case _ => Applicative[F].unit
228+
)

project/Settings.scala

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ object Settings {
2121
val munit = "1.1.1"
2222
val munitCatsEffect = "2.1.0"
2323
val natchez = "0.3.8"
24-
val otel4s = "0.13.1"
24+
val otel4s = "0.16.0"
2525
val http4sOtel4sMiddleware = "0.12.0"
2626
val scalaFix = scalafix.sbt.BuildInfo.scalafixVersion
2727
val scalaJsDom = "2.8.1"
@@ -162,6 +162,12 @@ object Settings {
162162
)
163163
)
164164

165+
val Otel4sTestkit = Def.setting(
166+
Seq(
167+
"org.typelevel" %%% "otel4s-sdk-testkit" % otel4s % "test"
168+
)
169+
)
170+
165171
val Http4sOtel4sMiddleware = Def.setting(
166172
Seq(
167173
"org.http4s" %%% "http4s-otel4s-middleware-trace-client" % http4sOtel4sMiddleware

0 commit comments

Comments
 (0)