From 1da09869725a1b2671db22d599e9ce7c69f4cea9 Mon Sep 17 00:00:00 2001 From: Carlos Quiroz Date: Tue, 28 Jul 2026 18:22:26 -0400 Subject: [PATCH 01/14] Support adding a descriptor for tracing purposes --- build.sbt | 4 +- .../src/main/scala/clue/FetchClientImpl.scala | 3 +- core/src/main/scala/clue/clients.scala | 54 ++++++++++++--- .../scala/clue/websocket/ApolloClient.scala | 6 +- .../scala/test/StarWarsDescriptorQuery.scala | 26 +++++++ .../scala/test/StarWarsDescriptorQuery.scala | 53 +++++++++++++++ .../src/main/scala/clue/gen/GraphQLGen.scala | 4 +- .../scala/clue/gen/GraphQLGenConfig.scala | 3 +- .../src/main/scala/clue/gen/QueryGen.scala | 24 +++++-- .../main/scala/clue/model/GraphQLQuery.scala | 26 +++++-- .../scala/clue/model/GraphQLQuerySpec.scala | 67 +++++++++++++++++++ .../scala/clue/otel4s/Otel4sMiddleware.scala | 41 +++++++++--- .../clue/otel4s/Otel4sMiddlewareSpec.scala | 35 ++++++++++ 13 files changed, 306 insertions(+), 40 deletions(-) create mode 100644 gen/input/src/main/scala/test/StarWarsDescriptorQuery.scala create mode 100644 gen/output/src/main/scala/test/StarWarsDescriptorQuery.scala create mode 100644 model/src/test/scala/clue/model/GraphQLQuerySpec.scala create mode 100644 otel4s/src/test/scala/clue/otel4s/Otel4sMiddlewareSpec.scala diff --git a/build.sbt b/build.sbt index e7602861..bb8984de 100644 --- a/build.sbt +++ b/build.sbt @@ -117,7 +117,9 @@ lazy val otel4s = .in(file("otel4s")) .settings( moduleName := "clue-otel4s", - libraryDependencies ++= Settings.Libraries.Otel4s.value + libraryDependencies ++= + Settings.Libraries.Otel4s.value ++ + Settings.Libraries.MUnit.value ) .dependsOn(core) diff --git a/core/src/main/scala/clue/FetchClientImpl.scala b/core/src/main/scala/clue/FetchClientImpl.scala index 31539cbd..632b3d72 100644 --- a/core/src/main/scala/clue/FetchClientImpl.scala +++ b/core/src/main/scala/clue/FetchClientImpl.scala @@ -27,7 +27,8 @@ class FetchClientImpl[F[_]: MonadThrow: Logger, P, S](requestParams: P)(using operationName: Option[String], variables: Option[JsonObject], extensions: Option[JsonObject], - modParams: P => P = identity + modParams: P => P = identity, + descriptor: Option[String] = none ): F[GraphQLResponse[D]] = backend .request( diff --git a/core/src/main/scala/clue/clients.scala b/core/src/main/scala/clue/clients.scala index 45e71a54..f11064e6 100644 --- a/core/src/main/scala/clue/clients.scala +++ b/core/src/main/scala/clue/clients.scala @@ -31,7 +31,8 @@ trait FetchClientWithPars[F[_], P, S] { operationName: Option[String] = none, variables: Option[JsonObject] = none, extensions: Option[JsonObject] = none, - modParams: P => P = identity + modParams: P => P = identity, + descriptor: Option[String] = none ): F[GraphQLResponse[D]] } @@ -44,8 +45,14 @@ case class RequestApplied[ ] protected[clue] ( client: FetchClientWithPars[F, P, S], operation: GraphQLOperation[S], - operationName: Option[String] + operationName: Option[String], + descriptor: Option[String] = none ) { + + /** Attaches a tracing-only display name (see `clue.descriptor`). Does not affect execution. */ + def withDescriptor(descriptor: String): RequestApplied[F, P, S, V, D] = + copy(descriptor = descriptor.some) + def withInput(variables: V): F[GraphQLResponse[D]] = withInput(variables, identity) @@ -55,14 +62,29 @@ case class RequestApplied[ operationName, variables.asJsonObject.some, none, - modParams + modParams, + descriptor ) def withModParams(modParams: P => P): F[GraphQLResponse[D]] = - client.requestInternal(GraphQLQuery(operation.document), operationName, none, none, modParams) + client.requestInternal( + GraphQLQuery(operation.document), + operationName, + none, + none, + modParams, + descriptor + ) def apply: F[GraphQLResponse[D]] = - client.requestInternal(GraphQLQuery(operation.document), operationName, none, none, identity) + client.requestInternal( + GraphQLQuery(operation.document), + operationName, + none, + none, + identity, + descriptor + ) } object RequestApplied { @@ -94,7 +116,8 @@ trait StreamingClient[F[_], S] extends FetchClientWithPars[F, Unit, S] { document: GraphQLQuery, operationName: Option[String] = none, variables: Option[JsonObject] = none, - extensions: Option[JsonObject] = none + extensions: Option[JsonObject] = none, + descriptor: Option[String] = none ): Resource[F, fs2.Stream[F, GraphQLResponse[D]]] } @@ -106,18 +129,31 @@ case class SubscriptionApplied[ ] protected[clue] ( client: StreamingClient[F, S], subscription: GraphQLOperation[S], - operationName: Option[String] = none + operationName: Option[String] = none, + descriptor: Option[String] = none ) { + + /** Attaches a tracing-only display name (see `clue.descriptor`). Does not affect execution. */ + def withDescriptor(descriptor: String): SubscriptionApplied[F, S, V, D] = + copy(descriptor = descriptor.some) + def withInput(variables: V): Resource[F, fs2.Stream[F, GraphQLResponse[D]]] = client.subscribeInternal( GraphQLQuery(subscription.document), operationName, variables.asJsonObject.some, - none + none, + descriptor ) def apply: Resource[F, fs2.Stream[F, GraphQLResponse[D]]] = - client.subscribeInternal(GraphQLQuery(subscription.document), operationName, none, none) + client.subscribeInternal( + GraphQLQuery(subscription.document), + operationName, + none, + none, + descriptor + ) } object SubscriptionApplied { diff --git a/core/src/main/scala/clue/websocket/ApolloClient.scala b/core/src/main/scala/clue/websocket/ApolloClient.scala index 82ef18fd..20d04db7 100644 --- a/core/src/main/scala/clue/websocket/ApolloClient.scala +++ b/core/src/main/scala/clue/websocket/ApolloClient.scala @@ -116,7 +116,8 @@ class ApolloClient[F[_], P, S]( subscription: GraphQLQuery, operationName: Option[String], variables: Option[JsonObject], - extensions: Option[JsonObject] + extensions: Option[JsonObject], + descriptor: Option[String] = none ): Resource[F, fs2.Stream[F, GraphQLResponse[D]]] = subscriptionResource(subscription, operationName, variables, extensions) @@ -126,7 +127,8 @@ class ApolloClient[F[_], P, S]( operationName: Option[String], variables: Option[JsonObject], extensions: Option[JsonObject], - modParams: Unit => Unit // This is ignored here. + modParams: Unit => Unit, // This is ignored here. + descriptor: Option[String] = none ): F[GraphQLResponse[D]] = F.async(cb => startSubscription[D](document, operationName, variables, extensions) diff --git a/gen/input/src/main/scala/test/StarWarsDescriptorQuery.scala b/gen/input/src/main/scala/test/StarWarsDescriptorQuery.scala new file mode 100644 index 00000000..5335c857 --- /dev/null +++ b/gen/input/src/main/scala/test/StarWarsDescriptorQuery.scala @@ -0,0 +1,26 @@ +// Copyright (c) 2016-2025 Association of Universities for Research in Astronomy, Inc. (AURA) +// For license information see LICENSE or https://opensource.org/licenses/BSD-3-Clause + +// format: off +/* + rules = [GraphQLGen] + Clue.schemaDirs = ["gen/input/src/main/resources/graphql/schemas"] + Clue.descriptor = true + */ +package test + +import clue.GraphQLOperation +import clue.annotation.GraphQL + +@GraphQL +trait StarWarsDescriptorQuery extends GraphQLOperation[StarWars] { + override val document: String = """ + query ($charId: ID!) { + character(id: $charId) { + id + name + } + } + """ +} +// format: on diff --git a/gen/output/src/main/scala/test/StarWarsDescriptorQuery.scala b/gen/output/src/main/scala/test/StarWarsDescriptorQuery.scala new file mode 100644 index 00000000..2cbc843f --- /dev/null +++ b/gen/output/src/main/scala/test/StarWarsDescriptorQuery.scala @@ -0,0 +1,53 @@ +// Copyright (c) 2016-2025 Association of Universities for Research in Astronomy, Inc. (AURA) +// For license information see LICENSE or https://opensource.org/licenses/BSD-3-Clause + +// format: off + +package test + +import clue.GraphQLOperation + + +object StarWarsDescriptorQuery extends GraphQLOperation[StarWars] { + import StarWars.Scalars._ + ignoreUnusedImportScalars() + import StarWars.Enums._ + ignoreUnusedImportEnums() + import StarWars.Types._ + ignoreUnusedImportTypes() + override val document: String = """ + query ($charId: ID!) { + character(id: $charId) { + id + name + } + } + """ + case class Variables(val charId: String) + object Variables { + val charId: monocle.Iso[Variables, String] = monocle.Focus[Variables](_.charId) + implicit val eqVariables: cats.Eq[Variables] = cats.Eq.fromUniversalEquals + implicit val showVariables: cats.Show[Variables] = cats.Show.fromToString + implicit val jsonEncoderVariables: io.circe.Encoder.AsObject[Variables] = io.circe.generic.semiauto.deriveEncoder[Variables].mapJsonObject(clue.data.Input.dropIgnores) + } + case class Data(val character: Option[Data.Character] = None) + object Data { + case class Character(val id: String, val name: Option[String] = None) + object Character { + val id: monocle.Lens[Data.Character, String] = monocle.macros.GenLens[Data.Character](_.id) + val name: monocle.Lens[Data.Character, Option[String]] = monocle.macros.GenLens[Data.Character](_.name) + implicit val eqCharacter: cats.Eq[Data.Character] = cats.Eq.fromUniversalEquals + implicit val showCharacter: cats.Show[Data.Character] = cats.Show.fromToString + implicit val jsonDecoderCharacter: io.circe.Decoder[Data.Character] = io.circe.generic.semiauto.deriveDecoder[Data.Character] + } + val character: monocle.Iso[Data, Option[Data.Character]] = monocle.Focus[Data](_.character) + implicit val eqData: cats.Eq[Data] = cats.Eq.fromUniversalEquals + implicit val showData: cats.Show[Data] = cats.Show.fromToString + implicit val jsonDecoderData: io.circe.Decoder[Data] = io.circe.generic.semiauto.deriveDecoder[Data] + } + val varEncoder: io.circe.Encoder.AsObject[Variables] = Variables.jsonEncoderVariables + val dataDecoder: io.circe.Decoder[Data] = Data.jsonDecoderData + def apply[F[_]]: clue.ClientAppliedF[F, StarWars, ClientAppliedFP] = new clue.ClientAppliedF[F, StarWars, ClientAppliedFP] { def applyP[P](client: clue.FetchClientWithPars[F, P, StarWars]) = new ClientAppliedFP(client) } + class ClientAppliedFP[F[_], P](val client: clue.FetchClientWithPars[F, P, StarWars]) { def query(charId: String, modParams: P => P = identity) = client.request(StarWarsDescriptorQuery).withDescriptor("StarWarsDescriptorQuery").withInput(Variables(charId), modParams) } +} +// format: on diff --git a/gen/rules/src/main/scala/clue/gen/GraphQLGen.scala b/gen/rules/src/main/scala/clue/gen/GraphQLGen.scala index 17132a33..bf0ddd76 100644 --- a/gen/rules/src/main/scala/clue/gen/GraphQLGen.scala +++ b/gen/rules/src/main/scala/clue/gen/GraphQLGen.scala @@ -172,7 +172,7 @@ class GraphQLGen(val config: GraphQLGenConfig) addData(schema, operation, config, document.subqueries, fragments), addVarEncoder, addDataDecoder, - addConvenienceMethod(schemaType, operation, objName) + addConvenienceMethod(schemaType, operation, objName, config) ) ) @@ -275,7 +275,7 @@ class GraphQLGen(val config: GraphQLGenConfig) schema.types.find(_.name == rootTypeName) ), addDataDecoder, - addConvenienceMethod(schemaType, operation, objName) + addConvenienceMethod(schemaType, operation, objName, config) ) ) diff --git a/gen/rules/src/main/scala/clue/gen/GraphQLGenConfig.scala b/gen/rules/src/main/scala/clue/gen/GraphQLGenConfig.scala index e30565b5..b7a4723b 100644 --- a/gen/rules/src/main/scala/clue/gen/GraphQLGenConfig.scala +++ b/gen/rules/src/main/scala/clue/gen/GraphQLGenConfig.scala @@ -25,7 +25,8 @@ final case class GraphQLGenConfig( monocleLenses: Boolean = true, scalaJsReactReuse: Boolean = false, circeEncoder: Boolean = true, - circeDecoder: Boolean = true + circeDecoder: Boolean = true, + descriptor: Boolean = false ) { // We memoize the [[Result]] of loading each schema. The Result carries everything: a failure // (missing or unparseable schema), warnings (a schema that parses with problems), or success. diff --git a/gen/rules/src/main/scala/clue/gen/QueryGen.scala b/gen/rules/src/main/scala/clue/gen/QueryGen.scala index 6873c392..4d5fac05 100644 --- a/gen/rules/src/main/scala/clue/gen/QueryGen.scala +++ b/gen/rules/src/main/scala/clue/gen/QueryGen.scala @@ -772,7 +772,8 @@ trait QueryGen extends Generator { protected def addConvenienceMethod( schemaType: Type, operation: UntypedOperation, - objName: String + objName: String, + config: GraphQLGenConfig ): List[Stat] => List[Stat] = parentBody => parentBody @@ -805,6 +806,19 @@ trait QueryGen extends Generator { new clue.ClientAppliedF[F, $schemaType, ClientAppliedFP] { def applyP[P](client: clue.FetchClientWithPars[F, P, $schemaType]) = new ClientAppliedFP(client) }""" + // When descriptor generation is enabled, tag the request/subscription with the + // object name so otel4s can name the span `clue--`. + val opName = Term.Name(objName) + val afterRequest = + if (config.descriptor) + q"client.request($opName).withDescriptor(${Lit.String(objName)})" + else + q"client.request($opName)" + val afterSubscribe = + if (config.descriptor) + q"client.subscribe(this).withDescriptor(${Lit.String(objName)})" + else + q"client.subscribe(this)" parentBody ++ (operation match { case _: UntypedQuery => @@ -812,8 +826,7 @@ trait QueryGen extends Generator { applied, q"""class ClientAppliedFP[F[_], P](val client: clue.FetchClientWithPars[F, P, $schemaType]) { def query(...${(paramss.head :+ param"modParams: P => P = identity") +: paramss.tail}) = - client.request(${Term - .Name(objName)}).withInput(Variables(...$variablesNames), modParams) + $afterRequest.withInput(Variables(...$variablesNames), modParams) } """ ) @@ -823,8 +836,7 @@ trait QueryGen extends Generator { applied, q"""class ClientAppliedFP[F[_], P](val client: clue.FetchClientWithPars[F, P, $schemaType]) { def execute(...${(paramss.head :+ param"modParams: P => P = identity") +: paramss.tail}) = - client.request(${Term - .Name(objName)}).withInput(Variables(...$variablesNames), modParams) + $afterRequest.withInput(Variables(...$variablesNames), modParams) } """ ) @@ -837,7 +849,7 @@ trait QueryGen extends Generator { default = none ) List( - q"def subscribe[F[_]](...${paramss :+ List(clientParam)}) = client.subscribe(this).withInput(Variables(...$variablesNames))" + q"def subscribe[F[_]](...${paramss :+ List(clientParam)}) = $afterSubscribe.withInput(Variables(...$variablesNames))" ) }) } diff --git a/model/src/main/scala/clue/model/GraphQLQuery.scala b/model/src/main/scala/clue/model/GraphQLQuery.scala index 19023a6f..754a0b97 100644 --- a/model/src/main/scala/clue/model/GraphQLQuery.scala +++ b/model/src/main/scala/clue/model/GraphQLQuery.scala @@ -4,24 +4,36 @@ package clue.model import cats.Eq -import cats.syntax.option.* opaque type GraphQLQuery = String object GraphQLQuery: def apply(query: String): GraphQLQuery = query - private val QueryTypeAndName = "(\\w+).*\\{(?:.|\\s)*?(\\w+)".r.unanchored + // The operation type keyword at the start of the document (query/mutation/subscription). + private val OperationType = """^\s*(\w+)""".r + + // An explicitly-named operation + private val NamedOperation = """^\s*\w+\s+(\w+)(?=\s*[({])""".r + + // Fallback for anonymous operations: the first word after the first '{'. + private val FirstField = """\{(?:.|\s)*?(\w+)""".r private def queryTypeAndName(query: GraphQLQuery): Option[(String, String)] = - query match - case QueryTypeAndName(queryType, queryName) => (queryType, queryName).some - case _ => none + val doc = query.trim + val tpe = OperationType.findFirstMatchIn(doc).map(_.group(1)) + val name = + NamedOperation + .findFirstMatchIn(doc) + .map(_.group(1)) + .orElse(FirstField.findFirstMatchIn(doc).map(_.group(1))) + tpe.map((_, name.getOrElse(""))) extension (query: GraphQLQuery) def value: String = query def querySummary: String = - val typeAndName: Option[(String, String)] = queryTypeAndName(query) - s"${typeAndName.map(_._1).getOrElse("")}-${typeAndName.map(_._2).getOrElse("")}" + queryTypeAndName(query) match + case Some((tpe, name)) => s"$tpe-$name" + case None => "-" inline given Eq[GraphQLQuery] = Eq.catsKernelInstancesForString diff --git a/model/src/test/scala/clue/model/GraphQLQuerySpec.scala b/model/src/test/scala/clue/model/GraphQLQuerySpec.scala new file mode 100644 index 00000000..2420a0fa --- /dev/null +++ b/model/src/test/scala/clue/model/GraphQLQuerySpec.scala @@ -0,0 +1,67 @@ +// Copyright (c) 2016-2025 Association of Universities for Research in Astronomy, Inc. (AURA) +// For license information see LICENSE or https://opensource.org/licenses/BSD-3-Clause + +package clue.model + +import munit.FunSuite + +final class GraphQLQuerySpec extends FunSuite: + + // A document as it appears in generated code: triple-quoted, with leading + // newline and indentation before the operation keyword. + private def check(document: String, expectedSummary: String): Unit = + assertEquals(GraphQLQuery(document).querySummary, expectedSummary) + + test("querySummary uses the named operation when present") { + check( + """ + query Program { + program(programId: "p-2") { id } + } + """, + "query-Program" + ) + } + + test("querySummary uses the named operation even with variable definitions") { + check( + """ + query ObservationVisits($obsId: ObservationId!) { + observation(observationId: $obsId) { id } + } + """, + "query-ObservationVisits" + ) + } + + test("querySummary falls back to the first root field for an anonymous query with vars") { + check( + """ + query ($charId: ID!) { + character(id: $charId) { id } + } + """, + "query-character" + ) + } + + test("querySummary falls back to the first root field for an anonymous query without vars") { + check("query { character { id } }", "query-character") + } + + test("querySummary handles a named mutation") { + check("mutation AddFoo($x: ID!) { addFoo(id: $x) { id } }", "mutation-AddFoo") + } + + test("querySummary falls back to the first root field for an anonymous mutation") { + check("mutation { addFoo { id } }", "mutation-addFoo") + } + + test("querySummary handles a named subscription") { + check("subscription Sub { x }", "subscription-Sub") + } + + test("querySummary is robust to a name immediately followed by the selection set") { + check("query Program{ program { id } }", "query-Program") + } +end GraphQLQuerySpec diff --git a/otel4s/src/main/scala/clue/otel4s/Otel4sMiddleware.scala b/otel4s/src/main/scala/clue/otel4s/Otel4sMiddleware.scala index 1d68a30e..b3c42d62 100644 --- a/otel4s/src/main/scala/clue/otel4s/Otel4sMiddleware.scala +++ b/otel4s/src/main/scala/clue/otel4s/Otel4sMiddleware.scala @@ -88,7 +88,8 @@ object Otel4sMiddleware: private[otel4s] def commonAttributes( document: GraphQLQuery, - operationName: Option[String] + operationName: Option[String], + descriptor: Option[String] ): List[Attribute[?]] = val base = List( Attribute("clue.version", BuildInfo.version), @@ -103,7 +104,9 @@ object Otel4sMiddleware: .map(n => GraphqlExperimentalAttributes.GraphqlOperationName(n)) .toList - base ++ opType ++ opName + val descr = descriptor.map(d => Attribute("clue.descriptor", d)).toList + + base ++ opType ++ opName ++ descr private[otel4s] def responseAttributes[F[_]: Applicative as F, D]( span: Span[F], @@ -128,11 +131,12 @@ class Otel4sFetchClient[F[_]: {MonadCancelThrow, Tracer as T}, P: TraceHeaderInj protected def traceSpan( operation: String, document: GraphQLQuery, - operationName: Option[String] + operationName: Option[String], + descriptor: Option[String] ) = spanMod( - T.spanBuilder(s"clue-$operation-${document.querySummary}") + T.spanBuilder(s"clue-$operation-${descriptor.getOrElse(document.querySummary)}") .withSpanKind(SpanKind.Client) - .addAttributes(Otel4sMiddleware.commonAttributes(document, operationName)*) + .addAttributes(Otel4sMiddleware.commonAttributes(document, operationName, descriptor)*) ) // Merge existing extensions with otel trace parent headers. @@ -148,10 +152,11 @@ class Otel4sFetchClient[F[_]: {MonadCancelThrow, Tracer as T}, P: TraceHeaderInj operationName: Option[String], variables: Option[JsonObject], extensions: Option[JsonObject], - modParams: P => P + modParams: P => P, + descriptor: Option[String] = none ): F[GraphQLResponse[D]] = MonadCancelThrow[F].uncancelable: poll => - traceSpan("request", document, operationName) + traceSpan("request", document, operationName, descriptor) .addAttribute(HttpAttributes.HttpRequestMethod("POST")) .build .use: span => @@ -165,7 +170,14 @@ class Otel4sFetchClient[F[_]: {MonadCancelThrow, Tracer as T}, P: TraceHeaderInj result <- poll( wrapped - .requestInternal[D](document, operationName, variables, mergedExt, modWithTrace) + .requestInternal[D]( + document, + operationName, + variables, + mergedExt, + modWithTrace, + descriptor + ) ) _ <- Otel4sMiddleware.responseAttributes(span, result) yield result @@ -180,17 +192,24 @@ class Otel4sStreamingClient[F[_]: {Concurrent, Tracer as T}, S]( document: GraphQLQuery, operationName: Option[String] = none, variables: Option[JsonObject] = none, - extensions: Option[JsonObject] = none + extensions: Option[JsonObject] = none, + descriptor: Option[String] = none ): Resource[F, fs2.Stream[F, GraphQLResponse[D]]] = for - res <- traceSpan("subscribe", document, operationName).build.resource + res <- traceSpan("subscribe", document, operationName, descriptor).build.resource span = res.span _ <- Resource.eval: additionalAttributesF(document, variables).flatMap: attrs => span.addAttributes(attrs*) traceHeaders <- Resource.eval(T.propagate(Map.empty)) mergedExt = mergeOtelExtension(extensions, traceHeaders) - stream <- wrapped.subscribeInternal[D](document, operationName, variables, mergedExt) + stream <- wrapped.subscribeInternal[D]( + document, + operationName, + variables, + mergedExt, + descriptor + ) yield stream.onFinalizeCase: exitCase => span.addAttribute(Attribute("clue.exitCase", exitCase.toOutcome.toString)) *> (exitCase match diff --git a/otel4s/src/test/scala/clue/otel4s/Otel4sMiddlewareSpec.scala b/otel4s/src/test/scala/clue/otel4s/Otel4sMiddlewareSpec.scala new file mode 100644 index 00000000..dd2cb1a4 --- /dev/null +++ b/otel4s/src/test/scala/clue/otel4s/Otel4sMiddlewareSpec.scala @@ -0,0 +1,35 @@ +// Copyright (c) 2016-2025 Association of Universities for Research in Astronomy, Inc. (AURA) +// For license information see LICENSE or https://opensource.org/licenses/BSD-3-Clause + +package clue.otel4s + +import clue.model.GraphQLQuery +import munit.FunSuite + +class Otel4sMiddlewareSpec extends FunSuite: + + // Anonymous document: descriptor is the only way to name it well. + private val doc = GraphQLQuery( + "query ObservationVisits($id: ID!) { observation(id: $id) { id } }" + ) + + test("commonAttributes emits clue.descriptor when a descriptor is set") { + val attrs = Otel4sMiddleware.commonAttributes(doc, None, Some("ObservationVisits")) + val descriptor = attrs.find(_.key.name == "clue.descriptor") + assert(descriptor.isDefined, "expected a clue.descriptor attribute") + assertEquals(descriptor.get.value: Any, "ObservationVisits") + } + + test("commonAttributes omits clue.descriptor when no descriptor is set") { + val attrs = Otel4sMiddleware.commonAttributes(doc, None, None) + assert(!attrs.exists(_.key.name == "clue.descriptor"), + "did not expect a clue.descriptor attribute" + ) + } + + test("commonAttributes still emits the graphql document regardless of descriptor") { + val attrs = Otel4sMiddleware.commonAttributes(doc, None, Some("X")) + assert(attrs.exists(_.key.name == "graphql.document"), "expected a graphql.document attribute") + } + +end Otel4sMiddlewareSpec From fcc126586f82c745bdacde2781c8b19c763b3cdb Mon Sep 17 00:00:00 2001 From: Carlos Quiroz Date: Tue, 28 Jul 2026 20:44:16 -0400 Subject: [PATCH 02/14] Fix steward job --- build.sbt | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/build.sbt b/build.sbt index bb8984de..45f506b5 100644 --- a/build.sbt +++ b/build.sbt @@ -8,6 +8,32 @@ ThisBuild / crossScalaVersions := Seq("3.8.4") ThisBuild / githubWorkflowScalaVersions := Seq("3.8.4") Global / onChangedBuildSource := ReloadOnSourceChanges +// sbt-typelevel-ci hardcodes Java 11 (both the job's `javas` matrix and the baked-in +// `matrix.java == 'temurin@11'` cond on its Setup Java step) for its auto-added +// "validate-steward" job, but the scala-steward binary that coursier/setup-action +// installs is now built for a newer JVM (class file version 61 = Java 17), so that job +// fails with UnsupportedClassVersionError. Rebuild the job on Java 17 until the plugin +// catches up. +ThisBuild / githubWorkflowAddedJobs ~= { jobs => + jobs.map { job => + if (job.id == "validate-steward") + WorkflowJob( + "validate-steward", + "Validate Steward Config", + WorkflowStep.Checkout :: + WorkflowStep.SetupJava(List(JavaSpec.temurin("17")), false) ::: + WorkflowStep.Use( + UseRef.Public("coursier", "setup-action", "v1"), + Map("apps" -> "scala-steward") + ) :: + WorkflowStep.Run(List("scala-steward validate-repo-config .scala-steward.conf")) :: Nil, + scalas = List.empty, + javas = List(JavaSpec.temurin("17")) + ) + else job + } +} + lazy val root = tlCrossRootProject .aggregate( model, From 7417b7914a04721fd3732643791c4f7b3c849385 Mon Sep 17 00:00:00 2001 From: Carlos Quiroz Date: Tue, 28 Jul 2026 20:54:12 -0400 Subject: [PATCH 03/14] scalafmt --- build.sbt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.sbt b/build.sbt index 45f506b5..00eb2387 100644 --- a/build.sbt +++ b/build.sbt @@ -28,7 +28,7 @@ ThisBuild / githubWorkflowAddedJobs ~= { jobs => ) :: WorkflowStep.Run(List("scala-steward validate-repo-config .scala-steward.conf")) :: Nil, scalas = List.empty, - javas = List(JavaSpec.temurin("17")) + javas = List(JavaSpec.temurin("17")) ) else job } From cd24c6186204b44fd91f673336f562c6a5ab3fab Mon Sep 17 00:00:00 2001 From: Carlos Quiroz Date: Tue, 28 Jul 2026 22:59:43 -0400 Subject: [PATCH 04/14] Bump binary version --- build.sbt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.sbt b/build.sbt index 00eb2387..1c14c1bd 100644 --- a/build.sbt +++ b/build.sbt @@ -1,6 +1,6 @@ lazy val V = _root_.scalafix.sbt.BuildInfo -ThisBuild / tlBaseVersion := "0.55" +ThisBuild / tlBaseVersion := "0.56" ThisBuild / tlJdkRelease := Some(17) ThisBuild / githubWorkflowJavaVersions := Seq("25", "17").map(JavaSpec.temurin(_)) ThisBuild / scalaVersion := "3.8.4" From 8c0d95d81183e82343681f721ed6c6635f137d65 Mon Sep 17 00:00:00 2001 From: Carlos Quiroz Date: Wed, 29 Jul 2026 11:01:33 -0400 Subject: [PATCH 05/14] fix WS method tag and set default descriptor --- .../scala/test/StarWarsDescriptorQuery.scala | 3 +- .../src/main/scala/test/LucumaQuery.scala | 2 +- .../src/main/scala/test/LucumaQuery2.scala | 2 +- .../src/main/scala/test/LucumaQuery3.scala | 2 +- .../scala/test/StarWarsDescriptorQuery.scala | 2 +- .../src/main/scala/test/StarWarsInclude.scala | 2 +- .../src/main/scala/test/StarWarsQuery.scala | 2 +- .../src/main/scala/test/StarWarsQuery2.scala | 2 +- .../src/main/scala/test/StarWarsQuery3.scala | 2 +- .../src/main/scala/test/StarWarsQuery4.scala | 2 +- .../scala/clue/gen/GraphQLGenConfig.scala | 2 +- .../scala/clue/otel4s/Otel4sMiddleware.scala | 12 ++- .../clue/otel4s/Otel4sRequestSpanSpec.scala | 83 +++++++++++++++++++ 13 files changed, 106 insertions(+), 12 deletions(-) create mode 100644 otel4s/src/test/scala/clue/otel4s/Otel4sRequestSpanSpec.scala diff --git a/gen/input/src/main/scala/test/StarWarsDescriptorQuery.scala b/gen/input/src/main/scala/test/StarWarsDescriptorQuery.scala index 5335c857..522fb467 100644 --- a/gen/input/src/main/scala/test/StarWarsDescriptorQuery.scala +++ b/gen/input/src/main/scala/test/StarWarsDescriptorQuery.scala @@ -5,7 +5,8 @@ /* rules = [GraphQLGen] Clue.schemaDirs = ["gen/input/src/main/resources/graphql/schemas"] - Clue.descriptor = true + // Opt-out fixture: the default is `true` + Clue.descriptor = false */ package test diff --git a/gen/output/src/main/scala/test/LucumaQuery.scala b/gen/output/src/main/scala/test/LucumaQuery.scala index 35a994b6..7e079e65 100644 --- a/gen/output/src/main/scala/test/LucumaQuery.scala +++ b/gen/output/src/main/scala/test/LucumaQuery.scala @@ -99,6 +99,6 @@ object LucumaQuery extends GraphQLOperation[LucumaODB] { val varEncoder: io.circe.Encoder.AsObject[Variables] = Variables.jsonEncoderVariables val dataDecoder: io.circe.Decoder[Data] = Data.jsonDecoderData def apply[F[_]]: clue.ClientAppliedF[F, LucumaODB, ClientAppliedFP] = new clue.ClientAppliedF[F, LucumaODB, ClientAppliedFP] { def applyP[P](client: clue.FetchClientWithPars[F, P, LucumaODB]) = new ClientAppliedFP(client) } - class ClientAppliedFP[F[_], P](val client: clue.FetchClientWithPars[F, P, LucumaODB]) { def query(modParams: P => P = identity) = client.request(LucumaQuery).withInput(Variables(), modParams) } + class ClientAppliedFP[F[_], P](val client: clue.FetchClientWithPars[F, P, LucumaODB]) { def query(modParams: P => P = identity) = client.request(LucumaQuery).withDescriptor("LucumaQuery").withInput(Variables(), modParams) } } // format: on diff --git a/gen/output/src/main/scala/test/LucumaQuery2.scala b/gen/output/src/main/scala/test/LucumaQuery2.scala index e727f375..9925e542 100644 --- a/gen/output/src/main/scala/test/LucumaQuery2.scala +++ b/gen/output/src/main/scala/test/LucumaQuery2.scala @@ -81,6 +81,6 @@ object LucumaQuery2 extends GraphQLOperation[LucumaODB] { val varEncoder: io.circe.Encoder.AsObject[Variables] = Variables.jsonEncoderVariables val dataDecoder: io.circe.Decoder[Data] = Data.jsonDecoderData def apply[F[_]]: clue.ClientAppliedF[F, LucumaODB, ClientAppliedFP] = new clue.ClientAppliedF[F, LucumaODB, ClientAppliedFP] { def applyP[P](client: clue.FetchClientWithPars[F, P, LucumaODB]) = new ClientAppliedFP(client) } - class ClientAppliedFP[F[_], P](val client: clue.FetchClientWithPars[F, P, LucumaODB]) { def query(modParams: P => P = identity) = client.request(LucumaQuery2).withInput(Variables(), modParams) } + class ClientAppliedFP[F[_], P](val client: clue.FetchClientWithPars[F, P, LucumaODB]) { def query(modParams: P => P = identity) = client.request(LucumaQuery2).withDescriptor("LucumaQuery2").withInput(Variables(), modParams) } } // format: on diff --git a/gen/output/src/main/scala/test/LucumaQuery3.scala b/gen/output/src/main/scala/test/LucumaQuery3.scala index bb41a140..c6bc17c2 100644 --- a/gen/output/src/main/scala/test/LucumaQuery3.scala +++ b/gen/output/src/main/scala/test/LucumaQuery3.scala @@ -88,6 +88,6 @@ object LucumaQuery3 extends GraphQLOperation[LucumaODB] { val varEncoder: io.circe.Encoder.AsObject[Variables] = Variables.jsonEncoderVariables val dataDecoder: io.circe.Decoder[Data] = Data.jsonDecoderData def apply[F[_]]: clue.ClientAppliedF[F, LucumaODB, ClientAppliedFP] = new clue.ClientAppliedF[F, LucumaODB, ClientAppliedFP] { def applyP[P](client: clue.FetchClientWithPars[F, P, LucumaODB]) = new ClientAppliedFP(client) } - class ClientAppliedFP[F[_], P](val client: clue.FetchClientWithPars[F, P, LucumaODB]) { def query(modParams: P => P = identity) = client.request(LucumaQuery3).withInput(Variables(), modParams) } + class ClientAppliedFP[F[_], P](val client: clue.FetchClientWithPars[F, P, LucumaODB]) { def query(modParams: P => P = identity) = client.request(LucumaQuery3).withDescriptor("LucumaQuery3").withInput(Variables(), modParams) } } // format: on diff --git a/gen/output/src/main/scala/test/StarWarsDescriptorQuery.scala b/gen/output/src/main/scala/test/StarWarsDescriptorQuery.scala index 2cbc843f..5b8876e1 100644 --- a/gen/output/src/main/scala/test/StarWarsDescriptorQuery.scala +++ b/gen/output/src/main/scala/test/StarWarsDescriptorQuery.scala @@ -48,6 +48,6 @@ object StarWarsDescriptorQuery extends GraphQLOperation[StarWars] { val varEncoder: io.circe.Encoder.AsObject[Variables] = Variables.jsonEncoderVariables val dataDecoder: io.circe.Decoder[Data] = Data.jsonDecoderData def apply[F[_]]: clue.ClientAppliedF[F, StarWars, ClientAppliedFP] = new clue.ClientAppliedF[F, StarWars, ClientAppliedFP] { def applyP[P](client: clue.FetchClientWithPars[F, P, StarWars]) = new ClientAppliedFP(client) } - class ClientAppliedFP[F[_], P](val client: clue.FetchClientWithPars[F, P, StarWars]) { def query(charId: String, modParams: P => P = identity) = client.request(StarWarsDescriptorQuery).withDescriptor("StarWarsDescriptorQuery").withInput(Variables(charId), modParams) } + class ClientAppliedFP[F[_], P](val client: clue.FetchClientWithPars[F, P, StarWars]) { def query(charId: String, modParams: P => P = identity) = client.request(StarWarsDescriptorQuery).withInput(Variables(charId), modParams) } } // format: on diff --git a/gen/output/src/main/scala/test/StarWarsInclude.scala b/gen/output/src/main/scala/test/StarWarsInclude.scala index 435cd755..e6b92494 100644 --- a/gen/output/src/main/scala/test/StarWarsInclude.scala +++ b/gen/output/src/main/scala/test/StarWarsInclude.scala @@ -52,6 +52,6 @@ object StarWarsInclude extends GraphQLOperation[StarWars] { val varEncoder: io.circe.Encoder.AsObject[Variables] = Variables.jsonEncoderVariables val dataDecoder: io.circe.Decoder[Data] = Data.jsonDecoderData def apply[F[_]]: clue.ClientAppliedF[F, StarWars, ClientAppliedFP] = new clue.ClientAppliedF[F, StarWars, ClientAppliedFP] { def applyP[P](client: clue.FetchClientWithPars[F, P, StarWars]) = new ClientAppliedFP(client) } - class ClientAppliedFP[F[_], P](val client: clue.FetchClientWithPars[F, P, StarWars]) { def query(humanId: String, skipId: Boolean, withName: Boolean, modParams: P => P = identity) = client.request(StarWarsInclude).withInput(Variables(humanId, skipId, withName), modParams) } + class ClientAppliedFP[F[_], P](val client: clue.FetchClientWithPars[F, P, StarWars]) { def query(humanId: String, skipId: Boolean, withName: Boolean, modParams: P => P = identity) = client.request(StarWarsInclude).withDescriptor("StarWarsInclude").withInput(Variables(humanId, skipId, withName), modParams) } } // format: on diff --git a/gen/output/src/main/scala/test/StarWarsQuery.scala b/gen/output/src/main/scala/test/StarWarsQuery.scala index 792919da..c5031b01 100644 --- a/gen/output/src/main/scala/test/StarWarsQuery.scala +++ b/gen/output/src/main/scala/test/StarWarsQuery.scala @@ -112,6 +112,6 @@ object StarWarsQuery extends GraphQLOperation[StarWars] { val varEncoder: io.circe.Encoder.AsObject[Variables] = Variables.jsonEncoderVariables val dataDecoder: io.circe.Decoder[Data] = Data.jsonDecoderData def apply[F[_]]: clue.ClientAppliedF[F, StarWars, ClientAppliedFP] = new clue.ClientAppliedF[F, StarWars, ClientAppliedFP] { def applyP[P](client: clue.FetchClientWithPars[F, P, StarWars]) = new ClientAppliedFP(client) } - class ClientAppliedFP[F[_], P](val client: clue.FetchClientWithPars[F, P, StarWars]) { def query(charId: String, modParams: P => P = identity) = client.request(StarWarsQuery).withInput(Variables(charId), modParams) } + class ClientAppliedFP[F[_], P](val client: clue.FetchClientWithPars[F, P, StarWars]) { def query(charId: String, modParams: P => P = identity) = client.request(StarWarsQuery).withDescriptor("StarWarsQuery").withInput(Variables(charId), modParams) } } // format: on diff --git a/gen/output/src/main/scala/test/StarWarsQuery2.scala b/gen/output/src/main/scala/test/StarWarsQuery2.scala index 351337a6..3a5d851f 100644 --- a/gen/output/src/main/scala/test/StarWarsQuery2.scala +++ b/gen/output/src/main/scala/test/StarWarsQuery2.scala @@ -132,7 +132,7 @@ object Wrapper extends Something { val varEncoder: io.circe.Encoder.AsObject[Variables] = Variables.jsonEncoderVariables val dataDecoder: io.circe.Decoder[Data] = Data.jsonDecoderData def apply[F[_]]: clue.ClientAppliedF[F, StarWars, ClientAppliedFP] = new clue.ClientAppliedF[F, StarWars, ClientAppliedFP] { def applyP[P](client: clue.FetchClientWithPars[F, P, StarWars]) = new ClientAppliedFP(client) } - class ClientAppliedFP[F[_], P](val client: clue.FetchClientWithPars[F, P, StarWars]) { def query(charId: String, modParams: P => P = identity) = client.request(StarWarsQuery2).withInput(Variables(charId), modParams) } + class ClientAppliedFP[F[_], P](val client: clue.FetchClientWithPars[F, P, StarWars]) { def query(charId: String, modParams: P => P = identity) = client.request(StarWarsQuery2).withDescriptor("StarWarsQuery2").withInput(Variables(charId), modParams) } } } // format: on diff --git a/gen/output/src/main/scala/test/StarWarsQuery3.scala b/gen/output/src/main/scala/test/StarWarsQuery3.scala index 649c9bdd..a7909876 100644 --- a/gen/output/src/main/scala/test/StarWarsQuery3.scala +++ b/gen/output/src/main/scala/test/StarWarsQuery3.scala @@ -103,6 +103,6 @@ object StarWarsQuery3 extends GraphQLOperation[StarWars] { val varEncoder: io.circe.Encoder.AsObject[Variables] = Variables.jsonEncoderVariables val dataDecoder: io.circe.Decoder[Data] = Data.jsonDecoderData def apply[F[_]]: clue.ClientAppliedF[F, StarWars, ClientAppliedFP] = new clue.ClientAppliedF[F, StarWars, ClientAppliedFP] { def applyP[P](client: clue.FetchClientWithPars[F, P, StarWars]) = new ClientAppliedFP(client) } - class ClientAppliedFP[F[_], P](val client: clue.FetchClientWithPars[F, P, StarWars]) { def query(charId: String, modParams: P => P = identity) = client.request(StarWarsQuery3).withInput(Variables(charId), modParams) } + class ClientAppliedFP[F[_], P](val client: clue.FetchClientWithPars[F, P, StarWars]) { def query(charId: String, modParams: P => P = identity) = client.request(StarWarsQuery3).withDescriptor("StarWarsQuery3").withInput(Variables(charId), modParams) } } // format: on diff --git a/gen/output/src/main/scala/test/StarWarsQuery4.scala b/gen/output/src/main/scala/test/StarWarsQuery4.scala index 15a31dda..b081e796 100644 --- a/gen/output/src/main/scala/test/StarWarsQuery4.scala +++ b/gen/output/src/main/scala/test/StarWarsQuery4.scala @@ -37,6 +37,6 @@ object StarWarsQuery4 extends GraphQLOperation[StarWars] { val varEncoder: io.circe.Encoder.AsObject[Variables] = Variables.jsonEncoderVariables val dataDecoder: io.circe.Decoder[Data] = Data.jsonDecoderData def apply[F[_]]: clue.ClientAppliedF[F, StarWars, ClientAppliedFP] = new clue.ClientAppliedF[F, StarWars, ClientAppliedFP] { def applyP[P](client: clue.FetchClientWithPars[F, P, StarWars]) = new ClientAppliedFP(client) } - class ClientAppliedFP[F[_], P](val client: clue.FetchClientWithPars[F, P, StarWars]) { def query(charId: String, modParams: P => P = identity) = client.request(StarWarsQuery4).withInput(Variables(charId), modParams) } + class ClientAppliedFP[F[_], P](val client: clue.FetchClientWithPars[F, P, StarWars]) { def query(charId: String, modParams: P => P = identity) = client.request(StarWarsQuery4).withDescriptor("StarWarsQuery4").withInput(Variables(charId), modParams) } } // format: on diff --git a/gen/rules/src/main/scala/clue/gen/GraphQLGenConfig.scala b/gen/rules/src/main/scala/clue/gen/GraphQLGenConfig.scala index b7a4723b..448f59b6 100644 --- a/gen/rules/src/main/scala/clue/gen/GraphQLGenConfig.scala +++ b/gen/rules/src/main/scala/clue/gen/GraphQLGenConfig.scala @@ -26,7 +26,7 @@ final case class GraphQLGenConfig( scalaJsReactReuse: Boolean = false, circeEncoder: Boolean = true, circeDecoder: Boolean = true, - descriptor: Boolean = false + descriptor: Boolean = true ) { // We memoize the [[Result]] of loading each schema. The Result carries everything: a failure // (missing or unparseable schema), warnings (a schema that parses with problems), or success. diff --git a/otel4s/src/main/scala/clue/otel4s/Otel4sMiddleware.scala b/otel4s/src/main/scala/clue/otel4s/Otel4sMiddleware.scala index b3c42d62..012d9700 100644 --- a/otel4s/src/main/scala/clue/otel4s/Otel4sMiddleware.scala +++ b/otel4s/src/main/scala/clue/otel4s/Otel4sMiddleware.scala @@ -147,6 +147,13 @@ class Otel4sFetchClient[F[_]: {MonadCancelThrow, Tracer as T}, P: TraceHeaderInj val traceExt = JsonObject.fromMap(traceHeaders.map((k, v) => k -> Json.fromString(v))) extensions.map(_.deepMerge(traceExt)).orElse(Some(traceExt)).filterNot(_.isEmpty) + /** + * Transport-specific attributes applied to the `request` span. The HTTP fetch client reports + * `http.request.method: POST`. + */ + protected[otel4s] def requestTransportAttributes: List[Attribute[?]] = + List(HttpAttributes.HttpRequestMethod("POST")) + override protected[clue] def requestInternal[D: Decoder]( document: GraphQLQuery, operationName: Option[String], @@ -157,7 +164,7 @@ class Otel4sFetchClient[F[_]: {MonadCancelThrow, Tracer as T}, P: TraceHeaderInj ): F[GraphQLResponse[D]] = MonadCancelThrow[F].uncancelable: poll => traceSpan("request", document, operationName, descriptor) - .addAttribute(HttpAttributes.HttpRequestMethod("POST")) + .addAttributes(requestTransportAttributes*) .build .use: span => for @@ -188,6 +195,9 @@ class Otel4sStreamingClient[F[_]: {Concurrent, Tracer as T}, S]( additionalAttributesF: (GraphQLQuery, Option[JsonObject]) => F[List[Attribute[?]]] ) extends Otel4sFetchClient[F, Unit, S](wrapped, spanMod, additionalAttributesF) with StreamingClient[F, S]: + // Streaming clients run over a persistent transport (typically WebSocket), not HTTP + override protected[otel4s] def requestTransportAttributes: List[Attribute[?]] = Nil + protected[clue] def subscribeInternal[D: Decoder]( document: GraphQLQuery, operationName: Option[String] = none, diff --git a/otel4s/src/test/scala/clue/otel4s/Otel4sRequestSpanSpec.scala b/otel4s/src/test/scala/clue/otel4s/Otel4sRequestSpanSpec.scala new file mode 100644 index 00000000..aa99844c --- /dev/null +++ b/otel4s/src/test/scala/clue/otel4s/Otel4sRequestSpanSpec.scala @@ -0,0 +1,83 @@ +// Copyright (c) 2016-2025 Association of Universities for Research in Astronomy, Inc. (AURA) +// For license information see LICENSE or https://opensource.org/licenses/BSD-3-Clause + +package clue.otel4s + +import cats.effect.IO +import cats.effect.Resource +import clue.FetchClientWithPars +import clue.StreamingClient +import clue.model.GraphQLQuery +import clue.model.GraphQLResponse +import fs2.Stream +import io.circe.Decoder +import io.circe.JsonObject +import munit.FunSuite +import org.typelevel.otel4s.trace.Tracer + +/** + * Regression coverage for the transport-specific attributes on the `request` span. + * + * `Otel4sStreamingClient extends Otel4sFetchClient`, so it used to inherit `requestInternal` + * wholesale — including the hardcoded `http.request.method: POST` attribute. That mislabelled + * queries/mutations sent as WebSocket `Subscribe` frames as HTTP POST. These tests pin the fix: + * the HTTP client reports the method, the streaming client does not. + * + * Only the pure attribute lists are inspected, so a noop tracer suffices and no request is ever + * actually run (the wrapped clients' methods are never called). + */ +class Otel4sRequestSpanSpec extends FunSuite: + + given Tracer[IO] = Tracer.noop[IO] + + private def noMod: Otel4sMiddleware.SpanMod[IO] = identity + + private def noAttrs: (GraphQLQuery, Option[JsonObject]) => IO[List[Nothing]] = + (_, _) => IO.pure(Nil) + + private def stubFetch: FetchClientWithPars[IO, Unit, Unit] = + new FetchClientWithPars[IO, Unit, Unit]: + protected[clue] def requestInternal[D: Decoder]( + document: GraphQLQuery, + operationName: Option[String], + variables: Option[JsonObject], + extensions: Option[JsonObject], + modParams: Unit => Unit, + descriptor: Option[String] + ): IO[GraphQLResponse[D]] = + IO.never + + private def stubStream: StreamingClient[IO, Unit] = + new StreamingClient[IO, Unit]: + protected[clue] def requestInternal[D: Decoder]( + document: GraphQLQuery, + operationName: Option[String], + variables: Option[JsonObject], + extensions: Option[JsonObject], + modParams: Unit => Unit, + descriptor: Option[String] + ): IO[GraphQLResponse[D]] = + IO.never + protected[clue] def subscribeInternal[D: Decoder]( + document: GraphQLQuery, + operationName: Option[String], + variables: Option[JsonObject], + extensions: Option[JsonObject], + descriptor: Option[String] + ): Resource[IO, Stream[IO, GraphQLResponse[D]]] = + Resource.eval(IO.never) + + private def httpMethodKeys[S](client: Otel4sFetchClient[IO, Unit, S]): List[String] = + client.requestTransportAttributes.map(_.key.name) + + test("the HTTP fetch client tags requests with http.request.method: POST"): + val client = new Otel4sFetchClient[IO, Unit, Unit](stubFetch, noMod, noAttrs) + assert(httpMethodKeys(client).contains("http.request.method")) + + test("the streaming (WebSocket) client does NOT tag requests with http.request.method"): + // Regression: Otel4sStreamingClient extends Otel4sFetchClient and used to inherit the POST + // attribute, mislabeling WebSocket Subscribe frames as HTTP POST. + val client = new Otel4sStreamingClient[IO, Unit](stubStream, noMod, noAttrs) + assert(!httpMethodKeys(client).contains("http.request.method")) + +end Otel4sRequestSpanSpec From 4d9b57c132aff272d21f8f471c842b1330fedb27 Mon Sep 17 00:00:00 2001 From: Carlos Quiroz Date: Wed, 29 Jul 2026 12:41:55 -0400 Subject: [PATCH 06/14] scalafmt --- .../src/test/scala/clue/otel4s/Otel4sRequestSpanSpec.scala | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/otel4s/src/test/scala/clue/otel4s/Otel4sRequestSpanSpec.scala b/otel4s/src/test/scala/clue/otel4s/Otel4sRequestSpanSpec.scala index aa99844c..b6e80ae2 100644 --- a/otel4s/src/test/scala/clue/otel4s/Otel4sRequestSpanSpec.scala +++ b/otel4s/src/test/scala/clue/otel4s/Otel4sRequestSpanSpec.scala @@ -20,8 +20,8 @@ import org.typelevel.otel4s.trace.Tracer * * `Otel4sStreamingClient extends Otel4sFetchClient`, so it used to inherit `requestInternal` * wholesale — including the hardcoded `http.request.method: POST` attribute. That mislabelled - * queries/mutations sent as WebSocket `Subscribe` frames as HTTP POST. These tests pin the fix: - * the HTTP client reports the method, the streaming client does not. + * queries/mutations sent as WebSocket `Subscribe` frames as HTTP POST. These tests pin the fix: the + * HTTP client reports the method, the streaming client does not. * * Only the pure attribute lists are inspected, so a noop tracer suffices and no request is ever * actually run (the wrapped clients' methods are never called). @@ -56,7 +56,7 @@ class Otel4sRequestSpanSpec extends FunSuite: extensions: Option[JsonObject], modParams: Unit => Unit, descriptor: Option[String] - ): IO[GraphQLResponse[D]] = + ): IO[GraphQLResponse[D]] = IO.never protected[clue] def subscribeInternal[D: Decoder]( document: GraphQLQuery, From c84d58241a20d6ee488264464f92d8ead7d324c8 Mon Sep 17 00:00:00 2001 From: Carlos Quiroz Date: Wed, 29 Jul 2026 15:08:00 -0400 Subject: [PATCH 07/14] Fix querySummary parsing and unify operation type detection --- .../main/scala/clue/model/GraphQLQuery.scala | 47 ++++++++----- .../scala/clue/model/GraphQLQuerySpec.scala | 70 +++++++++++++++++++ .../scala/clue/otel4s/Otel4sMiddleware.scala | 12 +--- 3 files changed, 102 insertions(+), 27 deletions(-) diff --git a/model/src/main/scala/clue/model/GraphQLQuery.scala b/model/src/main/scala/clue/model/GraphQLQuery.scala index 754a0b97..575c25d3 100644 --- a/model/src/main/scala/clue/model/GraphQLQuery.scala +++ b/model/src/main/scala/clue/model/GraphQLQuery.scala @@ -10,30 +10,43 @@ opaque type GraphQLQuery = String object GraphQLQuery: def apply(query: String): GraphQLQuery = query - // The operation type keyword at the start of the document (query/mutation/subscription). - private val OperationType = """^\s*(\w+)""".r + // The operation type keyword, matched at the start of a line so that leading comments and + // fragment definitions are skipped. + private val OperationType = """(?m)^[ \t]*(query|mutation|subscription)\b""".r - // An explicitly-named operation - private val NamedOperation = """^\s*\w+\s+(\w+)(?=\s*[({])""".r + // The name of an explicitly named operation: the word right after the operation type keyword, + // followed by variable definitions, a directive or the selection set. + private val NamedOperation = """^(?:query|mutation|subscription)\s+(\w+)(?=\s*[({@])""".r // Fallback for anonymous operations: the first word after the first '{'. private val FirstField = """\{(?:.|\s)*?(\w+)""".r - private def queryTypeAndName(query: GraphQLQuery): Option[(String, String)] = - val doc = query.trim - val tpe = OperationType.findFirstMatchIn(doc).map(_.group(1)) - val name = - NamedOperation - .findFirstMatchIn(doc) - .map(_.group(1)) - .orElse(FirstField.findFirstMatchIn(doc).map(_.group(1))) - tpe.map((_, name.getOrElse(""))) + // The operation type, paired with the document from the operation keyword onwards, so that + // anything preceding it (comments, fragment definitions) cannot be mistaken for the operation. + // A document that is a bare selection set is an anonymous query per the spec. + private def operation(query: GraphQLQuery): Option[(String, String)] = + OperationType + .findFirstMatchIn(query) + .map(m => (m.group(1), query.substring(m.start(1)))) + .orElse: + val trimmed = query.trim + Option.when(trimmed.startsWith("{"))(("query", trimmed)) extension (query: GraphQLQuery) - def value: String = query + def value: String = query + + /** The operation type keyword: `query`, `mutation` or `subscription`. */ + def operationType: Option[String] = operation(query).map(_._1) + def querySummary: String = - queryTypeAndName(query) match - case Some((tpe, name)) => s"$tpe-$name" - case None => "-" + operation(query) match + case Some((opType, doc)) => + val name = + NamedOperation + .findPrefixMatchOf(doc) + .map(_.group(1)) + .orElse(FirstField.findFirstMatchIn(doc).map(_.group(1))) + s"$opType-${name.getOrElse("")}" + case None => "-" inline given Eq[GraphQLQuery] = Eq.catsKernelInstancesForString diff --git a/model/src/test/scala/clue/model/GraphQLQuerySpec.scala b/model/src/test/scala/clue/model/GraphQLQuerySpec.scala index 2420a0fa..337c2912 100644 --- a/model/src/test/scala/clue/model/GraphQLQuerySpec.scala +++ b/model/src/test/scala/clue/model/GraphQLQuerySpec.scala @@ -3,6 +3,7 @@ package clue.model +import cats.syntax.option.* import munit.FunSuite final class GraphQLQuerySpec extends FunSuite: @@ -64,4 +65,73 @@ final class GraphQLQuerySpec extends FunSuite: test("querySummary is robust to a name immediately followed by the selection set") { check("query Program{ program { id } }", "query-Program") } + + test("querySummary skips leading comments") { + check( + """ + # Everything we need about a program. + query Program { + program(programId: "p-2") { id } + } + """, + "query-Program" + ) + } + + test("querySummary skips leading fragment definitions") { + check( + """ + fragment programFields on Program { + id + name + } + + query Program { + program(programId: "p-2") { ...programFields } + } + """, + "query-Program" + ) + } + + test("querySummary picks the operation's root field, not a leading fragment's") { + check( + """ + fragment programFields on Program { + id + } + + query { + program(programId: "p-2") { ...programFields } + } + """, + "query-program" + ) + } + + test("querySummary handles a directive between the name and the selection set") { + check("query Program @cached { program { id } }", "query-Program") + } + + test("querySummary treats a bare selection set as an anonymous query") { + check("{ character { id } }", "query-character") + } + + test("querySummary reports both parts as unknown when nothing parses") { + check("not a graphql document", "-") + } + + test("operationType reports the keyword") { + assertEquals(GraphQLQuery("query Program { program }").operationType, "query".some) + assertEquals(GraphQLQuery("mutation AddFoo { addFoo }").operationType, "mutation".some) + assertEquals(GraphQLQuery("subscription Sub { x }").operationType, "subscription".some) + } + + test("operationType defaults a bare selection set to query") { + assertEquals(GraphQLQuery("{ character { id } }").operationType, "query".some) + } + + test("operationType is empty when no operation can be found") { + assertEquals(GraphQLQuery("not a graphql document").operationType, none) + } end GraphQLQuerySpec diff --git a/otel4s/src/main/scala/clue/otel4s/Otel4sMiddleware.scala b/otel4s/src/main/scala/clue/otel4s/Otel4sMiddleware.scala index 012d9700..6163070d 100644 --- a/otel4s/src/main/scala/clue/otel4s/Otel4sMiddleware.scala +++ b/otel4s/src/main/scala/clue/otel4s/Otel4sMiddleware.scala @@ -24,7 +24,6 @@ import org.typelevel.otel4s.Attribute import org.typelevel.otel4s.context.propagation.TextMapUpdater import org.typelevel.otel4s.semconv.attributes.HttpAttributes import org.typelevel.otel4s.semconv.experimental.attributes.GraphqlExperimentalAttributes -import org.typelevel.otel4s.semconv.experimental.attributes.GraphqlExperimentalAttributes.GraphqlOperationTypeValue import org.typelevel.otel4s.trace.Span import org.typelevel.otel4s.trace.SpanBuilder import org.typelevel.otel4s.trace.SpanKind @@ -33,13 +32,6 @@ import org.typelevel.otel4s.trace.Tracer object Otel4sMiddleware: - private[otel4s] def extractOperation(query: String): Option[GraphqlOperationTypeValue] = - val trimmed = query.trim.toLowerCase - if trimmed.startsWith("query") then GraphqlOperationTypeValue.Query.some - else if trimmed.startsWith("mutation") then GraphqlOperationTypeValue.Mutation.some - else if trimmed.startsWith("subscription") then GraphqlOperationTypeValue.Subscription.some - else none - private[otel4s] type SpanMod[F[_]] = SpanBuilder[F] => SpanBuilder[F] private def identityMod[F[_]]: SpanMod[F] = identity @@ -96,8 +88,8 @@ object Otel4sMiddleware: GraphqlExperimentalAttributes.GraphqlDocument(document.value) ) - val opType = extractOperation(document.value) - .map(t => GraphqlExperimentalAttributes.GraphqlOperationType(t.value)) + val opType = document.operationType + .map(GraphqlExperimentalAttributes.GraphqlOperationType(_)) .toList val opName = operationName From b7b56c6fdcd3fb8c4e65eb33226d6be43b11973b Mon Sep 17 00:00:00 2001 From: Carlos Quiroz Date: Wed, 29 Jul 2026 15:22:37 -0400 Subject: [PATCH 08/14] Drop redundant default args on overrides, clarify ignored params --- core/src/main/scala/clue/FetchClientImpl.scala | 4 ++-- core/src/main/scala/clue/websocket/ApolloClient.scala | 6 +++--- gen/rules/src/main/scala/clue/gen/QueryGen.scala | 6 +++--- .../src/main/scala/clue/otel4s/Otel4sMiddleware.scala | 10 +++++----- 4 files changed, 13 insertions(+), 13 deletions(-) diff --git a/core/src/main/scala/clue/FetchClientImpl.scala b/core/src/main/scala/clue/FetchClientImpl.scala index 632b3d72..33b07ba7 100644 --- a/core/src/main/scala/clue/FetchClientImpl.scala +++ b/core/src/main/scala/clue/FetchClientImpl.scala @@ -27,8 +27,8 @@ class FetchClientImpl[F[_]: MonadThrow: Logger, P, S](requestParams: P)(using operationName: Option[String], variables: Option[JsonObject], extensions: Option[JsonObject], - modParams: P => P = identity, - descriptor: Option[String] = none + modParams: P => P, + descriptor: Option[String] // This is ignored here. ): F[GraphQLResponse[D]] = backend .request( diff --git a/core/src/main/scala/clue/websocket/ApolloClient.scala b/core/src/main/scala/clue/websocket/ApolloClient.scala index 20d04db7..4512dd4b 100644 --- a/core/src/main/scala/clue/websocket/ApolloClient.scala +++ b/core/src/main/scala/clue/websocket/ApolloClient.scala @@ -117,7 +117,7 @@ class ApolloClient[F[_], P, S]( operationName: Option[String], variables: Option[JsonObject], extensions: Option[JsonObject], - descriptor: Option[String] = none + descriptor: Option[String] // This is ignored here. ): Resource[F, fs2.Stream[F, GraphQLResponse[D]]] = subscriptionResource(subscription, operationName, variables, extensions) @@ -127,8 +127,8 @@ class ApolloClient[F[_], P, S]( operationName: Option[String], variables: Option[JsonObject], extensions: Option[JsonObject], - modParams: Unit => Unit, // This is ignored here. - descriptor: Option[String] = none + modParams: Unit => Unit, // This is ignored here. + descriptor: Option[String] // This is ignored here. ): F[GraphQLResponse[D]] = F.async(cb => startSubscription[D](document, operationName, variables, extensions) diff --git a/gen/rules/src/main/scala/clue/gen/QueryGen.scala b/gen/rules/src/main/scala/clue/gen/QueryGen.scala index 4d5fac05..791f0930 100644 --- a/gen/rules/src/main/scala/clue/gen/QueryGen.scala +++ b/gen/rules/src/main/scala/clue/gen/QueryGen.scala @@ -808,12 +808,12 @@ trait QueryGen extends Generator { }""" // When descriptor generation is enabled, tag the request/subscription with the // object name so otel4s can name the span `clue--`. - val opName = Term.Name(objName) + val objTerm = Term.Name(objName) val afterRequest = if (config.descriptor) - q"client.request($opName).withDescriptor(${Lit.String(objName)})" + q"client.request($objTerm).withDescriptor(${Lit.String(objName)})" else - q"client.request($opName)" + q"client.request($objTerm)" val afterSubscribe = if (config.descriptor) q"client.subscribe(this).withDescriptor(${Lit.String(objName)})" diff --git a/otel4s/src/main/scala/clue/otel4s/Otel4sMiddleware.scala b/otel4s/src/main/scala/clue/otel4s/Otel4sMiddleware.scala index 6163070d..da025f10 100644 --- a/otel4s/src/main/scala/clue/otel4s/Otel4sMiddleware.scala +++ b/otel4s/src/main/scala/clue/otel4s/Otel4sMiddleware.scala @@ -152,7 +152,7 @@ class Otel4sFetchClient[F[_]: {MonadCancelThrow, Tracer as T}, P: TraceHeaderInj variables: Option[JsonObject], extensions: Option[JsonObject], modParams: P => P, - descriptor: Option[String] = none + descriptor: Option[String] ): F[GraphQLResponse[D]] = MonadCancelThrow[F].uncancelable: poll => traceSpan("request", document, operationName, descriptor) @@ -192,10 +192,10 @@ class Otel4sStreamingClient[F[_]: {Concurrent, Tracer as T}, S]( protected[clue] def subscribeInternal[D: Decoder]( document: GraphQLQuery, - operationName: Option[String] = none, - variables: Option[JsonObject] = none, - extensions: Option[JsonObject] = none, - descriptor: Option[String] = none + operationName: Option[String], + variables: Option[JsonObject], + extensions: Option[JsonObject], + descriptor: Option[String] ): Resource[F, fs2.Stream[F, GraphQLResponse[D]]] = for res <- traceSpan("subscribe", document, operationName, descriptor).build.resource From 89cc587f31b0dd2f2e33daf6effa56c75158cf22 Mon Sep 17 00:00:00 2001 From: Carlos Quiroz Date: Wed, 29 Jul 2026 15:42:19 -0400 Subject: [PATCH 09/14] Supply transport attributes at construction, not by inheritance --- .../scala/clue/otel4s/Otel4sMiddleware.scala | 30 +++++++++--------- .../clue/otel4s/Otel4sRequestSpanSpec.scala | 31 ++++++++++--------- 2 files changed, 32 insertions(+), 29 deletions(-) diff --git a/otel4s/src/main/scala/clue/otel4s/Otel4sMiddleware.scala b/otel4s/src/main/scala/clue/otel4s/Otel4sMiddleware.scala index da025f10..8d3ab53e 100644 --- a/otel4s/src/main/scala/clue/otel4s/Otel4sMiddleware.scala +++ b/otel4s/src/main/scala/clue/otel4s/Otel4sMiddleware.scala @@ -39,13 +39,19 @@ object Otel4sMiddleware: private def emptyAttrs[F[_]: Applicative] : (GraphQLQuery, Option[JsonObject]) => F[List[Attribute[?]]] = (_, _) => List.empty.pure + // Transport-specific attributes for the one-shot HTTP clients, supplied at construction rather + // than baked into `Otel4sFetchClient`. The streaming clients extend it but speak WebSocket, and + // must not describe their requests as HTTP. + private val httpAttributes: List[Attribute[?]] = + List(HttpAttributes.HttpRequestMethod("POST")) + // Fetch client def apply[F[_]: Tracer: MonadCancelThrow, P: TraceHeaderInjector, S]( client: FetchClientWithPars[F, P, S], spanMod: SpanMod[F], additionalAttributesF: (GraphQLQuery, Option[JsonObject]) => F[List[Attribute[?]]] ): FetchClientWithPars[F, P, S] = - Otel4sFetchClient[F, P, S](client, spanMod, additionalAttributesF) + Otel4sFetchClient[F, P, S](client, spanMod, additionalAttributesF, httpAttributes) def apply[F[_]: Tracer: MonadCancelThrow, P: TraceHeaderInjector, S]( client: FetchClientWithPars[F, P, S] @@ -116,9 +122,13 @@ object Otel4sMiddleware: .getOrElse(F.unit) class Otel4sFetchClient[F[_]: {MonadCancelThrow, Tracer as T}, P: TraceHeaderInjector, S]( - wrapped: FetchClientWithPars[F, P, S], - spanMod: Otel4sMiddleware.SpanMod[F], - additionalAttributesF: (GraphQLQuery, Option[JsonObject]) => F[List[Attribute[?]]] + wrapped: FetchClientWithPars[F, P, S], + spanMod: Otel4sMiddleware.SpanMod[F], + additionalAttributesF: (GraphQLQuery, Option[JsonObject]) => F[List[Attribute[?]]], + // Attributes describing the transport, added to the `request` span. Empty unless the + // construction site can vouch for one, so that a client speaking a protocol it doesn't know + // about stays silent rather than inheriting someone else's claim. + private[otel4s] val transportAttributes: List[Attribute[?]] = Nil ) extends FetchClientWithPars[F, P, S]: protected def traceSpan( operation: String, @@ -139,13 +149,6 @@ class Otel4sFetchClient[F[_]: {MonadCancelThrow, Tracer as T}, P: TraceHeaderInj val traceExt = JsonObject.fromMap(traceHeaders.map((k, v) => k -> Json.fromString(v))) extensions.map(_.deepMerge(traceExt)).orElse(Some(traceExt)).filterNot(_.isEmpty) - /** - * Transport-specific attributes applied to the `request` span. The HTTP fetch client reports - * `http.request.method: POST`. - */ - protected[otel4s] def requestTransportAttributes: List[Attribute[?]] = - List(HttpAttributes.HttpRequestMethod("POST")) - override protected[clue] def requestInternal[D: Decoder]( document: GraphQLQuery, operationName: Option[String], @@ -156,7 +159,7 @@ class Otel4sFetchClient[F[_]: {MonadCancelThrow, Tracer as T}, P: TraceHeaderInj ): F[GraphQLResponse[D]] = MonadCancelThrow[F].uncancelable: poll => traceSpan("request", document, operationName, descriptor) - .addAttributes(requestTransportAttributes*) + .addAttributes(transportAttributes*) .build .use: span => for @@ -187,9 +190,6 @@ class Otel4sStreamingClient[F[_]: {Concurrent, Tracer as T}, S]( additionalAttributesF: (GraphQLQuery, Option[JsonObject]) => F[List[Attribute[?]]] ) extends Otel4sFetchClient[F, Unit, S](wrapped, spanMod, additionalAttributesF) with StreamingClient[F, S]: - // Streaming clients run over a persistent transport (typically WebSocket), not HTTP - override protected[otel4s] def requestTransportAttributes: List[Attribute[?]] = Nil - protected[clue] def subscribeInternal[D: Decoder]( document: GraphQLQuery, operationName: Option[String], diff --git a/otel4s/src/test/scala/clue/otel4s/Otel4sRequestSpanSpec.scala b/otel4s/src/test/scala/clue/otel4s/Otel4sRequestSpanSpec.scala index b6e80ae2..7bc07c72 100644 --- a/otel4s/src/test/scala/clue/otel4s/Otel4sRequestSpanSpec.scala +++ b/otel4s/src/test/scala/clue/otel4s/Otel4sRequestSpanSpec.scala @@ -19,12 +19,13 @@ import org.typelevel.otel4s.trace.Tracer * Regression coverage for the transport-specific attributes on the `request` span. * * `Otel4sStreamingClient extends Otel4sFetchClient`, so it used to inherit `requestInternal` - * wholesale — including the hardcoded `http.request.method: POST` attribute. That mislabelled - * queries/mutations sent as WebSocket `Subscribe` frames as HTTP POST. These tests pin the fix: the - * HTTP client reports the method, the streaming client does not. + * wholesale — including a hardcoded `http.request.method: POST`. That mislabelled queries and + * mutations sent as WebSocket `Subscribe` frames as HTTP POST. The attribute is now supplied at + * construction, and only the fetch factory supplies it. * - * Only the pure attribute lists are inspected, so a noop tracer suffices and no request is ever - * actually run (the wrapped clients' methods are never called). + * Only the wiring is inspected, so a noop tracer suffices and no request is ever run. Asserting on + * the emitted span instead would need an in-memory SDK, and otel4s publishes none for the 1.0 API + * (`otel4s-sdk-testkit` stops at 0.19.0). */ class Otel4sRequestSpanSpec extends FunSuite: @@ -67,17 +68,19 @@ class Otel4sRequestSpanSpec extends FunSuite: ): Resource[IO, Stream[IO, GraphQLResponse[D]]] = Resource.eval(IO.never) - private def httpMethodKeys[S](client: Otel4sFetchClient[IO, Unit, S]): List[String] = - client.requestTransportAttributes.map(_.key.name) + private def transportKeys(client: Any): List[String] = + client match + case c: Otel4sFetchClient[?, ?, ?] => c.transportAttributes.map(_.key.name) + case other => fail(s"expected an Otel4sFetchClient, got [$other]") - test("the HTTP fetch client tags requests with http.request.method: POST"): - val client = new Otel4sFetchClient[IO, Unit, Unit](stubFetch, noMod, noAttrs) - assert(httpMethodKeys(client).contains("http.request.method")) + test("the HTTP fetch client tags requests with http.request.method"): + assert(transportKeys(Otel4sMiddleware(stubFetch)).contains("http.request.method")) test("the streaming (WebSocket) client does NOT tag requests with http.request.method"): - // Regression: Otel4sStreamingClient extends Otel4sFetchClient and used to inherit the POST - // attribute, mislabeling WebSocket Subscribe frames as HTTP POST. - val client = new Otel4sStreamingClient[IO, Unit](stubStream, noMod, noAttrs) - assert(!httpMethodKeys(client).contains("http.request.method")) + assert(!transportKeys(Otel4sMiddleware(stubStream)).contains("http.request.method")) + + test("a directly constructed client claims no transport by default"): + val client = new Otel4sFetchClient[IO, Unit, Unit](stubFetch, noMod, noAttrs) + assertEquals(client.transportAttributes, Nil) end Otel4sRequestSpanSpec From e14a4a99a8e02cafa3c98d88f7080a2d9caac4a0 Mon Sep 17 00:00:00 2001 From: Carlos Quiroz Date: Wed, 29 Jul 2026 16:03:11 -0400 Subject: [PATCH 10/14] Upadet gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 6e784b06..af64ac71 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,4 @@ node_modules # direnv .direnv/ +.pi/ From 1ef86a151c601cf98d18f80c43d52d16e0cb2b93 Mon Sep 17 00:00:00 2001 From: Carlos Quiroz Date: Wed, 29 Jul 2026 17:12:13 -0400 Subject: [PATCH 11/14] Fix regex broken on js --- model/src/main/scala/clue/model/GraphQLQuery.scala | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/model/src/main/scala/clue/model/GraphQLQuery.scala b/model/src/main/scala/clue/model/GraphQLQuery.scala index 575c25d3..8bc6b400 100644 --- a/model/src/main/scala/clue/model/GraphQLQuery.scala +++ b/model/src/main/scala/clue/model/GraphQLQuery.scala @@ -11,8 +11,10 @@ object GraphQLQuery: def apply(query: String): GraphQLQuery = query // The operation type keyword, matched at the start of a line so that leading comments and - // fragment definitions are skipped. - private val OperationType = """(?m)^[ \t]*(query|mutation|subscription)\b""".r + // fragment definitions are skipped. We spell "start of a line" as `(?:^|\n)` rather than the + // `(?m)` flag because the latter is unsupported by Scala.js unless the linker targets ES2018+ + // (and `model` cross-compiles to JS), whereas this form uses only ES5 regex features. + private val OperationType = """(?:^|\n)[ \t]*(query|mutation|subscription)\b""".r // The name of an explicitly named operation: the word right after the operation type keyword, // followed by variable definitions, a directive or the selection set. From 610206d6fa829f2da84a988be8eda3b655cf7b78 Mon Sep 17 00:00:00 2001 From: Carlos Quiroz Date: Wed, 29 Jul 2026 18:19:43 -0400 Subject: [PATCH 12/14] Cleanup --- .../src/main/scala/test/LucumaQuery.scala | 6 ++-- .../src/main/scala/test/LucumaQuery2.scala | 6 ++-- .../src/main/scala/test/LucumaQuery3.scala | 6 ++-- .../scala/test/StarWarsDescriptorQuery.scala | 6 ++-- .../src/main/scala/test/StarWarsInclude.scala | 6 ++-- .../src/main/scala/test/StarWarsQuery.scala | 6 ++-- .../src/main/scala/test/StarWarsQuery2.scala | 6 ++-- .../src/main/scala/test/StarWarsQuery3.scala | 6 ++-- .../src/main/scala/test/StarWarsQuery4.scala | 6 ++-- .../src/main/scala/clue/gen/QueryGen.scala | 18 +++++++----- .../scala/clue/otel4s/Otel4sMiddleware.scala | 28 +++++++++++++++++++ .../clue/otel4s/Otel4sMiddlewareSpec.scala | 17 +++++++++++ 12 files changed, 92 insertions(+), 25 deletions(-) diff --git a/gen/output/src/main/scala/test/LucumaQuery.scala b/gen/output/src/main/scala/test/LucumaQuery.scala index 7e079e65..cfa9d0c3 100644 --- a/gen/output/src/main/scala/test/LucumaQuery.scala +++ b/gen/output/src/main/scala/test/LucumaQuery.scala @@ -98,7 +98,9 @@ object LucumaQuery extends GraphQLOperation[LucumaODB] { } val varEncoder: io.circe.Encoder.AsObject[Variables] = Variables.jsonEncoderVariables val dataDecoder: io.circe.Decoder[Data] = Data.jsonDecoderData - def apply[F[_]]: clue.ClientAppliedF[F, LucumaODB, ClientAppliedFP] = new clue.ClientAppliedF[F, LucumaODB, ClientAppliedFP] { def applyP[P](client: clue.FetchClientWithPars[F, P, LucumaODB]) = new ClientAppliedFP(client) } - class ClientAppliedFP[F[_], P](val client: clue.FetchClientWithPars[F, P, LucumaODB]) { def query(modParams: P => P = identity) = client.request(LucumaQuery).withDescriptor("LucumaQuery").withInput(Variables(), modParams) } + import clue.ClientAppliedF + import clue.FetchClientWithPars + def apply[F[_]]: ClientAppliedF[F, LucumaODB, ClientAppliedFP] = new ClientAppliedF[F, LucumaODB, ClientAppliedFP] { def applyP[P](client: FetchClientWithPars[F, P, LucumaODB]) = new ClientAppliedFP(client) } + class ClientAppliedFP[F[_], P](val client: FetchClientWithPars[F, P, LucumaODB]) { def query(modParams: P => P = identity) = client.request(LucumaQuery).withDescriptor("LucumaQuery").withInput(Variables(), modParams) } } // format: on diff --git a/gen/output/src/main/scala/test/LucumaQuery2.scala b/gen/output/src/main/scala/test/LucumaQuery2.scala index 9925e542..f256ebdc 100644 --- a/gen/output/src/main/scala/test/LucumaQuery2.scala +++ b/gen/output/src/main/scala/test/LucumaQuery2.scala @@ -80,7 +80,9 @@ object LucumaQuery2 extends GraphQLOperation[LucumaODB] { } val varEncoder: io.circe.Encoder.AsObject[Variables] = Variables.jsonEncoderVariables val dataDecoder: io.circe.Decoder[Data] = Data.jsonDecoderData - def apply[F[_]]: clue.ClientAppliedF[F, LucumaODB, ClientAppliedFP] = new clue.ClientAppliedF[F, LucumaODB, ClientAppliedFP] { def applyP[P](client: clue.FetchClientWithPars[F, P, LucumaODB]) = new ClientAppliedFP(client) } - class ClientAppliedFP[F[_], P](val client: clue.FetchClientWithPars[F, P, LucumaODB]) { def query(modParams: P => P = identity) = client.request(LucumaQuery2).withDescriptor("LucumaQuery2").withInput(Variables(), modParams) } + import clue.ClientAppliedF + import clue.FetchClientWithPars + def apply[F[_]]: ClientAppliedF[F, LucumaODB, ClientAppliedFP] = new ClientAppliedF[F, LucumaODB, ClientAppliedFP] { def applyP[P](client: FetchClientWithPars[F, P, LucumaODB]) = new ClientAppliedFP(client) } + class ClientAppliedFP[F[_], P](val client: FetchClientWithPars[F, P, LucumaODB]) { def query(modParams: P => P = identity) = client.request(LucumaQuery2).withDescriptor("LucumaQuery2").withInput(Variables(), modParams) } } // format: on diff --git a/gen/output/src/main/scala/test/LucumaQuery3.scala b/gen/output/src/main/scala/test/LucumaQuery3.scala index c6bc17c2..91b1c39d 100644 --- a/gen/output/src/main/scala/test/LucumaQuery3.scala +++ b/gen/output/src/main/scala/test/LucumaQuery3.scala @@ -87,7 +87,9 @@ object LucumaQuery3 extends GraphQLOperation[LucumaODB] { } val varEncoder: io.circe.Encoder.AsObject[Variables] = Variables.jsonEncoderVariables val dataDecoder: io.circe.Decoder[Data] = Data.jsonDecoderData - def apply[F[_]]: clue.ClientAppliedF[F, LucumaODB, ClientAppliedFP] = new clue.ClientAppliedF[F, LucumaODB, ClientAppliedFP] { def applyP[P](client: clue.FetchClientWithPars[F, P, LucumaODB]) = new ClientAppliedFP(client) } - class ClientAppliedFP[F[_], P](val client: clue.FetchClientWithPars[F, P, LucumaODB]) { def query(modParams: P => P = identity) = client.request(LucumaQuery3).withDescriptor("LucumaQuery3").withInput(Variables(), modParams) } + import clue.ClientAppliedF + import clue.FetchClientWithPars + def apply[F[_]]: ClientAppliedF[F, LucumaODB, ClientAppliedFP] = new ClientAppliedF[F, LucumaODB, ClientAppliedFP] { def applyP[P](client: FetchClientWithPars[F, P, LucumaODB]) = new ClientAppliedFP(client) } + class ClientAppliedFP[F[_], P](val client: FetchClientWithPars[F, P, LucumaODB]) { def query(modParams: P => P = identity) = client.request(LucumaQuery3).withDescriptor("LucumaQuery3").withInput(Variables(), modParams) } } // format: on diff --git a/gen/output/src/main/scala/test/StarWarsDescriptorQuery.scala b/gen/output/src/main/scala/test/StarWarsDescriptorQuery.scala index 5b8876e1..4fa92658 100644 --- a/gen/output/src/main/scala/test/StarWarsDescriptorQuery.scala +++ b/gen/output/src/main/scala/test/StarWarsDescriptorQuery.scala @@ -47,7 +47,9 @@ object StarWarsDescriptorQuery extends GraphQLOperation[StarWars] { } val varEncoder: io.circe.Encoder.AsObject[Variables] = Variables.jsonEncoderVariables val dataDecoder: io.circe.Decoder[Data] = Data.jsonDecoderData - def apply[F[_]]: clue.ClientAppliedF[F, StarWars, ClientAppliedFP] = new clue.ClientAppliedF[F, StarWars, ClientAppliedFP] { def applyP[P](client: clue.FetchClientWithPars[F, P, StarWars]) = new ClientAppliedFP(client) } - class ClientAppliedFP[F[_], P](val client: clue.FetchClientWithPars[F, P, StarWars]) { def query(charId: String, modParams: P => P = identity) = client.request(StarWarsDescriptorQuery).withInput(Variables(charId), modParams) } + import clue.ClientAppliedF + import clue.FetchClientWithPars + def apply[F[_]]: ClientAppliedF[F, StarWars, ClientAppliedFP] = new ClientAppliedF[F, StarWars, ClientAppliedFP] { def applyP[P](client: FetchClientWithPars[F, P, StarWars]) = new ClientAppliedFP(client) } + class ClientAppliedFP[F[_], P](val client: FetchClientWithPars[F, P, StarWars]) { def query(charId: String, modParams: P => P = identity) = client.request(StarWarsDescriptorQuery).withInput(Variables(charId), modParams) } } // format: on diff --git a/gen/output/src/main/scala/test/StarWarsInclude.scala b/gen/output/src/main/scala/test/StarWarsInclude.scala index e6b92494..192d43ff 100644 --- a/gen/output/src/main/scala/test/StarWarsInclude.scala +++ b/gen/output/src/main/scala/test/StarWarsInclude.scala @@ -51,7 +51,9 @@ object StarWarsInclude extends GraphQLOperation[StarWars] { } val varEncoder: io.circe.Encoder.AsObject[Variables] = Variables.jsonEncoderVariables val dataDecoder: io.circe.Decoder[Data] = Data.jsonDecoderData - def apply[F[_]]: clue.ClientAppliedF[F, StarWars, ClientAppliedFP] = new clue.ClientAppliedF[F, StarWars, ClientAppliedFP] { def applyP[P](client: clue.FetchClientWithPars[F, P, StarWars]) = new ClientAppliedFP(client) } - class ClientAppliedFP[F[_], P](val client: clue.FetchClientWithPars[F, P, StarWars]) { def query(humanId: String, skipId: Boolean, withName: Boolean, modParams: P => P = identity) = client.request(StarWarsInclude).withDescriptor("StarWarsInclude").withInput(Variables(humanId, skipId, withName), modParams) } + import clue.ClientAppliedF + import clue.FetchClientWithPars + def apply[F[_]]: ClientAppliedF[F, StarWars, ClientAppliedFP] = new ClientAppliedF[F, StarWars, ClientAppliedFP] { def applyP[P](client: FetchClientWithPars[F, P, StarWars]) = new ClientAppliedFP(client) } + class ClientAppliedFP[F[_], P](val client: FetchClientWithPars[F, P, StarWars]) { def query(humanId: String, skipId: Boolean, withName: Boolean, modParams: P => P = identity) = client.request(StarWarsInclude).withDescriptor("StarWarsInclude").withInput(Variables(humanId, skipId, withName), modParams) } } // format: on diff --git a/gen/output/src/main/scala/test/StarWarsQuery.scala b/gen/output/src/main/scala/test/StarWarsQuery.scala index c5031b01..2be62020 100644 --- a/gen/output/src/main/scala/test/StarWarsQuery.scala +++ b/gen/output/src/main/scala/test/StarWarsQuery.scala @@ -111,7 +111,9 @@ object StarWarsQuery extends GraphQLOperation[StarWars] { } val varEncoder: io.circe.Encoder.AsObject[Variables] = Variables.jsonEncoderVariables val dataDecoder: io.circe.Decoder[Data] = Data.jsonDecoderData - def apply[F[_]]: clue.ClientAppliedF[F, StarWars, ClientAppliedFP] = new clue.ClientAppliedF[F, StarWars, ClientAppliedFP] { def applyP[P](client: clue.FetchClientWithPars[F, P, StarWars]) = new ClientAppliedFP(client) } - class ClientAppliedFP[F[_], P](val client: clue.FetchClientWithPars[F, P, StarWars]) { def query(charId: String, modParams: P => P = identity) = client.request(StarWarsQuery).withDescriptor("StarWarsQuery").withInput(Variables(charId), modParams) } + import clue.ClientAppliedF + import clue.FetchClientWithPars + def apply[F[_]]: ClientAppliedF[F, StarWars, ClientAppliedFP] = new ClientAppliedF[F, StarWars, ClientAppliedFP] { def applyP[P](client: FetchClientWithPars[F, P, StarWars]) = new ClientAppliedFP(client) } + class ClientAppliedFP[F[_], P](val client: FetchClientWithPars[F, P, StarWars]) { def query(charId: String, modParams: P => P = identity) = client.request(StarWarsQuery).withDescriptor("StarWarsQuery").withInput(Variables(charId), modParams) } } // format: on diff --git a/gen/output/src/main/scala/test/StarWarsQuery2.scala b/gen/output/src/main/scala/test/StarWarsQuery2.scala index 3a5d851f..b39fcc1f 100644 --- a/gen/output/src/main/scala/test/StarWarsQuery2.scala +++ b/gen/output/src/main/scala/test/StarWarsQuery2.scala @@ -131,8 +131,10 @@ object Wrapper extends Something { } val varEncoder: io.circe.Encoder.AsObject[Variables] = Variables.jsonEncoderVariables val dataDecoder: io.circe.Decoder[Data] = Data.jsonDecoderData - def apply[F[_]]: clue.ClientAppliedF[F, StarWars, ClientAppliedFP] = new clue.ClientAppliedF[F, StarWars, ClientAppliedFP] { def applyP[P](client: clue.FetchClientWithPars[F, P, StarWars]) = new ClientAppliedFP(client) } - class ClientAppliedFP[F[_], P](val client: clue.FetchClientWithPars[F, P, StarWars]) { def query(charId: String, modParams: P => P = identity) = client.request(StarWarsQuery2).withDescriptor("StarWarsQuery2").withInput(Variables(charId), modParams) } + import clue.ClientAppliedF + import clue.FetchClientWithPars + def apply[F[_]]: ClientAppliedF[F, StarWars, ClientAppliedFP] = new ClientAppliedF[F, StarWars, ClientAppliedFP] { def applyP[P](client: FetchClientWithPars[F, P, StarWars]) = new ClientAppliedFP(client) } + class ClientAppliedFP[F[_], P](val client: FetchClientWithPars[F, P, StarWars]) { def query(charId: String, modParams: P => P = identity) = client.request(StarWarsQuery2).withDescriptor("StarWarsQuery2").withInput(Variables(charId), modParams) } } } // format: on diff --git a/gen/output/src/main/scala/test/StarWarsQuery3.scala b/gen/output/src/main/scala/test/StarWarsQuery3.scala index a7909876..7aa08e9b 100644 --- a/gen/output/src/main/scala/test/StarWarsQuery3.scala +++ b/gen/output/src/main/scala/test/StarWarsQuery3.scala @@ -102,7 +102,9 @@ object StarWarsQuery3 extends GraphQLOperation[StarWars] { } val varEncoder: io.circe.Encoder.AsObject[Variables] = Variables.jsonEncoderVariables val dataDecoder: io.circe.Decoder[Data] = Data.jsonDecoderData - def apply[F[_]]: clue.ClientAppliedF[F, StarWars, ClientAppliedFP] = new clue.ClientAppliedF[F, StarWars, ClientAppliedFP] { def applyP[P](client: clue.FetchClientWithPars[F, P, StarWars]) = new ClientAppliedFP(client) } - class ClientAppliedFP[F[_], P](val client: clue.FetchClientWithPars[F, P, StarWars]) { def query(charId: String, modParams: P => P = identity) = client.request(StarWarsQuery3).withDescriptor("StarWarsQuery3").withInput(Variables(charId), modParams) } + import clue.ClientAppliedF + import clue.FetchClientWithPars + def apply[F[_]]: ClientAppliedF[F, StarWars, ClientAppliedFP] = new ClientAppliedF[F, StarWars, ClientAppliedFP] { def applyP[P](client: FetchClientWithPars[F, P, StarWars]) = new ClientAppliedFP(client) } + class ClientAppliedFP[F[_], P](val client: FetchClientWithPars[F, P, StarWars]) { def query(charId: String, modParams: P => P = identity) = client.request(StarWarsQuery3).withDescriptor("StarWarsQuery3").withInput(Variables(charId), modParams) } } // format: on diff --git a/gen/output/src/main/scala/test/StarWarsQuery4.scala b/gen/output/src/main/scala/test/StarWarsQuery4.scala index b081e796..a860590c 100644 --- a/gen/output/src/main/scala/test/StarWarsQuery4.scala +++ b/gen/output/src/main/scala/test/StarWarsQuery4.scala @@ -36,7 +36,9 @@ object StarWarsQuery4 extends GraphQLOperation[StarWars] { } val varEncoder: io.circe.Encoder.AsObject[Variables] = Variables.jsonEncoderVariables val dataDecoder: io.circe.Decoder[Data] = Data.jsonDecoderData - def apply[F[_]]: clue.ClientAppliedF[F, StarWars, ClientAppliedFP] = new clue.ClientAppliedF[F, StarWars, ClientAppliedFP] { def applyP[P](client: clue.FetchClientWithPars[F, P, StarWars]) = new ClientAppliedFP(client) } - class ClientAppliedFP[F[_], P](val client: clue.FetchClientWithPars[F, P, StarWars]) { def query(charId: String, modParams: P => P = identity) = client.request(StarWarsQuery4).withDescriptor("StarWarsQuery4").withInput(Variables(charId), modParams) } + import clue.ClientAppliedF + import clue.FetchClientWithPars + def apply[F[_]]: ClientAppliedF[F, StarWars, ClientAppliedFP] = new ClientAppliedF[F, StarWars, ClientAppliedFP] { def applyP[P](client: FetchClientWithPars[F, P, StarWars]) = new ClientAppliedFP(client) } + class ClientAppliedFP[F[_], P](val client: FetchClientWithPars[F, P, StarWars]) { def query(charId: String, modParams: P => P = identity) = client.request(StarWarsQuery4).withDescriptor("StarWarsQuery4").withInput(Variables(charId), modParams) } } // format: on diff --git a/gen/rules/src/main/scala/clue/gen/QueryGen.scala b/gen/rules/src/main/scala/clue/gen/QueryGen.scala index 791f0930..c28ff192 100644 --- a/gen/rules/src/main/scala/clue/gen/QueryGen.scala +++ b/gen/rules/src/main/scala/clue/gen/QueryGen.scala @@ -802,10 +802,14 @@ trait QueryGen extends Generator { }) .toList val applied = - q"""def apply[F[_]]: clue.ClientAppliedF[F, $schemaType, ClientAppliedFP] = - new clue.ClientAppliedF[F, $schemaType, ClientAppliedFP] { - def applyP[P](client: clue.FetchClientWithPars[F, P, $schemaType]) = new ClientAppliedFP(client) + q"""def apply[F[_]]: ClientAppliedF[F, $schemaType, ClientAppliedFP] = + new ClientAppliedF[F, $schemaType, ClientAppliedFP] { + def applyP[P](client: FetchClientWithPars[F, P, $schemaType]) = new ClientAppliedFP(client) }""" + // `ClientAppliedF` and `FetchClientWithPars` are only referenced by query/mutation + // operations, so import them here rather than for every operation kind. + val clientImports = + List(q"import clue.ClientAppliedF", q"import clue.FetchClientWithPars") // When descriptor generation is enabled, tag the request/subscription with the // object name so otel4s can name the span `clue--`. val objTerm = Term.Name(objName) @@ -822,9 +826,9 @@ trait QueryGen extends Generator { parentBody ++ (operation match { case _: UntypedQuery => - List( + clientImports ++ List( applied, - q"""class ClientAppliedFP[F[_], P](val client: clue.FetchClientWithPars[F, P, $schemaType]) { + q"""class ClientAppliedFP[F[_], P](val client: FetchClientWithPars[F, P, $schemaType]) { def query(...${(paramss.head :+ param"modParams: P => P = identity") +: paramss.tail}) = $afterRequest.withInput(Variables(...$variablesNames), modParams) } @@ -832,9 +836,9 @@ trait QueryGen extends Generator { ) case _: UntypedMutation => - List( + clientImports ++ List( applied, - q"""class ClientAppliedFP[F[_], P](val client: clue.FetchClientWithPars[F, P, $schemaType]) { + q"""class ClientAppliedFP[F[_], P](val client: FetchClientWithPars[F, P, $schemaType]) { def execute(...${(paramss.head :+ param"modParams: P => P = identity") +: paramss.tail}) = $afterRequest.withInput(Variables(...$variablesNames), modParams) } diff --git a/otel4s/src/main/scala/clue/otel4s/Otel4sMiddleware.scala b/otel4s/src/main/scala/clue/otel4s/Otel4sMiddleware.scala index 8d3ab53e..7600cbe6 100644 --- a/otel4s/src/main/scala/clue/otel4s/Otel4sMiddleware.scala +++ b/otel4s/src/main/scala/clue/otel4s/Otel4sMiddleware.scala @@ -15,11 +15,14 @@ import clue.PersistentStreamingClient import clue.StreamingClient import clue.TraceHeaderInjector import clue.model.GraphQLQuery +import clue.model.GraphQLRequest import clue.model.GraphQLResponse +import clue.model.json.given import io.circe.Decoder import io.circe.Encoder import io.circe.Json import io.circe.JsonObject +import io.circe.syntax.* import org.typelevel.otel4s.Attribute import org.typelevel.otel4s.context.propagation.TextMapUpdater import org.typelevel.otel4s.semconv.attributes.HttpAttributes @@ -121,6 +124,17 @@ object Otel4sMiddleware: ) *> span.setStatus(StatusCode.Error, "GraphQL request returned errors") .getOrElse(F.unit) + /** + * Inculde the requested query size as an attribute + */ + private[otel4s] def requestBodySize( + document: GraphQLQuery, + operationName: Option[String], + variables: Option[JsonObject], + extensions: Option[JsonObject] + ): Long = + GraphQLRequest(document, operationName, variables, extensions).asJson.noSpaces.length.toLong + class Otel4sFetchClient[F[_]: {MonadCancelThrow, Tracer as T}, P: TraceHeaderInjector, S]( wrapped: FetchClientWithPars[F, P, S], spanMod: Otel4sMiddleware.SpanMod[F], @@ -167,6 +181,13 @@ class Otel4sFetchClient[F[_]: {MonadCancelThrow, Tracer as T}, P: TraceHeaderInj _ <- span.addAttributes(additional*) traceHeaders <- T.propagate(Map.empty) mergedExt = mergeOtelExtension(extensions, traceHeaders) + // Measure the payload after trace headers are merged in + _ <- span.addAttribute( + Attribute( + "clue.request.body.size", + Otel4sMiddleware.requestBodySize(document, operationName, variables, mergedExt) + ) + ) modWithTrace = modParams.andThen(p => TraceHeaderInjector[P].addHeaders(p, traceHeaders)) result <- @@ -205,6 +226,13 @@ class Otel4sStreamingClient[F[_]: {Concurrent, Tracer as T}, S]( span.addAttributes(attrs*) traceHeaders <- Resource.eval(T.propagate(Map.empty)) mergedExt = mergeOtelExtension(extensions, traceHeaders) + _ <- Resource.eval: + span.addAttribute( + Attribute( + "clue.request.body.size", + Otel4sMiddleware.requestBodySize(document, operationName, variables, mergedExt) + ) + ) stream <- wrapped.subscribeInternal[D]( document, operationName, diff --git a/otel4s/src/test/scala/clue/otel4s/Otel4sMiddlewareSpec.scala b/otel4s/src/test/scala/clue/otel4s/Otel4sMiddlewareSpec.scala index dd2cb1a4..9c90e7b7 100644 --- a/otel4s/src/test/scala/clue/otel4s/Otel4sMiddlewareSpec.scala +++ b/otel4s/src/test/scala/clue/otel4s/Otel4sMiddlewareSpec.scala @@ -4,6 +4,8 @@ package clue.otel4s import clue.model.GraphQLQuery +import io.circe.Json +import io.circe.JsonObject import munit.FunSuite class Otel4sMiddlewareSpec extends FunSuite: @@ -32,4 +34,19 @@ class Otel4sMiddlewareSpec extends FunSuite: assert(attrs.exists(_.key.name == "graphql.document"), "expected a graphql.document attribute") } + test("requestBodySize reflects the serialized payload and grows with variables") { + // The serialized request is JSON wrapping the document, so its length must at least contain + // the document text … + val base = Otel4sMiddleware.requestBodySize(doc, Some("ObservationVisits"), None, None) + assert(base >= doc.value.length.toLong) + // … and adding variables can only make it larger. + val withVars = Otel4sMiddleware.requestBodySize( + doc, + Some("ObservationVisits"), + Some(JsonObject("id" -> Json.fromString("o-1"))), + None + ) + assert(withVars > base) + } + end Otel4sMiddlewareSpec From 4fe567672ffde4c9ce2fac01068575556fc830fb Mon Sep 17 00:00:00 2001 From: Carlos Quiroz Date: Wed, 29 Jul 2026 18:24:46 -0400 Subject: [PATCH 13/14] Add the planned descriptor and span-naming tests --- core/src/test/scala/clue/DescriptorSpec.scala | 87 +++++++++++++++++++ .../src/main/scala/test/LucumaMutation.scala | 24 +++++ .../main/scala/test/LucumaSubscription.scala | 27 ++++++ .../src/main/scala/test/LucumaMutation.scala | 54 ++++++++++++ .../main/scala/test/LucumaSubscription.scala | 62 +++++++++++++ .../scala/clue/otel4s/Otel4sMiddleware.scala | 16 +++- .../clue/otel4s/Otel4sMiddlewareSpec.scala | 71 +++++++++++++-- .../clue/otel4s/Otel4sRequestSpanSpec.scala | 4 +- 8 files changed, 338 insertions(+), 7 deletions(-) create mode 100644 core/src/test/scala/clue/DescriptorSpec.scala create mode 100644 gen/input/src/main/scala/test/LucumaMutation.scala create mode 100644 gen/input/src/main/scala/test/LucumaSubscription.scala create mode 100644 gen/output/src/main/scala/test/LucumaMutation.scala create mode 100644 gen/output/src/main/scala/test/LucumaSubscription.scala diff --git a/core/src/test/scala/clue/DescriptorSpec.scala b/core/src/test/scala/clue/DescriptorSpec.scala new file mode 100644 index 00000000..228e9a34 --- /dev/null +++ b/core/src/test/scala/clue/DescriptorSpec.scala @@ -0,0 +1,87 @@ +// Copyright (c) 2016-2025 Association of Universities for Research in Astronomy, Inc. (AURA) +// For license information see LICENSE or https://opensource.org/licenses/BSD-3-Clause + +package clue + +import cats.effect.IO +import cats.effect.Ref +import cats.effect.Resource +import cats.syntax.all.* +import clue.model.GraphQLQuery +import clue.model.GraphQLResponse +import io.circe.Decoder +import io.circe.Json +import io.circe.JsonObject +import munit.CatsEffectSuite + +/** + * The descriptor is tracing-only: it never goes on the wire. The only thing that carries it from + * the call site to a tracing middleware is the `descriptor` parameter of `requestInternal` / + * `subscribeInternal`, so these tests capture what a wrapped client actually receives. + */ +class DescriptorSpec extends CatsEffectSuite: + + private object Op extends GraphQLOperation.Typed[Unit, JsonObject, Json]: + val document = "query NamedOp { field }" + + // A client that records the `descriptor` it was handed and answers with an empty response. + private class Recorder(ref: Ref[IO, Option[Option[String]]]) extends StreamingClient[IO, Unit]: + protected[clue] def requestInternal[D: Decoder]( + document: GraphQLQuery, + operationName: Option[String], + variables: Option[JsonObject], + extensions: Option[JsonObject], + modParams: Unit => Unit, + descriptor: Option[String] + ): IO[GraphQLResponse[D]] = + ref.set(descriptor.some) *> IO.raiseError(new NoSuchElementException("no data")) + + protected[clue] def subscribeInternal[D: Decoder]( + document: GraphQLQuery, + operationName: Option[String], + variables: Option[JsonObject], + extensions: Option[JsonObject], + descriptor: Option[String] + ): Resource[IO, fs2.Stream[IO, GraphQLResponse[D]]] = + Resource.eval(ref.set(descriptor.some).as(fs2.Stream.empty)) + + // Runs `f` against a recording client and returns the descriptor it saw. The client's response is + // an error, which is irrelevant here and discarded: only the recorded value is under test. + private def descriptorSeen(f: StreamingClient[IO, Unit] => IO[Unit]): IO[Option[String]] = + for + ref <- IO.ref(Option.empty[Option[String]]) + _ <- f(Recorder(ref)).attempt + seen <- ref.get + yield seen.getOrElse(fail("the wrapped client was never called")) + + test("withDescriptor reaches requestInternal"): + assertIO( + descriptorSeen(_.request(Op).withDescriptor("MyQuery").withInput(JsonObject.empty).void), + "MyQuery".some + ) + + test("withDescriptor reaches requestInternal through the no-input path"): + assertIO(descriptorSeen(_.request(Op).withDescriptor("MyQuery").apply.void), "MyQuery".some) + + test("a request without a descriptor passes none"): + assertIO(descriptorSeen(_.request(Op).withInput(JsonObject.empty).void), none) + + test("withDescriptor reaches subscribeInternal"): + assertIO( + descriptorSeen( + _.subscribe(Op).withDescriptor("MySub").withInput(JsonObject.empty).use_ + ), + "MySub".some + ) + + test("a subscription without a descriptor passes none"): + assertIO(descriptorSeen(_.subscribe(Op).withInput(JsonObject.empty).use_), none) + + test("withDescriptor is independent of operationName"): + // `operationName` goes on the wire, the descriptor does not; setting one must not set the other. + val client = Recorder(Ref.unsafe[IO, Option[Option[String]]](none)) + val applied = client.request(Op, "NamedOp".some).withDescriptor("MyQuery") + assertEquals(applied.operationName, "NamedOp".some) + assertEquals(applied.descriptor, "MyQuery".some) + +end DescriptorSpec diff --git a/gen/input/src/main/scala/test/LucumaMutation.scala b/gen/input/src/main/scala/test/LucumaMutation.scala new file mode 100644 index 00000000..650d5496 --- /dev/null +++ b/gen/input/src/main/scala/test/LucumaMutation.scala @@ -0,0 +1,24 @@ +// Copyright (c) 2016-2025 Association of Universities for Research in Astronomy, Inc. (AURA) +// For license information see LICENSE or https://opensource.org/licenses/BSD-3-Clause + +// format: off +/* + rules = [GraphQLGen] + Clue.schemaDirs = ["gen/input/src/main/resources/graphql/schemas"] + */ +package test + +import clue.GraphQLOperation +import clue.annotation.GraphQL + +@GraphQL +trait LucumaMutation extends GraphQLOperation[LucumaODB] { + val document = """ + mutation DeleteAsterism($asterismId: AsterismId!) { + deleteAsterism(asterismId: $asterismId) { + id + existence + } + }""" +} +// format: on diff --git a/gen/input/src/main/scala/test/LucumaSubscription.scala b/gen/input/src/main/scala/test/LucumaSubscription.scala new file mode 100644 index 00000000..aac3a7d9 --- /dev/null +++ b/gen/input/src/main/scala/test/LucumaSubscription.scala @@ -0,0 +1,27 @@ +// Copyright (c) 2016-2025 Association of Universities for Research in Astronomy, Inc. (AURA) +// For license information see LICENSE or https://opensource.org/licenses/BSD-3-Clause + +// format: off +/* + rules = [GraphQLGen] + Clue.schemaDirs = ["gen/input/src/main/resources/graphql/schemas"] + */ +package test + +import clue.GraphQLOperation +import clue.annotation.GraphQL + +@GraphQL +trait LucumaSubscription extends GraphQLOperation[LucumaODB] { + val document = """ + subscription AsterismEdit($programId: ProgramId) { + asterismEdit(programId: $programId) { + editType + value { + id + name + } + } + }""" +} +// format: on diff --git a/gen/output/src/main/scala/test/LucumaMutation.scala b/gen/output/src/main/scala/test/LucumaMutation.scala new file mode 100644 index 00000000..1527edb4 --- /dev/null +++ b/gen/output/src/main/scala/test/LucumaMutation.scala @@ -0,0 +1,54 @@ +// Copyright (c) 2016-2025 Association of Universities for Research in Astronomy, Inc. (AURA) +// For license information see LICENSE or https://opensource.org/licenses/BSD-3-Clause + +// format: off + +package test + +import clue.GraphQLOperation + + +object LucumaMutation extends GraphQLOperation[LucumaODB] { + import LucumaODB.Scalars._ + ignoreUnusedImportScalars() + import LucumaODB.Enums._ + ignoreUnusedImportEnums() + import LucumaODB.Types._ + ignoreUnusedImportTypes() + val document = """ + mutation DeleteAsterism($asterismId: AsterismId!) { + deleteAsterism(asterismId: $asterismId) { + id + existence + } + }""" + case class Variables(val asterismId: AsterismId) + object Variables { + val asterismId: monocle.Iso[Variables, AsterismId] = monocle.Focus[Variables](_.asterismId) + implicit val eqVariables: cats.Eq[Variables] = cats.Eq.fromUniversalEquals + implicit val showVariables: cats.Show[Variables] = cats.Show.fromToString + implicit val jsonEncoderVariables: io.circe.Encoder.AsObject[Variables] = io.circe.generic.semiauto.deriveEncoder[Variables].mapJsonObject(clue.data.Input.dropIgnores) + } + case class Data(val deleteAsterism: Data.DeleteAsterism) + object Data { + case class DeleteAsterism(val id: AsterismId, val existence: Existence) + object DeleteAsterism { + val id: monocle.Lens[Data.DeleteAsterism, AsterismId] = monocle.macros.GenLens[Data.DeleteAsterism](_.id) + val existence: monocle.Lens[Data.DeleteAsterism, Existence] = monocle.macros.GenLens[Data.DeleteAsterism](_.existence) + implicit val eqDeleteAsterism: cats.Eq[Data.DeleteAsterism] = cats.Eq.fromUniversalEquals + implicit val showDeleteAsterism: cats.Show[Data.DeleteAsterism] = cats.Show.fromToString + implicit val jsonDecoderDeleteAsterism: io.circe.Decoder[Data.DeleteAsterism] = io.circe.generic.semiauto.deriveDecoder[Data.DeleteAsterism] + } + val deleteAsterism: monocle.Iso[Data, Data.DeleteAsterism] = monocle.Focus[Data](_.deleteAsterism) + implicit val eqData: cats.Eq[Data] = cats.Eq.fromUniversalEquals + implicit val showData: cats.Show[Data] = cats.Show.fromToString + implicit val jsonDecoderData: io.circe.Decoder[Data] = io.circe.generic.semiauto.deriveDecoder[Data] + } + val varEncoder: io.circe.Encoder.AsObject[Variables] = Variables.jsonEncoderVariables + val dataDecoder: io.circe.Decoder[Data] = Data.jsonDecoderData + import clue.ClientAppliedF + import clue.FetchClientWithPars + def apply[F[_]]: ClientAppliedF[F, LucumaODB, ClientAppliedFP] = new ClientAppliedF[F, LucumaODB, ClientAppliedFP] { def applyP[P](client: FetchClientWithPars[F, P, LucumaODB]) = new ClientAppliedFP(client) } + class ClientAppliedFP[F[_], P](val client: FetchClientWithPars[F, P, LucumaODB]) { def execute(asterismId: AsterismId, modParams: P => P = identity) = client.request(LucumaMutation).withDescriptor("LucumaMutation").withInput(Variables(asterismId), modParams) } +} +// format: on diff --git a/gen/output/src/main/scala/test/LucumaSubscription.scala b/gen/output/src/main/scala/test/LucumaSubscription.scala new file mode 100644 index 00000000..34e2aa6a --- /dev/null +++ b/gen/output/src/main/scala/test/LucumaSubscription.scala @@ -0,0 +1,62 @@ +// Copyright (c) 2016-2025 Association of Universities for Research in Astronomy, Inc. (AURA) +// For license information see LICENSE or https://opensource.org/licenses/BSD-3-Clause + +// format: off + +package test + +import clue.GraphQLOperation + + +object LucumaSubscription extends GraphQLOperation[LucumaODB] { + import LucumaODB.Scalars._ + ignoreUnusedImportScalars() + import LucumaODB.Enums._ + ignoreUnusedImportEnums() + import LucumaODB.Types._ + ignoreUnusedImportTypes() + val document = """ + subscription AsterismEdit($programId: ProgramId) { + asterismEdit(programId: $programId) { + editType + value { + id + name + } + } + }""" + case class Variables(val programId: clue.data.Input[ProgramId] = clue.data.Ignore) + object Variables { + val programId: monocle.Iso[Variables, clue.data.Input[ProgramId]] = monocle.Focus[Variables](_.programId) + implicit val eqVariables: cats.Eq[Variables] = cats.Eq.fromUniversalEquals + implicit val showVariables: cats.Show[Variables] = cats.Show.fromToString + implicit val jsonEncoderVariables: io.circe.Encoder.AsObject[Variables] = io.circe.generic.semiauto.deriveEncoder[Variables].mapJsonObject(clue.data.Input.dropIgnores) + } + case class Data(val asterismEdit: Data.AsterismEdit) + object Data { + case class AsterismEdit(val editType: EditType, val value: Data.AsterismEdit.Value) + object AsterismEdit { + case class Value(val id: AsterismId, val name: Option[NonEmptyString] = None) + object Value { + val id: monocle.Lens[Data.AsterismEdit.Value, AsterismId] = monocle.macros.GenLens[Data.AsterismEdit.Value](_.id) + val name: monocle.Lens[Data.AsterismEdit.Value, Option[NonEmptyString]] = monocle.macros.GenLens[Data.AsterismEdit.Value](_.name) + implicit val eqValue: cats.Eq[Data.AsterismEdit.Value] = cats.Eq.fromUniversalEquals + implicit val showValue: cats.Show[Data.AsterismEdit.Value] = cats.Show.fromToString + implicit val jsonDecoderValue: io.circe.Decoder[Data.AsterismEdit.Value] = io.circe.generic.semiauto.deriveDecoder[Data.AsterismEdit.Value] + } + val editType: monocle.Lens[Data.AsterismEdit, EditType] = monocle.macros.GenLens[Data.AsterismEdit](_.editType) + val value: monocle.Lens[Data.AsterismEdit, Data.AsterismEdit.Value] = monocle.macros.GenLens[Data.AsterismEdit](_.value) + implicit val eqAsterismEdit: cats.Eq[Data.AsterismEdit] = cats.Eq.fromUniversalEquals + implicit val showAsterismEdit: cats.Show[Data.AsterismEdit] = cats.Show.fromToString + implicit val jsonDecoderAsterismEdit: io.circe.Decoder[Data.AsterismEdit] = io.circe.generic.semiauto.deriveDecoder[Data.AsterismEdit] + } + val asterismEdit: monocle.Iso[Data, Data.AsterismEdit] = monocle.Focus[Data](_.asterismEdit) + implicit val eqData: cats.Eq[Data] = cats.Eq.fromUniversalEquals + implicit val showData: cats.Show[Data] = cats.Show.fromToString + implicit val jsonDecoderData: io.circe.Decoder[Data] = io.circe.generic.semiauto.deriveDecoder[Data] + } + val varEncoder: io.circe.Encoder.AsObject[Variables] = Variables.jsonEncoderVariables + val dataDecoder: io.circe.Decoder[Data] = Data.jsonDecoderData + def subscribe[F[_]](programId: clue.data.Input[ProgramId] = clue.data.Ignore)(implicit client: clue.StreamingClient[F, LucumaODB]) = client.subscribe(this).withDescriptor("LucumaSubscription").withInput(Variables(programId)) +} +// format: on diff --git a/otel4s/src/main/scala/clue/otel4s/Otel4sMiddleware.scala b/otel4s/src/main/scala/clue/otel4s/Otel4sMiddleware.scala index 7600cbe6..1c28f01e 100644 --- a/otel4s/src/main/scala/clue/otel4s/Otel4sMiddleware.scala +++ b/otel4s/src/main/scala/clue/otel4s/Otel4sMiddleware.scala @@ -87,6 +87,20 @@ object Otel4sMiddleware: ): PersistentStreamingClient[F, S, CP, CE] = apply(client, identityMod[F], emptyAttrs[F]) + /** + * The span name: `clue--`. + * + * The descriptor wins when present precisely because it is the one name the call site chose; the + * document summary (`-`) is the fallback, and it degrades to placeholders rather than + * failing when the document cannot be parsed. + */ + private[otel4s] def spanName( + operation: String, + document: GraphQLQuery, + descriptor: Option[String] + ): String = + s"clue-$operation-${descriptor.getOrElse(document.querySummary)}" + private[otel4s] def commonAttributes( document: GraphQLQuery, operationName: Option[String], @@ -150,7 +164,7 @@ class Otel4sFetchClient[F[_]: {MonadCancelThrow, Tracer as T}, P: TraceHeaderInj operationName: Option[String], descriptor: Option[String] ) = spanMod( - T.spanBuilder(s"clue-$operation-${descriptor.getOrElse(document.querySummary)}") + T.spanBuilder(Otel4sMiddleware.spanName(operation, document, descriptor)) .withSpanKind(SpanKind.Client) .addAttributes(Otel4sMiddleware.commonAttributes(document, operationName, descriptor)*) ) diff --git a/otel4s/src/test/scala/clue/otel4s/Otel4sMiddlewareSpec.scala b/otel4s/src/test/scala/clue/otel4s/Otel4sMiddlewareSpec.scala index 9c90e7b7..0c356e39 100644 --- a/otel4s/src/test/scala/clue/otel4s/Otel4sMiddlewareSpec.scala +++ b/otel4s/src/test/scala/clue/otel4s/Otel4sMiddlewareSpec.scala @@ -7,19 +7,45 @@ import clue.model.GraphQLQuery import io.circe.Json import io.circe.JsonObject import munit.FunSuite +import org.typelevel.otel4s.Attribute class Otel4sMiddlewareSpec extends FunSuite: - // Anonymous document: descriptor is the only way to name it well. private val doc = GraphQLQuery( "query ObservationVisits($id: ID!) { observation(id: $id) { id } }" ) + // An anonymous document has no name of its own, so the descriptor is the only way to name it well. + private val anonymousDoc = GraphQLQuery("query ($id: ID!) { observation(id: $id) { id } }") + + private def attribute(attrs: List[Attribute[?]], key: String): Option[String] = + attrs.collectFirst { case a if a.key.name == key => a.value.toString } + test("commonAttributes emits clue.descriptor when a descriptor is set") { - val attrs = Otel4sMiddleware.commonAttributes(doc, None, Some("ObservationVisits")) - val descriptor = attrs.find(_.key.name == "clue.descriptor") - assert(descriptor.isDefined, "expected a clue.descriptor attribute") - assertEquals(descriptor.get.value: Any, "ObservationVisits") + val attrs = Otel4sMiddleware.commonAttributes(doc, None, Some("ObservationVisits")) + assertEquals(attribute(attrs, "clue.descriptor"), Some("ObservationVisits")) + } + + test("commonAttributes reports the operation type read from the document") { + assertEquals( + attribute(Otel4sMiddleware.commonAttributes(doc, None, None), "graphql.operation.type"), + Some("query") + ) + assertEquals( + attribute( + Otel4sMiddleware.commonAttributes(GraphQLQuery("mutation { addFoo { id } }"), None, None), + "graphql.operation.type" + ), + Some("mutation") + ) + } + + test("commonAttributes keeps operationName and the descriptor separate") { + // `operationName` goes on the wire; the descriptor is tracing-only. They are distinct + // attributes and neither implies the other. + val attrs = Otel4sMiddleware.commonAttributes(doc, Some("ObservationVisits"), Some("ObsQuery")) + assertEquals(attribute(attrs, "graphql.operation.name"), Some("ObservationVisits")) + assertEquals(attribute(attrs, "clue.descriptor"), Some("ObsQuery")) } test("commonAttributes omits clue.descriptor when no descriptor is set") { @@ -34,6 +60,41 @@ class Otel4sMiddlewareSpec extends FunSuite: assert(attrs.exists(_.key.name == "graphql.document"), "expected a graphql.document attribute") } + test("spanName prefers the descriptor over the document's own name") { + assertEquals( + Otel4sMiddleware.spanName("request", doc, Some("ObsQuery")), + "clue-request-ObsQuery" + ) + } + + test("spanName falls back to the document's operation name") { + assertEquals( + Otel4sMiddleware.spanName("request", doc, None), + "clue-request-query-ObservationVisits" + ) + } + + test("spanName falls back to the first root field for an anonymous document") { + assertEquals( + Otel4sMiddleware.spanName("request", anonymousDoc, None), + "clue-request-query-observation" + ) + } + + test("spanName carries the operation through, so subscriptions are distinguishable") { + assertEquals( + Otel4sMiddleware.spanName("subscribe", doc, Some("ObsQuery")), + "clue-subscribe-ObsQuery" + ) + } + + test("spanName degrades to placeholders rather than failing on an unparseable document") { + assertEquals( + Otel4sMiddleware.spanName("request", GraphQLQuery("not a graphql document"), None), + "clue-request--" + ) + } + test("requestBodySize reflects the serialized payload and grows with variables") { // The serialized request is JSON wrapping the document, so its length must at least contain // the document text … diff --git a/otel4s/src/test/scala/clue/otel4s/Otel4sRequestSpanSpec.scala b/otel4s/src/test/scala/clue/otel4s/Otel4sRequestSpanSpec.scala index 7bc07c72..0c05554a 100644 --- a/otel4s/src/test/scala/clue/otel4s/Otel4sRequestSpanSpec.scala +++ b/otel4s/src/test/scala/clue/otel4s/Otel4sRequestSpanSpec.scala @@ -25,7 +25,9 @@ import org.typelevel.otel4s.trace.Tracer * * Only the wiring is inspected, so a noop tracer suffices and no request is ever run. Asserting on * the emitted span instead would need an in-memory SDK, and otel4s publishes none for the 1.0 API - * (`otel4s-sdk-testkit` stops at 0.19.0). + * (`otel4s-sdk-testkit` stops at 0.19.0). For the same reason, span names and attributes are + * asserted against `Otel4sMiddleware.spanName` / `commonAttributes` directly in + * [[Otel4sMiddlewareSpec]] rather than against what a collector would see. */ class Otel4sRequestSpanSpec extends FunSuite: From 87533b205c46aab9e37a78bb0ecdc73129e3ae6c Mon Sep 17 00:00:00 2001 From: Carlos Quiroz Date: Wed, 29 Jul 2026 18:43:55 -0400 Subject: [PATCH 14/14] Generate fully-qualified client types instead of mid-body imports --- core/src/test/scala/clue/DescriptorSpec.scala | 10 ----- .../src/main/scala/test/LucumaMutation.scala | 6 +-- .../src/main/scala/test/LucumaQuery.scala | 6 +-- .../src/main/scala/test/LucumaQuery2.scala | 6 +-- .../src/main/scala/test/LucumaQuery3.scala | 6 +-- .../scala/test/StarWarsDescriptorQuery.scala | 6 +-- .../src/main/scala/test/StarWarsInclude.scala | 6 +-- .../src/main/scala/test/StarWarsQuery.scala | 6 +-- .../src/main/scala/test/StarWarsQuery2.scala | 6 +-- .../src/main/scala/test/StarWarsQuery3.scala | 6 +-- .../src/main/scala/test/StarWarsQuery4.scala | 6 +-- .../src/main/scala/clue/gen/QueryGen.scala | 18 ++++---- .../main/scala/clue/model/GraphQLQuery.scala | 14 ++----- .../scala/clue/model/GraphQLQuerySpec.scala | 8 ---- .../scala/clue/otel4s/Otel4sMiddleware.scala | 20 ++------- .../clue/otel4s/Otel4sMiddlewareSpec.scala | 41 ------------------- .../clue/otel4s/Otel4sRequestSpanSpec.scala | 11 ----- 17 files changed, 35 insertions(+), 147 deletions(-) diff --git a/core/src/test/scala/clue/DescriptorSpec.scala b/core/src/test/scala/clue/DescriptorSpec.scala index 228e9a34..54113868 100644 --- a/core/src/test/scala/clue/DescriptorSpec.scala +++ b/core/src/test/scala/clue/DescriptorSpec.scala @@ -74,14 +74,4 @@ class DescriptorSpec extends CatsEffectSuite: "MySub".some ) - test("a subscription without a descriptor passes none"): - assertIO(descriptorSeen(_.subscribe(Op).withInput(JsonObject.empty).use_), none) - - test("withDescriptor is independent of operationName"): - // `operationName` goes on the wire, the descriptor does not; setting one must not set the other. - val client = Recorder(Ref.unsafe[IO, Option[Option[String]]](none)) - val applied = client.request(Op, "NamedOp".some).withDescriptor("MyQuery") - assertEquals(applied.operationName, "NamedOp".some) - assertEquals(applied.descriptor, "MyQuery".some) - end DescriptorSpec diff --git a/gen/output/src/main/scala/test/LucumaMutation.scala b/gen/output/src/main/scala/test/LucumaMutation.scala index 1527edb4..35d7b06c 100644 --- a/gen/output/src/main/scala/test/LucumaMutation.scala +++ b/gen/output/src/main/scala/test/LucumaMutation.scala @@ -46,9 +46,7 @@ object LucumaMutation extends GraphQLOperation[LucumaODB] { } val varEncoder: io.circe.Encoder.AsObject[Variables] = Variables.jsonEncoderVariables val dataDecoder: io.circe.Decoder[Data] = Data.jsonDecoderData - import clue.ClientAppliedF - import clue.FetchClientWithPars - def apply[F[_]]: ClientAppliedF[F, LucumaODB, ClientAppliedFP] = new ClientAppliedF[F, LucumaODB, ClientAppliedFP] { def applyP[P](client: FetchClientWithPars[F, P, LucumaODB]) = new ClientAppliedFP(client) } - class ClientAppliedFP[F[_], P](val client: FetchClientWithPars[F, P, LucumaODB]) { def execute(asterismId: AsterismId, modParams: P => P = identity) = client.request(LucumaMutation).withDescriptor("LucumaMutation").withInput(Variables(asterismId), modParams) } + def apply[F[_]]: clue.ClientAppliedF[F, LucumaODB, ClientAppliedFP] = new clue.ClientAppliedF[F, LucumaODB, ClientAppliedFP] { def applyP[P](client: clue.FetchClientWithPars[F, P, LucumaODB]) = new ClientAppliedFP(client) } + class ClientAppliedFP[F[_], P](val client: clue.FetchClientWithPars[F, P, LucumaODB]) { def execute(asterismId: AsterismId, modParams: P => P = identity) = client.request(LucumaMutation).withDescriptor("LucumaMutation").withInput(Variables(asterismId), modParams) } } // format: on diff --git a/gen/output/src/main/scala/test/LucumaQuery.scala b/gen/output/src/main/scala/test/LucumaQuery.scala index cfa9d0c3..7e079e65 100644 --- a/gen/output/src/main/scala/test/LucumaQuery.scala +++ b/gen/output/src/main/scala/test/LucumaQuery.scala @@ -98,9 +98,7 @@ object LucumaQuery extends GraphQLOperation[LucumaODB] { } val varEncoder: io.circe.Encoder.AsObject[Variables] = Variables.jsonEncoderVariables val dataDecoder: io.circe.Decoder[Data] = Data.jsonDecoderData - import clue.ClientAppliedF - import clue.FetchClientWithPars - def apply[F[_]]: ClientAppliedF[F, LucumaODB, ClientAppliedFP] = new ClientAppliedF[F, LucumaODB, ClientAppliedFP] { def applyP[P](client: FetchClientWithPars[F, P, LucumaODB]) = new ClientAppliedFP(client) } - class ClientAppliedFP[F[_], P](val client: FetchClientWithPars[F, P, LucumaODB]) { def query(modParams: P => P = identity) = client.request(LucumaQuery).withDescriptor("LucumaQuery").withInput(Variables(), modParams) } + def apply[F[_]]: clue.ClientAppliedF[F, LucumaODB, ClientAppliedFP] = new clue.ClientAppliedF[F, LucumaODB, ClientAppliedFP] { def applyP[P](client: clue.FetchClientWithPars[F, P, LucumaODB]) = new ClientAppliedFP(client) } + class ClientAppliedFP[F[_], P](val client: clue.FetchClientWithPars[F, P, LucumaODB]) { def query(modParams: P => P = identity) = client.request(LucumaQuery).withDescriptor("LucumaQuery").withInput(Variables(), modParams) } } // format: on diff --git a/gen/output/src/main/scala/test/LucumaQuery2.scala b/gen/output/src/main/scala/test/LucumaQuery2.scala index f256ebdc..9925e542 100644 --- a/gen/output/src/main/scala/test/LucumaQuery2.scala +++ b/gen/output/src/main/scala/test/LucumaQuery2.scala @@ -80,9 +80,7 @@ object LucumaQuery2 extends GraphQLOperation[LucumaODB] { } val varEncoder: io.circe.Encoder.AsObject[Variables] = Variables.jsonEncoderVariables val dataDecoder: io.circe.Decoder[Data] = Data.jsonDecoderData - import clue.ClientAppliedF - import clue.FetchClientWithPars - def apply[F[_]]: ClientAppliedF[F, LucumaODB, ClientAppliedFP] = new ClientAppliedF[F, LucumaODB, ClientAppliedFP] { def applyP[P](client: FetchClientWithPars[F, P, LucumaODB]) = new ClientAppliedFP(client) } - class ClientAppliedFP[F[_], P](val client: FetchClientWithPars[F, P, LucumaODB]) { def query(modParams: P => P = identity) = client.request(LucumaQuery2).withDescriptor("LucumaQuery2").withInput(Variables(), modParams) } + def apply[F[_]]: clue.ClientAppliedF[F, LucumaODB, ClientAppliedFP] = new clue.ClientAppliedF[F, LucumaODB, ClientAppliedFP] { def applyP[P](client: clue.FetchClientWithPars[F, P, LucumaODB]) = new ClientAppliedFP(client) } + class ClientAppliedFP[F[_], P](val client: clue.FetchClientWithPars[F, P, LucumaODB]) { def query(modParams: P => P = identity) = client.request(LucumaQuery2).withDescriptor("LucumaQuery2").withInput(Variables(), modParams) } } // format: on diff --git a/gen/output/src/main/scala/test/LucumaQuery3.scala b/gen/output/src/main/scala/test/LucumaQuery3.scala index 91b1c39d..c6bc17c2 100644 --- a/gen/output/src/main/scala/test/LucumaQuery3.scala +++ b/gen/output/src/main/scala/test/LucumaQuery3.scala @@ -87,9 +87,7 @@ object LucumaQuery3 extends GraphQLOperation[LucumaODB] { } val varEncoder: io.circe.Encoder.AsObject[Variables] = Variables.jsonEncoderVariables val dataDecoder: io.circe.Decoder[Data] = Data.jsonDecoderData - import clue.ClientAppliedF - import clue.FetchClientWithPars - def apply[F[_]]: ClientAppliedF[F, LucumaODB, ClientAppliedFP] = new ClientAppliedF[F, LucumaODB, ClientAppliedFP] { def applyP[P](client: FetchClientWithPars[F, P, LucumaODB]) = new ClientAppliedFP(client) } - class ClientAppliedFP[F[_], P](val client: FetchClientWithPars[F, P, LucumaODB]) { def query(modParams: P => P = identity) = client.request(LucumaQuery3).withDescriptor("LucumaQuery3").withInput(Variables(), modParams) } + def apply[F[_]]: clue.ClientAppliedF[F, LucumaODB, ClientAppliedFP] = new clue.ClientAppliedF[F, LucumaODB, ClientAppliedFP] { def applyP[P](client: clue.FetchClientWithPars[F, P, LucumaODB]) = new ClientAppliedFP(client) } + class ClientAppliedFP[F[_], P](val client: clue.FetchClientWithPars[F, P, LucumaODB]) { def query(modParams: P => P = identity) = client.request(LucumaQuery3).withDescriptor("LucumaQuery3").withInput(Variables(), modParams) } } // format: on diff --git a/gen/output/src/main/scala/test/StarWarsDescriptorQuery.scala b/gen/output/src/main/scala/test/StarWarsDescriptorQuery.scala index 4fa92658..5b8876e1 100644 --- a/gen/output/src/main/scala/test/StarWarsDescriptorQuery.scala +++ b/gen/output/src/main/scala/test/StarWarsDescriptorQuery.scala @@ -47,9 +47,7 @@ object StarWarsDescriptorQuery extends GraphQLOperation[StarWars] { } val varEncoder: io.circe.Encoder.AsObject[Variables] = Variables.jsonEncoderVariables val dataDecoder: io.circe.Decoder[Data] = Data.jsonDecoderData - import clue.ClientAppliedF - import clue.FetchClientWithPars - def apply[F[_]]: ClientAppliedF[F, StarWars, ClientAppliedFP] = new ClientAppliedF[F, StarWars, ClientAppliedFP] { def applyP[P](client: FetchClientWithPars[F, P, StarWars]) = new ClientAppliedFP(client) } - class ClientAppliedFP[F[_], P](val client: FetchClientWithPars[F, P, StarWars]) { def query(charId: String, modParams: P => P = identity) = client.request(StarWarsDescriptorQuery).withInput(Variables(charId), modParams) } + def apply[F[_]]: clue.ClientAppliedF[F, StarWars, ClientAppliedFP] = new clue.ClientAppliedF[F, StarWars, ClientAppliedFP] { def applyP[P](client: clue.FetchClientWithPars[F, P, StarWars]) = new ClientAppliedFP(client) } + class ClientAppliedFP[F[_], P](val client: clue.FetchClientWithPars[F, P, StarWars]) { def query(charId: String, modParams: P => P = identity) = client.request(StarWarsDescriptorQuery).withInput(Variables(charId), modParams) } } // format: on diff --git a/gen/output/src/main/scala/test/StarWarsInclude.scala b/gen/output/src/main/scala/test/StarWarsInclude.scala index 192d43ff..e6b92494 100644 --- a/gen/output/src/main/scala/test/StarWarsInclude.scala +++ b/gen/output/src/main/scala/test/StarWarsInclude.scala @@ -51,9 +51,7 @@ object StarWarsInclude extends GraphQLOperation[StarWars] { } val varEncoder: io.circe.Encoder.AsObject[Variables] = Variables.jsonEncoderVariables val dataDecoder: io.circe.Decoder[Data] = Data.jsonDecoderData - import clue.ClientAppliedF - import clue.FetchClientWithPars - def apply[F[_]]: ClientAppliedF[F, StarWars, ClientAppliedFP] = new ClientAppliedF[F, StarWars, ClientAppliedFP] { def applyP[P](client: FetchClientWithPars[F, P, StarWars]) = new ClientAppliedFP(client) } - class ClientAppliedFP[F[_], P](val client: FetchClientWithPars[F, P, StarWars]) { def query(humanId: String, skipId: Boolean, withName: Boolean, modParams: P => P = identity) = client.request(StarWarsInclude).withDescriptor("StarWarsInclude").withInput(Variables(humanId, skipId, withName), modParams) } + def apply[F[_]]: clue.ClientAppliedF[F, StarWars, ClientAppliedFP] = new clue.ClientAppliedF[F, StarWars, ClientAppliedFP] { def applyP[P](client: clue.FetchClientWithPars[F, P, StarWars]) = new ClientAppliedFP(client) } + class ClientAppliedFP[F[_], P](val client: clue.FetchClientWithPars[F, P, StarWars]) { def query(humanId: String, skipId: Boolean, withName: Boolean, modParams: P => P = identity) = client.request(StarWarsInclude).withDescriptor("StarWarsInclude").withInput(Variables(humanId, skipId, withName), modParams) } } // format: on diff --git a/gen/output/src/main/scala/test/StarWarsQuery.scala b/gen/output/src/main/scala/test/StarWarsQuery.scala index 2be62020..c5031b01 100644 --- a/gen/output/src/main/scala/test/StarWarsQuery.scala +++ b/gen/output/src/main/scala/test/StarWarsQuery.scala @@ -111,9 +111,7 @@ object StarWarsQuery extends GraphQLOperation[StarWars] { } val varEncoder: io.circe.Encoder.AsObject[Variables] = Variables.jsonEncoderVariables val dataDecoder: io.circe.Decoder[Data] = Data.jsonDecoderData - import clue.ClientAppliedF - import clue.FetchClientWithPars - def apply[F[_]]: ClientAppliedF[F, StarWars, ClientAppliedFP] = new ClientAppliedF[F, StarWars, ClientAppliedFP] { def applyP[P](client: FetchClientWithPars[F, P, StarWars]) = new ClientAppliedFP(client) } - class ClientAppliedFP[F[_], P](val client: FetchClientWithPars[F, P, StarWars]) { def query(charId: String, modParams: P => P = identity) = client.request(StarWarsQuery).withDescriptor("StarWarsQuery").withInput(Variables(charId), modParams) } + def apply[F[_]]: clue.ClientAppliedF[F, StarWars, ClientAppliedFP] = new clue.ClientAppliedF[F, StarWars, ClientAppliedFP] { def applyP[P](client: clue.FetchClientWithPars[F, P, StarWars]) = new ClientAppliedFP(client) } + class ClientAppliedFP[F[_], P](val client: clue.FetchClientWithPars[F, P, StarWars]) { def query(charId: String, modParams: P => P = identity) = client.request(StarWarsQuery).withDescriptor("StarWarsQuery").withInput(Variables(charId), modParams) } } // format: on diff --git a/gen/output/src/main/scala/test/StarWarsQuery2.scala b/gen/output/src/main/scala/test/StarWarsQuery2.scala index b39fcc1f..3a5d851f 100644 --- a/gen/output/src/main/scala/test/StarWarsQuery2.scala +++ b/gen/output/src/main/scala/test/StarWarsQuery2.scala @@ -131,10 +131,8 @@ object Wrapper extends Something { } val varEncoder: io.circe.Encoder.AsObject[Variables] = Variables.jsonEncoderVariables val dataDecoder: io.circe.Decoder[Data] = Data.jsonDecoderData - import clue.ClientAppliedF - import clue.FetchClientWithPars - def apply[F[_]]: ClientAppliedF[F, StarWars, ClientAppliedFP] = new ClientAppliedF[F, StarWars, ClientAppliedFP] { def applyP[P](client: FetchClientWithPars[F, P, StarWars]) = new ClientAppliedFP(client) } - class ClientAppliedFP[F[_], P](val client: FetchClientWithPars[F, P, StarWars]) { def query(charId: String, modParams: P => P = identity) = client.request(StarWarsQuery2).withDescriptor("StarWarsQuery2").withInput(Variables(charId), modParams) } + def apply[F[_]]: clue.ClientAppliedF[F, StarWars, ClientAppliedFP] = new clue.ClientAppliedF[F, StarWars, ClientAppliedFP] { def applyP[P](client: clue.FetchClientWithPars[F, P, StarWars]) = new ClientAppliedFP(client) } + class ClientAppliedFP[F[_], P](val client: clue.FetchClientWithPars[F, P, StarWars]) { def query(charId: String, modParams: P => P = identity) = client.request(StarWarsQuery2).withDescriptor("StarWarsQuery2").withInput(Variables(charId), modParams) } } } // format: on diff --git a/gen/output/src/main/scala/test/StarWarsQuery3.scala b/gen/output/src/main/scala/test/StarWarsQuery3.scala index 7aa08e9b..a7909876 100644 --- a/gen/output/src/main/scala/test/StarWarsQuery3.scala +++ b/gen/output/src/main/scala/test/StarWarsQuery3.scala @@ -102,9 +102,7 @@ object StarWarsQuery3 extends GraphQLOperation[StarWars] { } val varEncoder: io.circe.Encoder.AsObject[Variables] = Variables.jsonEncoderVariables val dataDecoder: io.circe.Decoder[Data] = Data.jsonDecoderData - import clue.ClientAppliedF - import clue.FetchClientWithPars - def apply[F[_]]: ClientAppliedF[F, StarWars, ClientAppliedFP] = new ClientAppliedF[F, StarWars, ClientAppliedFP] { def applyP[P](client: FetchClientWithPars[F, P, StarWars]) = new ClientAppliedFP(client) } - class ClientAppliedFP[F[_], P](val client: FetchClientWithPars[F, P, StarWars]) { def query(charId: String, modParams: P => P = identity) = client.request(StarWarsQuery3).withDescriptor("StarWarsQuery3").withInput(Variables(charId), modParams) } + def apply[F[_]]: clue.ClientAppliedF[F, StarWars, ClientAppliedFP] = new clue.ClientAppliedF[F, StarWars, ClientAppliedFP] { def applyP[P](client: clue.FetchClientWithPars[F, P, StarWars]) = new ClientAppliedFP(client) } + class ClientAppliedFP[F[_], P](val client: clue.FetchClientWithPars[F, P, StarWars]) { def query(charId: String, modParams: P => P = identity) = client.request(StarWarsQuery3).withDescriptor("StarWarsQuery3").withInput(Variables(charId), modParams) } } // format: on diff --git a/gen/output/src/main/scala/test/StarWarsQuery4.scala b/gen/output/src/main/scala/test/StarWarsQuery4.scala index a860590c..b081e796 100644 --- a/gen/output/src/main/scala/test/StarWarsQuery4.scala +++ b/gen/output/src/main/scala/test/StarWarsQuery4.scala @@ -36,9 +36,7 @@ object StarWarsQuery4 extends GraphQLOperation[StarWars] { } val varEncoder: io.circe.Encoder.AsObject[Variables] = Variables.jsonEncoderVariables val dataDecoder: io.circe.Decoder[Data] = Data.jsonDecoderData - import clue.ClientAppliedF - import clue.FetchClientWithPars - def apply[F[_]]: ClientAppliedF[F, StarWars, ClientAppliedFP] = new ClientAppliedF[F, StarWars, ClientAppliedFP] { def applyP[P](client: FetchClientWithPars[F, P, StarWars]) = new ClientAppliedFP(client) } - class ClientAppliedFP[F[_], P](val client: FetchClientWithPars[F, P, StarWars]) { def query(charId: String, modParams: P => P = identity) = client.request(StarWarsQuery4).withDescriptor("StarWarsQuery4").withInput(Variables(charId), modParams) } + def apply[F[_]]: clue.ClientAppliedF[F, StarWars, ClientAppliedFP] = new clue.ClientAppliedF[F, StarWars, ClientAppliedFP] { def applyP[P](client: clue.FetchClientWithPars[F, P, StarWars]) = new ClientAppliedFP(client) } + class ClientAppliedFP[F[_], P](val client: clue.FetchClientWithPars[F, P, StarWars]) { def query(charId: String, modParams: P => P = identity) = client.request(StarWarsQuery4).withDescriptor("StarWarsQuery4").withInput(Variables(charId), modParams) } } // format: on diff --git a/gen/rules/src/main/scala/clue/gen/QueryGen.scala b/gen/rules/src/main/scala/clue/gen/QueryGen.scala index c28ff192..791f0930 100644 --- a/gen/rules/src/main/scala/clue/gen/QueryGen.scala +++ b/gen/rules/src/main/scala/clue/gen/QueryGen.scala @@ -802,14 +802,10 @@ trait QueryGen extends Generator { }) .toList val applied = - q"""def apply[F[_]]: ClientAppliedF[F, $schemaType, ClientAppliedFP] = - new ClientAppliedF[F, $schemaType, ClientAppliedFP] { - def applyP[P](client: FetchClientWithPars[F, P, $schemaType]) = new ClientAppliedFP(client) + q"""def apply[F[_]]: clue.ClientAppliedF[F, $schemaType, ClientAppliedFP] = + new clue.ClientAppliedF[F, $schemaType, ClientAppliedFP] { + def applyP[P](client: clue.FetchClientWithPars[F, P, $schemaType]) = new ClientAppliedFP(client) }""" - // `ClientAppliedF` and `FetchClientWithPars` are only referenced by query/mutation - // operations, so import them here rather than for every operation kind. - val clientImports = - List(q"import clue.ClientAppliedF", q"import clue.FetchClientWithPars") // When descriptor generation is enabled, tag the request/subscription with the // object name so otel4s can name the span `clue--`. val objTerm = Term.Name(objName) @@ -826,9 +822,9 @@ trait QueryGen extends Generator { parentBody ++ (operation match { case _: UntypedQuery => - clientImports ++ List( + List( applied, - q"""class ClientAppliedFP[F[_], P](val client: FetchClientWithPars[F, P, $schemaType]) { + q"""class ClientAppliedFP[F[_], P](val client: clue.FetchClientWithPars[F, P, $schemaType]) { def query(...${(paramss.head :+ param"modParams: P => P = identity") +: paramss.tail}) = $afterRequest.withInput(Variables(...$variablesNames), modParams) } @@ -836,9 +832,9 @@ trait QueryGen extends Generator { ) case _: UntypedMutation => - clientImports ++ List( + List( applied, - q"""class ClientAppliedFP[F[_], P](val client: FetchClientWithPars[F, P, $schemaType]) { + q"""class ClientAppliedFP[F[_], P](val client: clue.FetchClientWithPars[F, P, $schemaType]) { def execute(...${(paramss.head :+ param"modParams: P => P = identity") +: paramss.tail}) = $afterRequest.withInput(Variables(...$variablesNames), modParams) } diff --git a/model/src/main/scala/clue/model/GraphQLQuery.scala b/model/src/main/scala/clue/model/GraphQLQuery.scala index 8bc6b400..2c79e113 100644 --- a/model/src/main/scala/clue/model/GraphQLQuery.scala +++ b/model/src/main/scala/clue/model/GraphQLQuery.scala @@ -10,22 +10,16 @@ opaque type GraphQLQuery = String object GraphQLQuery: def apply(query: String): GraphQLQuery = query - // The operation type keyword, matched at the start of a line so that leading comments and - // fragment definitions are skipped. We spell "start of a line" as `(?:^|\n)` rather than the - // `(?m)` flag because the latter is unsupported by Scala.js unless the linker targets ES2018+ - // (and `model` cross-compiles to JS), whereas this form uses only ES5 regex features. + // Matched at a line start, so leading comments and fragment definitions are skipped. Spelled + // `(?:^|\n)` rather than `(?m)^`, which Scala.js rejects unless the linker targets ES2018+. private val OperationType = """(?:^|\n)[ \t]*(query|mutation|subscription)\b""".r - // The name of an explicitly named operation: the word right after the operation type keyword, - // followed by variable definitions, a directive or the selection set. private val NamedOperation = """^(?:query|mutation|subscription)\s+(\w+)(?=\s*[({@])""".r - // Fallback for anonymous operations: the first word after the first '{'. private val FirstField = """\{(?:.|\s)*?(\w+)""".r - // The operation type, paired with the document from the operation keyword onwards, so that - // anything preceding it (comments, fragment definitions) cannot be mistaken for the operation. - // A document that is a bare selection set is an anonymous query per the spec. + // Paired with the document from the keyword onwards, so nothing preceding it can be mistaken for + // the operation. A bare selection set is an anonymous query per the spec. private def operation(query: GraphQLQuery): Option[(String, String)] = OperationType .findFirstMatchIn(query) diff --git a/model/src/test/scala/clue/model/GraphQLQuerySpec.scala b/model/src/test/scala/clue/model/GraphQLQuerySpec.scala index 337c2912..c7e69d8e 100644 --- a/model/src/test/scala/clue/model/GraphQLQuerySpec.scala +++ b/model/src/test/scala/clue/model/GraphQLQuerySpec.scala @@ -46,10 +46,6 @@ final class GraphQLQuerySpec extends FunSuite: ) } - test("querySummary falls back to the first root field for an anonymous query without vars") { - check("query { character { id } }", "query-character") - } - test("querySummary handles a named mutation") { check("mutation AddFoo($x: ID!) { addFoo(id: $x) { id } }", "mutation-AddFoo") } @@ -127,10 +123,6 @@ final class GraphQLQuerySpec extends FunSuite: assertEquals(GraphQLQuery("subscription Sub { x }").operationType, "subscription".some) } - test("operationType defaults a bare selection set to query") { - assertEquals(GraphQLQuery("{ character { id } }").operationType, "query".some) - } - test("operationType is empty when no operation can be found") { assertEquals(GraphQLQuery("not a graphql document").operationType, none) } diff --git a/otel4s/src/main/scala/clue/otel4s/Otel4sMiddleware.scala b/otel4s/src/main/scala/clue/otel4s/Otel4sMiddleware.scala index 1c28f01e..33f1f4d8 100644 --- a/otel4s/src/main/scala/clue/otel4s/Otel4sMiddleware.scala +++ b/otel4s/src/main/scala/clue/otel4s/Otel4sMiddleware.scala @@ -42,8 +42,7 @@ object Otel4sMiddleware: private def emptyAttrs[F[_]: Applicative] : (GraphQLQuery, Option[JsonObject]) => F[List[Attribute[?]]] = (_, _) => List.empty.pure - // Transport-specific attributes for the one-shot HTTP clients, supplied at construction rather - // than baked into `Otel4sFetchClient`. The streaming clients extend it but speak WebSocket, and + // Supplied only here: the streaming clients extend `Otel4sFetchClient` but speak WebSocket, and // must not describe their requests as HTTP. private val httpAttributes: List[Attribute[?]] = List(HttpAttributes.HttpRequestMethod("POST")) @@ -87,13 +86,7 @@ object Otel4sMiddleware: ): PersistentStreamingClient[F, S, CP, CE] = apply(client, identityMod[F], emptyAttrs[F]) - /** - * The span name: `clue--`. - * - * The descriptor wins when present precisely because it is the one name the call site chose; the - * document summary (`-`) is the fallback, and it degrades to placeholders rather than - * failing when the document cannot be parsed. - */ + /** The descriptor is the name the call site chose, so it wins over the document's own summary. */ private[otel4s] def spanName( operation: String, document: GraphQLQuery, @@ -138,9 +131,6 @@ object Otel4sMiddleware: ) *> span.setStatus(StatusCode.Error, "GraphQL request returned errors") .getOrElse(F.unit) - /** - * Inculde the requested query size as an attribute - */ private[otel4s] def requestBodySize( document: GraphQLQuery, operationName: Option[String], @@ -153,9 +143,8 @@ class Otel4sFetchClient[F[_]: {MonadCancelThrow, Tracer as T}, P: TraceHeaderInj wrapped: FetchClientWithPars[F, P, S], spanMod: Otel4sMiddleware.SpanMod[F], additionalAttributesF: (GraphQLQuery, Option[JsonObject]) => F[List[Attribute[?]]], - // Attributes describing the transport, added to the `request` span. Empty unless the - // construction site can vouch for one, so that a client speaking a protocol it doesn't know - // about stays silent rather than inheriting someone else's claim. + // Empty unless the construction site can vouch for a transport, so a client stays silent rather + // than inheriting someone else's claim. private[otel4s] val transportAttributes: List[Attribute[?]] = Nil ) extends FetchClientWithPars[F, P, S]: protected def traceSpan( @@ -169,7 +158,6 @@ class Otel4sFetchClient[F[_]: {MonadCancelThrow, Tracer as T}, P: TraceHeaderInj .addAttributes(Otel4sMiddleware.commonAttributes(document, operationName, descriptor)*) ) - // Merge existing extensions with otel trace parent headers. protected def mergeOtelExtension( extensions: Option[JsonObject], traceHeaders: Map[String, String] diff --git a/otel4s/src/test/scala/clue/otel4s/Otel4sMiddlewareSpec.scala b/otel4s/src/test/scala/clue/otel4s/Otel4sMiddlewareSpec.scala index 0c356e39..56cfa704 100644 --- a/otel4s/src/test/scala/clue/otel4s/Otel4sMiddlewareSpec.scala +++ b/otel4s/src/test/scala/clue/otel4s/Otel4sMiddlewareSpec.scala @@ -15,29 +15,14 @@ class Otel4sMiddlewareSpec extends FunSuite: "query ObservationVisits($id: ID!) { observation(id: $id) { id } }" ) - // An anonymous document has no name of its own, so the descriptor is the only way to name it well. - private val anonymousDoc = GraphQLQuery("query ($id: ID!) { observation(id: $id) { id } }") - private def attribute(attrs: List[Attribute[?]], key: String): Option[String] = attrs.collectFirst { case a if a.key.name == key => a.value.toString } - test("commonAttributes emits clue.descriptor when a descriptor is set") { - val attrs = Otel4sMiddleware.commonAttributes(doc, None, Some("ObservationVisits")) - assertEquals(attribute(attrs, "clue.descriptor"), Some("ObservationVisits")) - } - test("commonAttributes reports the operation type read from the document") { assertEquals( attribute(Otel4sMiddleware.commonAttributes(doc, None, None), "graphql.operation.type"), Some("query") ) - assertEquals( - attribute( - Otel4sMiddleware.commonAttributes(GraphQLQuery("mutation { addFoo { id } }"), None, None), - "graphql.operation.type" - ), - Some("mutation") - ) } test("commonAttributes keeps operationName and the descriptor separate") { @@ -55,11 +40,6 @@ class Otel4sMiddlewareSpec extends FunSuite: ) } - test("commonAttributes still emits the graphql document regardless of descriptor") { - val attrs = Otel4sMiddleware.commonAttributes(doc, None, Some("X")) - assert(attrs.exists(_.key.name == "graphql.document"), "expected a graphql.document attribute") - } - test("spanName prefers the descriptor over the document's own name") { assertEquals( Otel4sMiddleware.spanName("request", doc, Some("ObsQuery")), @@ -74,27 +54,6 @@ class Otel4sMiddlewareSpec extends FunSuite: ) } - test("spanName falls back to the first root field for an anonymous document") { - assertEquals( - Otel4sMiddleware.spanName("request", anonymousDoc, None), - "clue-request-query-observation" - ) - } - - test("spanName carries the operation through, so subscriptions are distinguishable") { - assertEquals( - Otel4sMiddleware.spanName("subscribe", doc, Some("ObsQuery")), - "clue-subscribe-ObsQuery" - ) - } - - test("spanName degrades to placeholders rather than failing on an unparseable document") { - assertEquals( - Otel4sMiddleware.spanName("request", GraphQLQuery("not a graphql document"), None), - "clue-request--" - ) - } - test("requestBodySize reflects the serialized payload and grows with variables") { // The serialized request is JSON wrapping the document, so its length must at least contain // the document text … diff --git a/otel4s/src/test/scala/clue/otel4s/Otel4sRequestSpanSpec.scala b/otel4s/src/test/scala/clue/otel4s/Otel4sRequestSpanSpec.scala index 0c05554a..41a589a3 100644 --- a/otel4s/src/test/scala/clue/otel4s/Otel4sRequestSpanSpec.scala +++ b/otel4s/src/test/scala/clue/otel4s/Otel4sRequestSpanSpec.scala @@ -17,17 +17,6 @@ import org.typelevel.otel4s.trace.Tracer /** * Regression coverage for the transport-specific attributes on the `request` span. - * - * `Otel4sStreamingClient extends Otel4sFetchClient`, so it used to inherit `requestInternal` - * wholesale — including a hardcoded `http.request.method: POST`. That mislabelled queries and - * mutations sent as WebSocket `Subscribe` frames as HTTP POST. The attribute is now supplied at - * construction, and only the fetch factory supplies it. - * - * Only the wiring is inspected, so a noop tracer suffices and no request is ever run. Asserting on - * the emitted span instead would need an in-memory SDK, and otel4s publishes none for the 1.0 API - * (`otel4s-sdk-testkit` stops at 0.19.0). For the same reason, span names and attributes are - * asserted against `Otel4sMiddleware.spanName` / `commonAttributes` directly in - * [[Otel4sMiddlewareSpec]] rather than against what a collector would see. */ class Otel4sRequestSpanSpec extends FunSuite: