Add GraphQL DataFetcher Instrumentation (#10998)
Co-authored-by: Lauri Tulmin <ltulmin@splunk.com>
This commit is contained in:
parent
6b66434258
commit
692739a364
|
@ -1,5 +1,12 @@
|
|||
# Settings for the GraphQL instrumentation
|
||||
|
||||
| System property | Type | Default | Description |
|
||||
| ------------------------------------------------------ | ------- | ------- | ------------------------------------------------------------------------------------------ |
|
||||
|--------------------------------------------------------|---------|---------|--------------------------------------------------------------------------------------------|
|
||||
| `otel.instrumentation.graphql.query-sanitizer.enabled` | Boolean | `true` | Whether to remove sensitive information from query source that is added as span attribute. |
|
||||
|
||||
# Settings for the GraphQL 20 instrumentation
|
||||
|
||||
| System property | Type | Default | Description |
|
||||
|-------------------------------------------------------------|---------|---------|-----------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `otel.instrumentation.graphql.data-fetcher.enabled` | Boolean | `false` | Whether to create spans for data fetchers. |
|
||||
| `otel.instrumentation.graphql.trivial-data-fetcher.enabled` | Boolean | `false` | Whether to create spans for trivial data fetchers. A trivial data fetcher is one that simply maps data from an object to a field. |
|
||||
|
|
|
@ -23,6 +23,10 @@ dependencies {
|
|||
testImplementation(project(":instrumentation:graphql-java:graphql-java-common:testing"))
|
||||
}
|
||||
|
||||
tasks.withType<Test>().configureEach {
|
||||
jvmArgs("-Dotel.instrumentation.graphql.data-fetcher.enabled=true")
|
||||
}
|
||||
|
||||
if (findProperty("testLatestDeps") as Boolean) {
|
||||
otelJava {
|
||||
minJavaVersionSupported.set(JavaVersion.VERSION_11)
|
||||
|
|
|
@ -16,10 +16,18 @@ public final class GraphqlSingletons {
|
|||
private static final boolean QUERY_SANITIZATION_ENABLED =
|
||||
InstrumentationConfig.get()
|
||||
.getBoolean("otel.instrumentation.graphql.query-sanitizer.enabled", true);
|
||||
private static final boolean DATA_FETCHER_ENABLED =
|
||||
InstrumentationConfig.get()
|
||||
.getBoolean("otel.instrumentation.graphql.data-fetcher.enabled", false);
|
||||
private static final boolean TRIVIAL_DATA_FETCHER_ENABLED =
|
||||
InstrumentationConfig.get()
|
||||
.getBoolean("otel.instrumentation.graphql.trivial-data-fetcher.enabled", false);
|
||||
|
||||
private static final GraphQLTelemetry TELEMETRY =
|
||||
GraphQLTelemetry.builder(GlobalOpenTelemetry.get())
|
||||
.setSanitizeQuery(QUERY_SANITIZATION_ENABLED)
|
||||
.setDataFetcherInstrumentationEnabled(DATA_FETCHER_ENABLED)
|
||||
.setTrivialDataFetcherInstrumentationEnabled(TRIVIAL_DATA_FETCHER_ENABLED)
|
||||
.build();
|
||||
|
||||
private GraphqlSingletons() {}
|
||||
|
|
|
@ -23,4 +23,9 @@ public class GraphqlTest extends AbstractGraphqlTest {
|
|||
|
||||
@Override
|
||||
protected void configure(GraphQL.Builder builder) {}
|
||||
|
||||
@Override
|
||||
protected boolean hasDataFetcherSpans() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,12 +6,13 @@
|
|||
package io.opentelemetry.instrumentation.graphql.v20_0;
|
||||
|
||||
import graphql.execution.instrumentation.Instrumentation;
|
||||
import graphql.schema.DataFetchingEnvironment;
|
||||
import io.opentelemetry.api.OpenTelemetry;
|
||||
import io.opentelemetry.instrumentation.api.instrumenter.Instrumenter;
|
||||
import io.opentelemetry.instrumentation.graphql.internal.OpenTelemetryInstrumentationHelper;
|
||||
|
||||
@SuppressWarnings("AbbreviationAsWordInName")
|
||||
public final class GraphQLTelemetry {
|
||||
private static final String INSTRUMENTATION_NAME = "io.opentelemetry.graphql-java-20.0";
|
||||
|
||||
/** Returns a new {@link GraphQLTelemetry} configured with the given {@link OpenTelemetry}. */
|
||||
public static GraphQLTelemetry create(OpenTelemetry openTelemetry) {
|
||||
|
@ -26,17 +27,24 @@ public final class GraphQLTelemetry {
|
|||
}
|
||||
|
||||
private final OpenTelemetryInstrumentationHelper helper;
|
||||
private final Instrumenter<DataFetchingEnvironment, Void> dataFetcherInstrumenter;
|
||||
private final boolean createSpansForTrivialDataFetcher;
|
||||
|
||||
GraphQLTelemetry(OpenTelemetry openTelemetry, boolean sanitizeQuery) {
|
||||
helper =
|
||||
OpenTelemetryInstrumentationHelper.create(
|
||||
openTelemetry, INSTRUMENTATION_NAME, sanitizeQuery);
|
||||
GraphQLTelemetry(
|
||||
OpenTelemetry openTelemetry,
|
||||
boolean sanitizeQuery,
|
||||
Instrumenter<DataFetchingEnvironment, Void> dataFetcherInstrumenter,
|
||||
boolean createSpansForTrivialDataFetcher) {
|
||||
helper = GraphqlInstrumenterFactory.createInstrumentationHelper(openTelemetry, sanitizeQuery);
|
||||
this.dataFetcherInstrumenter = dataFetcherInstrumenter;
|
||||
this.createSpansForTrivialDataFetcher = createSpansForTrivialDataFetcher;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a new {@link Instrumentation} that generates telemetry for received GraphQL requests.
|
||||
*/
|
||||
public Instrumentation newInstrumentation() {
|
||||
return new OpenTelemetryInstrumentation(helper);
|
||||
return new OpenTelemetryInstrumentation(
|
||||
helper, dataFetcherInstrumenter, createSpansForTrivialDataFetcher);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -16,6 +16,10 @@ public final class GraphQLTelemetryBuilder {
|
|||
|
||||
private boolean sanitizeQuery = true;
|
||||
|
||||
private boolean dataFetcherInstrumentationEnabled = false;
|
||||
|
||||
private boolean trivialDataFetcherInstrumentationEnabled = false;
|
||||
|
||||
GraphQLTelemetryBuilder(OpenTelemetry openTelemetry) {
|
||||
this.openTelemetry = openTelemetry;
|
||||
}
|
||||
|
@ -27,11 +31,35 @@ public final class GraphQLTelemetryBuilder {
|
|||
return this;
|
||||
}
|
||||
|
||||
/** Sets whether spans are created for GraphQL Data Fetchers. Default is {@code false}. */
|
||||
@CanIgnoreReturnValue
|
||||
public GraphQLTelemetryBuilder setDataFetcherInstrumentationEnabled(
|
||||
boolean dataFetcherInstrumentationEnabled) {
|
||||
this.dataFetcherInstrumentationEnabled = dataFetcherInstrumentationEnabled;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets whether spans are created for trivial GraphQL Data Fetchers. A trivial DataFetcher is one
|
||||
* that simply maps data from an object to a field. Default is {@code false}.
|
||||
*/
|
||||
@CanIgnoreReturnValue
|
||||
public GraphQLTelemetryBuilder setTrivialDataFetcherInstrumentationEnabled(
|
||||
boolean trivialDataFetcherInstrumentationEnabled) {
|
||||
this.trivialDataFetcherInstrumentationEnabled = trivialDataFetcherInstrumentationEnabled;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a new {@link GraphQLTelemetry} with the settings of this {@link
|
||||
* GraphQLTelemetryBuilder}.
|
||||
*/
|
||||
public GraphQLTelemetry build() {
|
||||
return new GraphQLTelemetry(openTelemetry, sanitizeQuery);
|
||||
return new GraphQLTelemetry(
|
||||
openTelemetry,
|
||||
sanitizeQuery,
|
||||
GraphqlInstrumenterFactory.createDataFetcherInstrumenter(
|
||||
openTelemetry, dataFetcherInstrumentationEnabled),
|
||||
trivialDataFetcherInstrumentationEnabled);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,50 @@
|
|||
/*
|
||||
* Copyright The OpenTelemetry Authors
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
package io.opentelemetry.instrumentation.graphql.v20_0;
|
||||
|
||||
import graphql.execution.ResultPath;
|
||||
import io.opentelemetry.context.Context;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ConcurrentMap;
|
||||
|
||||
final class Graphql20OpenTelemetryInstrumentationState
|
||||
extends io.opentelemetry.instrumentation.graphql.internal.OpenTelemetryInstrumentationState {
|
||||
private static final String ROOT_PATH = ResultPath.rootPath().toString();
|
||||
|
||||
private final ConcurrentMap<String, Context> contextStorage = new ConcurrentHashMap<>();
|
||||
|
||||
@Override
|
||||
public Context getContext() {
|
||||
return contextStorage.getOrDefault(ROOT_PATH, Context.current());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setContext(Context context) {
|
||||
this.contextStorage.put(ROOT_PATH, context);
|
||||
}
|
||||
|
||||
public Context setContextForPath(ResultPath resultPath, Context context) {
|
||||
return contextStorage.putIfAbsent(resultPath.toString(), context);
|
||||
}
|
||||
|
||||
public Context getParentContextForPath(ResultPath resultPath) {
|
||||
|
||||
// Navigate up the path until we find the closest parent context
|
||||
for (ResultPath currentPath = resultPath.getParent();
|
||||
currentPath != null;
|
||||
currentPath = currentPath.getParent()) {
|
||||
|
||||
Context parentContext = contextStorage.getOrDefault(currentPath.toString(), null);
|
||||
|
||||
if (parentContext != null) {
|
||||
return parentContext;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to returning the context for ROOT_PATH
|
||||
return getContext();
|
||||
}
|
||||
}
|
|
@ -0,0 +1,39 @@
|
|||
/*
|
||||
* Copyright The OpenTelemetry Authors
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
package io.opentelemetry.instrumentation.graphql.v20_0;
|
||||
|
||||
import graphql.schema.DataFetchingEnvironment;
|
||||
import io.opentelemetry.api.common.AttributeKey;
|
||||
import io.opentelemetry.api.common.AttributesBuilder;
|
||||
import io.opentelemetry.context.Context;
|
||||
import io.opentelemetry.instrumentation.api.instrumenter.AttributesExtractor;
|
||||
import javax.annotation.Nullable;
|
||||
|
||||
final class GraphqlDataFetcherAttributesExtractor
|
||||
implements AttributesExtractor<DataFetchingEnvironment, Void> {
|
||||
|
||||
// NOTE: These are not part of the Semantic Convention and are subject to change
|
||||
private static final AttributeKey<String> GRAPHQL_FIELD_NAME =
|
||||
AttributeKey.stringKey("graphql.field.name");
|
||||
private static final AttributeKey<String> GRAPHQL_FIELD_PATH =
|
||||
AttributeKey.stringKey("graphql.field.path");
|
||||
|
||||
@Override
|
||||
public void onStart(
|
||||
AttributesBuilder attributes, Context parentContext, DataFetchingEnvironment environment) {
|
||||
attributes
|
||||
.put(GRAPHQL_FIELD_NAME, environment.getExecutionStepInfo().getField().getName())
|
||||
.put(GRAPHQL_FIELD_PATH, environment.getExecutionStepInfo().getPath().toString());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onEnd(
|
||||
AttributesBuilder attributes,
|
||||
Context context,
|
||||
DataFetchingEnvironment environment,
|
||||
@Nullable Void unused,
|
||||
@Nullable Throwable error) {}
|
||||
}
|
|
@ -0,0 +1,40 @@
|
|||
/*
|
||||
* Copyright The OpenTelemetry Authors
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
package io.opentelemetry.instrumentation.graphql.v20_0;
|
||||
|
||||
import graphql.schema.DataFetchingEnvironment;
|
||||
import io.opentelemetry.api.OpenTelemetry;
|
||||
import io.opentelemetry.instrumentation.api.instrumenter.Instrumenter;
|
||||
import io.opentelemetry.instrumentation.api.instrumenter.SpanStatusExtractor;
|
||||
import io.opentelemetry.instrumentation.graphql.internal.OpenTelemetryInstrumentationHelper;
|
||||
|
||||
final class GraphqlInstrumenterFactory {
|
||||
|
||||
private static final String INSTRUMENTATION_NAME = "io.opentelemetry.graphql-java-20.0";
|
||||
|
||||
static OpenTelemetryInstrumentationHelper createInstrumentationHelper(
|
||||
OpenTelemetry openTelemetry, boolean sanitizeQuery) {
|
||||
return OpenTelemetryInstrumentationHelper.create(
|
||||
openTelemetry, INSTRUMENTATION_NAME, sanitizeQuery);
|
||||
}
|
||||
|
||||
static Instrumenter<DataFetchingEnvironment, Void> createDataFetcherInstrumenter(
|
||||
OpenTelemetry openTelemetry, boolean enabled) {
|
||||
return Instrumenter.<DataFetchingEnvironment, Void>builder(
|
||||
openTelemetry,
|
||||
INSTRUMENTATION_NAME,
|
||||
environment -> environment.getExecutionStepInfo().getField().getName())
|
||||
.addAttributesExtractor(new GraphqlDataFetcherAttributesExtractor())
|
||||
.setSpanStatusExtractor(
|
||||
(spanStatusBuilder, environment, unused, error) ->
|
||||
SpanStatusExtractor.getDefault()
|
||||
.extract(spanStatusBuilder, environment, null, error))
|
||||
.setEnabled(enabled)
|
||||
.buildInstrumenter();
|
||||
}
|
||||
|
||||
private GraphqlInstrumenterFactory() {}
|
||||
}
|
|
@ -8,6 +8,7 @@ package io.opentelemetry.instrumentation.graphql.v20_0;
|
|||
import static graphql.execution.instrumentation.InstrumentationState.ofState;
|
||||
|
||||
import graphql.ExecutionResult;
|
||||
import graphql.execution.ResultPath;
|
||||
import graphql.execution.instrumentation.InstrumentationContext;
|
||||
import graphql.execution.instrumentation.InstrumentationState;
|
||||
import graphql.execution.instrumentation.SimplePerformantInstrumentation;
|
||||
|
@ -16,19 +17,31 @@ import graphql.execution.instrumentation.parameters.InstrumentationExecuteOperat
|
|||
import graphql.execution.instrumentation.parameters.InstrumentationExecutionParameters;
|
||||
import graphql.execution.instrumentation.parameters.InstrumentationFieldFetchParameters;
|
||||
import graphql.schema.DataFetcher;
|
||||
import graphql.schema.DataFetchingEnvironment;
|
||||
import io.opentelemetry.context.Context;
|
||||
import io.opentelemetry.context.Scope;
|
||||
import io.opentelemetry.instrumentation.api.instrumenter.Instrumenter;
|
||||
import io.opentelemetry.instrumentation.graphql.internal.OpenTelemetryInstrumentationHelper;
|
||||
import io.opentelemetry.instrumentation.graphql.internal.OpenTelemetryInstrumentationState;
|
||||
import java.util.concurrent.CompletionStage;
|
||||
|
||||
final class OpenTelemetryInstrumentation extends SimplePerformantInstrumentation {
|
||||
private final OpenTelemetryInstrumentationHelper helper;
|
||||
private final Instrumenter<DataFetchingEnvironment, Void> dataFetcherInstrumenter;
|
||||
private final boolean createSpansForTrivialDataFetcher;
|
||||
|
||||
OpenTelemetryInstrumentation(OpenTelemetryInstrumentationHelper helper) {
|
||||
OpenTelemetryInstrumentation(
|
||||
OpenTelemetryInstrumentationHelper helper,
|
||||
Instrumenter<DataFetchingEnvironment, Void> dataFetcherInstrumenter,
|
||||
boolean createSpansForTrivialDataFetcher) {
|
||||
this.helper = helper;
|
||||
this.dataFetcherInstrumenter = dataFetcherInstrumenter;
|
||||
this.createSpansForTrivialDataFetcher = createSpansForTrivialDataFetcher;
|
||||
}
|
||||
|
||||
@Override
|
||||
public InstrumentationState createState(InstrumentationCreateStateParameters parameters) {
|
||||
return new OpenTelemetryInstrumentationState();
|
||||
return new Graphql20OpenTelemetryInstrumentationState();
|
||||
}
|
||||
|
||||
@Override
|
||||
|
@ -50,7 +63,49 @@ final class OpenTelemetryInstrumentation extends SimplePerformantInstrumentation
|
|||
DataFetcher<?> dataFetcher,
|
||||
InstrumentationFieldFetchParameters parameters,
|
||||
InstrumentationState rawState) {
|
||||
OpenTelemetryInstrumentationState state = ofState(rawState);
|
||||
return helper.instrumentDataFetcher(dataFetcher, state);
|
||||
|
||||
Graphql20OpenTelemetryInstrumentationState state = ofState(rawState);
|
||||
|
||||
return environment -> {
|
||||
ResultPath path = environment.getExecutionStepInfo().getPath();
|
||||
Context parentContext = state.getParentContextForPath(path);
|
||||
|
||||
if (!dataFetcherInstrumenter.shouldStart(parentContext, environment)
|
||||
|| (parameters.isTrivialDataFetcher() && !createSpansForTrivialDataFetcher)) {
|
||||
// Propagate context only, do not create span
|
||||
try (Scope ignored = parentContext.makeCurrent()) {
|
||||
return dataFetcher.get(environment);
|
||||
}
|
||||
}
|
||||
|
||||
// Start span
|
||||
Context childContext = dataFetcherInstrumenter.start(parentContext, environment);
|
||||
state.setContextForPath(path, childContext);
|
||||
|
||||
boolean isCompletionStage = false;
|
||||
|
||||
try (Scope ignored = childContext.makeCurrent()) {
|
||||
Object fieldValue = dataFetcher.get(environment);
|
||||
|
||||
isCompletionStage = fieldValue instanceof CompletionStage;
|
||||
|
||||
if (isCompletionStage) {
|
||||
return ((CompletionStage<?>) fieldValue)
|
||||
.whenComplete(
|
||||
(result, throwable) ->
|
||||
dataFetcherInstrumenter.end(childContext, environment, null, throwable));
|
||||
}
|
||||
|
||||
return fieldValue;
|
||||
|
||||
} catch (Throwable throwable) {
|
||||
dataFetcherInstrumenter.end(childContext, environment, null, throwable);
|
||||
throw throwable;
|
||||
} finally {
|
||||
if (!isCompletionStage) {
|
||||
dataFetcherInstrumenter.end(childContext, environment, null, null);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
@ -5,10 +5,19 @@
|
|||
|
||||
package io.opentelemetry.instrumentation.graphql.v20_0;
|
||||
|
||||
import static io.opentelemetry.sdk.testing.assertj.OpenTelemetryAssertions.assertThat;
|
||||
import static io.opentelemetry.sdk.testing.assertj.OpenTelemetryAssertions.equalTo;
|
||||
|
||||
import graphql.ExecutionResult;
|
||||
import graphql.GraphQL;
|
||||
import io.opentelemetry.api.common.AttributeKey;
|
||||
import io.opentelemetry.api.trace.SpanKind;
|
||||
import io.opentelemetry.instrumentation.graphql.AbstractGraphqlTest;
|
||||
import io.opentelemetry.instrumentation.testing.junit.InstrumentationExtension;
|
||||
import io.opentelemetry.instrumentation.testing.junit.LibraryInstrumentationExtension;
|
||||
import io.opentelemetry.sdk.trace.data.SpanData;
|
||||
import io.opentelemetry.semconv.incubating.GraphqlIncubatingAttributes;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.RegisterExtension;
|
||||
|
||||
public class GraphqlTest extends AbstractGraphqlTest {
|
||||
|
@ -16,6 +25,12 @@ public class GraphqlTest extends AbstractGraphqlTest {
|
|||
@RegisterExtension
|
||||
private static final InstrumentationExtension testing = LibraryInstrumentationExtension.create();
|
||||
|
||||
private static final AttributeKey<String> GRAPHQL_FIELD_NAME =
|
||||
AttributeKey.stringKey("graphql.field.name");
|
||||
|
||||
private static final AttributeKey<String> GRAPHQL_FIELD_PATH =
|
||||
AttributeKey.stringKey("graphql.field.path");
|
||||
|
||||
@Override
|
||||
protected InstrumentationExtension getTesting() {
|
||||
return testing;
|
||||
|
@ -23,7 +38,207 @@ public class GraphqlTest extends AbstractGraphqlTest {
|
|||
|
||||
@Override
|
||||
protected void configure(GraphQL.Builder builder) {
|
||||
GraphQLTelemetry telemetry = GraphQLTelemetry.builder(testing.getOpenTelemetry()).build();
|
||||
GraphQLTelemetry telemetry =
|
||||
GraphQLTelemetry.builder(testing.getOpenTelemetry())
|
||||
.setDataFetcherInstrumentationEnabled(true)
|
||||
.build();
|
||||
builder.instrumentation(telemetry.newInstrumentation());
|
||||
}
|
||||
|
||||
@Override
|
||||
protected boolean hasDataFetcherSpans() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Test
|
||||
void createSpansForDataFetchers() {
|
||||
// Arrange
|
||||
GraphQLTelemetry telemetry =
|
||||
GraphQLTelemetry.builder(testing.getOpenTelemetry())
|
||||
.setDataFetcherInstrumentationEnabled(true)
|
||||
.build();
|
||||
|
||||
GraphQL graphql =
|
||||
GraphQL.newGraphQL(graphqlSchema).instrumentation(telemetry.newInstrumentation()).build();
|
||||
|
||||
// Act
|
||||
ExecutionResult result =
|
||||
graphql.execute(
|
||||
""
|
||||
+ " query findBookById {\n"
|
||||
+ " bookById(id: \"book-1\") {\n"
|
||||
+ " name\n"
|
||||
+ " author {\n"
|
||||
+ " name\n"
|
||||
+ " }\n"
|
||||
+ " }\n"
|
||||
+ " }");
|
||||
|
||||
// Assert
|
||||
assertThat(result.getErrors()).isEmpty();
|
||||
|
||||
testing.waitAndAssertTraces(
|
||||
trace ->
|
||||
trace.hasSpansSatisfyingExactly(
|
||||
span ->
|
||||
span.hasName("query findBookById")
|
||||
.hasKind(SpanKind.INTERNAL)
|
||||
.hasNoParent()
|
||||
.hasAttributesSatisfyingExactly(
|
||||
equalTo(
|
||||
GraphqlIncubatingAttributes.GRAPHQL_OPERATION_NAME, "findBookById"),
|
||||
equalTo(GraphqlIncubatingAttributes.GRAPHQL_OPERATION_TYPE, "query"),
|
||||
normalizedQueryEqualsTo(
|
||||
GraphqlIncubatingAttributes.GRAPHQL_DOCUMENT,
|
||||
"query findBookById { bookById(id: ?) { name author { name } } }")),
|
||||
span ->
|
||||
span.hasName("bookById")
|
||||
.hasKind(SpanKind.INTERNAL)
|
||||
.hasParent(spanWithName("query findBookById"))
|
||||
.hasAttributesSatisfyingExactly(
|
||||
equalTo(GRAPHQL_FIELD_NAME, "bookById"),
|
||||
equalTo(GRAPHQL_FIELD_PATH, "/bookById")),
|
||||
span ->
|
||||
span.hasName("fetchBookById")
|
||||
.hasKind(SpanKind.INTERNAL)
|
||||
.hasParent(spanWithName("bookById")),
|
||||
span ->
|
||||
span.hasName("author")
|
||||
.hasKind(SpanKind.INTERNAL)
|
||||
.hasParent(spanWithName("bookById"))
|
||||
.hasAttributesSatisfyingExactly(
|
||||
equalTo(GRAPHQL_FIELD_NAME, "author"),
|
||||
equalTo(GRAPHQL_FIELD_PATH, "/bookById/author"))));
|
||||
}
|
||||
|
||||
@Test
|
||||
void createSpanForTrivialDataFetchers() {
|
||||
// Arrange
|
||||
GraphQLTelemetry telemetry =
|
||||
GraphQLTelemetry.builder(testing.getOpenTelemetry())
|
||||
.setDataFetcherInstrumentationEnabled(true)
|
||||
.setTrivialDataFetcherInstrumentationEnabled(true)
|
||||
.build();
|
||||
|
||||
GraphQL graphql =
|
||||
GraphQL.newGraphQL(graphqlSchema).instrumentation(telemetry.newInstrumentation()).build();
|
||||
|
||||
// Act
|
||||
ExecutionResult result =
|
||||
graphql.execute(
|
||||
""
|
||||
+ " query findBookById {\n"
|
||||
+ " bookById(id: \"book-1\") {\n"
|
||||
+ " name\n"
|
||||
+ " author {\n"
|
||||
+ " name\n"
|
||||
+ " }\n"
|
||||
+ " }\n"
|
||||
+ " }");
|
||||
|
||||
// Assert
|
||||
assertThat(result.getErrors()).isEmpty();
|
||||
|
||||
testing.waitAndAssertTraces(
|
||||
trace ->
|
||||
trace.hasSpansSatisfyingExactly(
|
||||
span ->
|
||||
span.hasName("query findBookById")
|
||||
.hasKind(SpanKind.INTERNAL)
|
||||
.hasNoParent()
|
||||
.hasAttributesSatisfyingExactly(
|
||||
equalTo(
|
||||
GraphqlIncubatingAttributes.GRAPHQL_OPERATION_NAME, "findBookById"),
|
||||
equalTo(GraphqlIncubatingAttributes.GRAPHQL_OPERATION_TYPE, "query"),
|
||||
normalizedQueryEqualsTo(
|
||||
GraphqlIncubatingAttributes.GRAPHQL_DOCUMENT,
|
||||
"query findBookById { bookById(id: ?) { name author { name } } }")),
|
||||
span ->
|
||||
span.hasName("bookById")
|
||||
.hasKind(SpanKind.INTERNAL)
|
||||
.hasParent(spanWithName("query findBookById"))
|
||||
.hasAttributesSatisfyingExactly(
|
||||
equalTo(GRAPHQL_FIELD_NAME, "bookById"),
|
||||
equalTo(GRAPHQL_FIELD_PATH, "/bookById")),
|
||||
span ->
|
||||
span.hasName("fetchBookById")
|
||||
.hasKind(SpanKind.INTERNAL)
|
||||
.hasParent(spanWithName("bookById")),
|
||||
span ->
|
||||
span.hasName("name")
|
||||
.hasKind(SpanKind.INTERNAL)
|
||||
.hasParent(spanWithName("bookById"))
|
||||
.hasAttributesSatisfyingExactly(
|
||||
equalTo(GRAPHQL_FIELD_NAME, "name"),
|
||||
equalTo(GRAPHQL_FIELD_PATH, "/bookById/name")),
|
||||
span ->
|
||||
span.hasName("author")
|
||||
.hasKind(SpanKind.INTERNAL)
|
||||
.hasParent(spanWithName("bookById"))
|
||||
.hasAttributesSatisfyingExactly(
|
||||
equalTo(GRAPHQL_FIELD_NAME, "author"),
|
||||
equalTo(GRAPHQL_FIELD_PATH, "/bookById/author")),
|
||||
span ->
|
||||
span.hasName("name")
|
||||
.hasKind(SpanKind.INTERNAL)
|
||||
.hasParent(spanWithName("author"))
|
||||
.hasAttributesSatisfyingExactly(
|
||||
equalTo(GRAPHQL_FIELD_NAME, "name"),
|
||||
equalTo(GRAPHQL_FIELD_PATH, "/bookById/author/name"))));
|
||||
}
|
||||
|
||||
@Test
|
||||
void noDataFetcherSpansCreated() {
|
||||
// Arrange
|
||||
GraphQLTelemetry telemetry =
|
||||
GraphQLTelemetry.builder(testing.getOpenTelemetry())
|
||||
.setDataFetcherInstrumentationEnabled(false)
|
||||
.setTrivialDataFetcherInstrumentationEnabled(true)
|
||||
.build();
|
||||
|
||||
GraphQL graphql =
|
||||
GraphQL.newGraphQL(graphqlSchema).instrumentation(telemetry.newInstrumentation()).build();
|
||||
|
||||
// Act
|
||||
ExecutionResult result =
|
||||
graphql.execute(
|
||||
""
|
||||
+ " query findBookById {\n"
|
||||
+ " bookById(id: \"book-1\") {\n"
|
||||
+ " name\n"
|
||||
+ " author {\n"
|
||||
+ " name\n"
|
||||
+ " }\n"
|
||||
+ " }\n"
|
||||
+ " }");
|
||||
|
||||
// Assert
|
||||
assertThat(result.getErrors()).isEmpty();
|
||||
|
||||
testing.waitAndAssertTraces(
|
||||
trace ->
|
||||
trace.hasSpansSatisfyingExactly(
|
||||
span ->
|
||||
span.hasName("query findBookById")
|
||||
.hasKind(SpanKind.INTERNAL)
|
||||
.hasNoParent()
|
||||
.hasAttributesSatisfyingExactly(
|
||||
equalTo(
|
||||
GraphqlIncubatingAttributes.GRAPHQL_OPERATION_NAME, "findBookById"),
|
||||
equalTo(GraphqlIncubatingAttributes.GRAPHQL_OPERATION_TYPE, "query"),
|
||||
normalizedQueryEqualsTo(
|
||||
GraphqlIncubatingAttributes.GRAPHQL_DOCUMENT,
|
||||
"query findBookById { bookById(id: ?) { name author { name } } }")),
|
||||
span ->
|
||||
span.hasName("fetchBookById")
|
||||
.hasKind(SpanKind.INTERNAL)
|
||||
.hasParent(spanWithName("query findBookById"))));
|
||||
}
|
||||
|
||||
private static SpanData spanWithName(String name) {
|
||||
return testing.spans().stream()
|
||||
.filter(span -> span.getName().equals(name))
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,155 @@
|
|||
/*
|
||||
* Copyright The OpenTelemetry Authors
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
package io.opentelemetry.instrumentation.graphql.v20_0;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
import graphql.execution.ResultPath;
|
||||
import io.opentelemetry.context.Context;
|
||||
import io.opentelemetry.context.ContextKey;
|
||||
import io.opentelemetry.context.Scope;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class OpenTelemetryInstrumentationStateTest {
|
||||
|
||||
@Test
|
||||
void setContextSetsContextForRootPath() {
|
||||
// Arrange
|
||||
Graphql20OpenTelemetryInstrumentationState state =
|
||||
new Graphql20OpenTelemetryInstrumentationState();
|
||||
Context context = Context.root().with(ContextKey.named("New"), "Context");
|
||||
|
||||
// Act
|
||||
state.setContext(context);
|
||||
|
||||
// Assert
|
||||
assertEquals(context, state.getParentContextForPath(ResultPath.rootPath()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getContextGetsContextForRootPath() {
|
||||
// Arrange
|
||||
Graphql20OpenTelemetryInstrumentationState state =
|
||||
new Graphql20OpenTelemetryInstrumentationState();
|
||||
Context context = Context.root().with(ContextKey.named("New"), "Context");
|
||||
|
||||
// Act
|
||||
state.setContextForPath(ResultPath.rootPath(), context);
|
||||
|
||||
// Assert
|
||||
assertEquals(context, state.getContext());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getContextReturnsCurrentContext() {
|
||||
// Arrange
|
||||
Graphql20OpenTelemetryInstrumentationState state =
|
||||
new Graphql20OpenTelemetryInstrumentationState();
|
||||
Context context = Context.root().with(ContextKey.named("New"), "Context");
|
||||
|
||||
Context result;
|
||||
|
||||
// Act
|
||||
try (Scope ignored = context.makeCurrent()) {
|
||||
result = state.getContext();
|
||||
}
|
||||
|
||||
// Assert
|
||||
assertEquals(context, result);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getParentContextForPathReturnsCurrentContextForRootPath() {
|
||||
// Arrange
|
||||
Graphql20OpenTelemetryInstrumentationState state =
|
||||
new Graphql20OpenTelemetryInstrumentationState();
|
||||
Context context = Context.root().with(ContextKey.named("New"), "Context");
|
||||
|
||||
Context result;
|
||||
|
||||
// Act and Assert
|
||||
try (Scope ignored = context.makeCurrent()) {
|
||||
result = state.getParentContextForPath(ResultPath.rootPath());
|
||||
}
|
||||
|
||||
// Assert
|
||||
assertEquals(context, result);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getParentContextForPathReturnsRootPathContextForRootPath() {
|
||||
// Arrange
|
||||
Graphql20OpenTelemetryInstrumentationState state =
|
||||
new Graphql20OpenTelemetryInstrumentationState();
|
||||
Context context = Context.root().with(ContextKey.named("New"), "Context");
|
||||
|
||||
state.setContextForPath(ResultPath.rootPath(), context);
|
||||
|
||||
// Act
|
||||
Context result = state.getParentContextForPath(ResultPath.rootPath());
|
||||
|
||||
// Assert
|
||||
assertEquals(context, result);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getParentContextForPathReturnsCurrentContextForDeepPath() {
|
||||
// Arrange
|
||||
Graphql20OpenTelemetryInstrumentationState state =
|
||||
new Graphql20OpenTelemetryInstrumentationState();
|
||||
Context context = Context.root().with(ContextKey.named("New"), "Context");
|
||||
|
||||
ResultPath resultPath = ResultPath.parse("/segment1/segment2/segment3");
|
||||
|
||||
Context result;
|
||||
|
||||
// Act and Assert
|
||||
try (Scope ignored = context.makeCurrent()) {
|
||||
result = state.getParentContextForPath(resultPath);
|
||||
}
|
||||
|
||||
// Assert
|
||||
assertEquals(context, result);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getParentContextForPathReturnsRootPathContextForDeepPath() {
|
||||
// Arrange
|
||||
Graphql20OpenTelemetryInstrumentationState state =
|
||||
new Graphql20OpenTelemetryInstrumentationState();
|
||||
Context context = Context.root().with(ContextKey.named("New"), "Context");
|
||||
|
||||
state.setContextForPath(ResultPath.rootPath(), context);
|
||||
|
||||
ResultPath resultPath = ResultPath.parse("/segment1/segment2/segment3");
|
||||
|
||||
// Act
|
||||
Context result = state.getParentContextForPath(resultPath);
|
||||
|
||||
// Assert
|
||||
assertEquals(context, result);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getParentContextForPathReturnsParentContextForDeepPath() {
|
||||
// Arrange
|
||||
Graphql20OpenTelemetryInstrumentationState state =
|
||||
new Graphql20OpenTelemetryInstrumentationState();
|
||||
Context rootPathContext = Context.root().with(ContextKey.named("Name"), "RootPath");
|
||||
Context childContext = Context.root().with(ContextKey.named("Name"), "Child");
|
||||
|
||||
state.setContextForPath(ResultPath.rootPath(), rootPathContext);
|
||||
state.setContextForPath(ResultPath.parse("/segment1/segment2"), childContext);
|
||||
|
||||
// Act
|
||||
Context result =
|
||||
state.getParentContextForPath(
|
||||
ResultPath.parse("/segment1/segment2/segment3/segment4/segment5"));
|
||||
|
||||
// Assert
|
||||
assertEquals(childContext, result);
|
||||
}
|
||||
}
|
|
@ -19,13 +19,13 @@ import javax.annotation.Nullable;
|
|||
*/
|
||||
final class GraphqlAttributesExtractor
|
||||
implements AttributesExtractor<OpenTelemetryInstrumentationState, ExecutionResult> {
|
||||
// https://github.com/open-telemetry/semantic-conventions/blob/main/docs/database/graphql.md
|
||||
private static final AttributeKey<String> OPERATION_NAME =
|
||||
AttributeKey.stringKey("graphql.operation.name");
|
||||
private static final AttributeKey<String> OPERATION_TYPE =
|
||||
AttributeKey.stringKey("graphql.operation.type");
|
||||
// copied from GraphqlIncubatingAttributes
|
||||
private static final AttributeKey<String> GRAPHQL_DOCUMENT =
|
||||
AttributeKey.stringKey("graphql.document");
|
||||
private static final AttributeKey<String> GRAPHQL_OPERATION_NAME =
|
||||
AttributeKey.stringKey("graphql.operation.name");
|
||||
private static final AttributeKey<String> GRAPHQL_OPERATION_TYPE =
|
||||
AttributeKey.stringKey("graphql.operation.type");
|
||||
|
||||
@Override
|
||||
public void onStart(
|
||||
|
@ -40,9 +40,10 @@ final class GraphqlAttributesExtractor
|
|||
OpenTelemetryInstrumentationState request,
|
||||
@Nullable ExecutionResult response,
|
||||
@Nullable Throwable error) {
|
||||
attributes.put(OPERATION_NAME, request.getOperationName());
|
||||
attributes.put(GRAPHQL_OPERATION_NAME, request.getOperationName());
|
||||
if (request.getOperation() != null) {
|
||||
attributes.put(OPERATION_TYPE, request.getOperation().name().toLowerCase(Locale.ROOT));
|
||||
attributes.put(
|
||||
GRAPHQL_OPERATION_TYPE, request.getOperation().name().toLowerCase(Locale.ROOT));
|
||||
}
|
||||
attributes.put(GRAPHQL_DOCUMENT, request.getQuery());
|
||||
}
|
||||
|
|
|
@ -13,41 +13,41 @@ import io.opentelemetry.context.Context;
|
|||
* This class is internal and is hence not for public use. Its APIs are unstable and can change at
|
||||
* any time.
|
||||
*/
|
||||
public final class OpenTelemetryInstrumentationState implements InstrumentationState {
|
||||
public class OpenTelemetryInstrumentationState implements InstrumentationState {
|
||||
private Context context;
|
||||
private Operation operation;
|
||||
private String operationName;
|
||||
private String query;
|
||||
|
||||
Context getContext() {
|
||||
public Context getContext() {
|
||||
return context;
|
||||
}
|
||||
|
||||
void setContext(Context context) {
|
||||
public void setContext(Context context) {
|
||||
this.context = context;
|
||||
}
|
||||
|
||||
Operation getOperation() {
|
||||
public Operation getOperation() {
|
||||
return operation;
|
||||
}
|
||||
|
||||
void setOperation(Operation operation) {
|
||||
public void setOperation(Operation operation) {
|
||||
this.operation = operation;
|
||||
}
|
||||
|
||||
String getOperationName() {
|
||||
public String getOperationName() {
|
||||
return operationName;
|
||||
}
|
||||
|
||||
void setOperationName(String operationName) {
|
||||
public void setOperationName(String operationName) {
|
||||
this.operationName = operationName;
|
||||
}
|
||||
|
||||
String getQuery() {
|
||||
public String getQuery() {
|
||||
return query;
|
||||
}
|
||||
|
||||
void setQuery(String query) {
|
||||
public void setQuery(String query) {
|
||||
this.query = query;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -23,8 +23,10 @@ import io.opentelemetry.api.common.Attributes;
|
|||
import io.opentelemetry.api.trace.SpanKind;
|
||||
import io.opentelemetry.instrumentation.testing.junit.InstrumentationExtension;
|
||||
import io.opentelemetry.sdk.testing.assertj.AttributeAssertion;
|
||||
import io.opentelemetry.sdk.testing.assertj.SpanDataAssert;
|
||||
import io.opentelemetry.sdk.trace.data.StatusData;
|
||||
import io.opentelemetry.semconv.ExceptionAttributes;
|
||||
import io.opentelemetry.semconv.incubating.GraphqlIncubatingAttributes;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStreamReader;
|
||||
import java.io.Reader;
|
||||
|
@ -33,6 +35,7 @@ import java.util.ArrayList;
|
|||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.function.Consumer;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.TestInstance;
|
||||
|
@ -43,12 +46,17 @@ public abstract class AbstractGraphqlTest {
|
|||
private final List<Map<String, String>> books = new ArrayList<>();
|
||||
private final List<Map<String, String>> authors = new ArrayList<>();
|
||||
|
||||
private GraphQL graphql;
|
||||
protected GraphQL graphql;
|
||||
protected GraphQLSchema graphqlSchema;
|
||||
|
||||
protected abstract InstrumentationExtension getTesting();
|
||||
|
||||
protected abstract void configure(GraphQL.Builder builder);
|
||||
|
||||
protected boolean hasDataFetcherSpans() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@BeforeAll
|
||||
void setup() throws IOException {
|
||||
addAuthor("author-1", "John");
|
||||
|
@ -62,7 +70,7 @@ public abstract class AbstractGraphqlTest {
|
|||
new InputStreamReader(
|
||||
this.getClass().getClassLoader().getResourceAsStream("schema.graphqls"),
|
||||
StandardCharsets.UTF_8)) {
|
||||
GraphQLSchema graphqlSchema = buildSchema(reader);
|
||||
graphqlSchema = buildSchema(reader);
|
||||
GraphQL.Builder graphqlBuilder = GraphQL.newGraphQL(graphqlSchema);
|
||||
configure(graphqlBuilder);
|
||||
this.graphql = graphqlBuilder.build();
|
||||
|
@ -138,21 +146,37 @@ public abstract class AbstractGraphqlTest {
|
|||
|
||||
getTesting()
|
||||
.waitAndAssertTraces(
|
||||
trace ->
|
||||
trace.hasSpansSatisfyingExactly(
|
||||
trace -> {
|
||||
List<Consumer<SpanDataAssert>> assertions = new ArrayList<>();
|
||||
assertions.add(
|
||||
span ->
|
||||
span.hasName("query findBookById")
|
||||
.hasKind(SpanKind.INTERNAL)
|
||||
.hasNoParent()
|
||||
.hasAttributesSatisfyingExactly(
|
||||
equalTo(
|
||||
GraphqlIncubatingAttributes.GRAPHQL_OPERATION_NAME,
|
||||
"findBookById"),
|
||||
equalTo(GraphqlIncubatingAttributes.GRAPHQL_OPERATION_TYPE, "query"),
|
||||
normalizedQueryEqualsTo(
|
||||
GraphqlIncubatingAttributes.GRAPHQL_DOCUMENT,
|
||||
"query findBookById { bookById(id: ?) { name } }")));
|
||||
if (hasDataFetcherSpans()) {
|
||||
assertions.add(
|
||||
span ->
|
||||
span.hasName("query findBookById")
|
||||
.hasKind(SpanKind.INTERNAL)
|
||||
.hasNoParent()
|
||||
span.hasName("bookById")
|
||||
.hasParent(trace.getSpan(0))
|
||||
.hasAttributesSatisfyingExactly(
|
||||
equalTo(
|
||||
AttributeKey.stringKey("graphql.operation.name"),
|
||||
"findBookById"),
|
||||
equalTo(AttributeKey.stringKey("graphql.operation.type"), "query"),
|
||||
normalizedQueryEqualsTo(
|
||||
AttributeKey.stringKey("graphql.document"),
|
||||
"query findBookById { bookById(id: ?) { name } }")),
|
||||
span -> span.hasName("fetchBookById").hasParent(trace.getSpan(0))));
|
||||
equalTo(AttributeKey.stringKey("graphql.field.path"), "/bookById"),
|
||||
equalTo(AttributeKey.stringKey("graphql.field.name"), "bookById")));
|
||||
}
|
||||
assertions.add(
|
||||
span ->
|
||||
span.hasName("fetchBookById")
|
||||
.hasParent(trace.getSpan(hasDataFetcherSpans() ? 1 : 0)));
|
||||
|
||||
trace.hasSpansSatisfyingExactly(assertions);
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
|
@ -170,18 +194,33 @@ public abstract class AbstractGraphqlTest {
|
|||
|
||||
getTesting()
|
||||
.waitAndAssertTraces(
|
||||
trace ->
|
||||
trace.hasSpansSatisfyingExactly(
|
||||
trace -> {
|
||||
List<Consumer<SpanDataAssert>> assertions = new ArrayList<>();
|
||||
assertions.add(
|
||||
span ->
|
||||
span.hasName("query")
|
||||
.hasKind(SpanKind.INTERNAL)
|
||||
.hasNoParent()
|
||||
.hasAttributesSatisfyingExactly(
|
||||
equalTo(AttributeKey.stringKey("graphql.operation.type"), "query"),
|
||||
normalizedQueryEqualsTo(
|
||||
AttributeKey.stringKey("graphql.document"),
|
||||
"{ bookById(id: ?) { name } }")));
|
||||
if (hasDataFetcherSpans()) {
|
||||
assertions.add(
|
||||
span ->
|
||||
span.hasName("query")
|
||||
.hasKind(SpanKind.INTERNAL)
|
||||
.hasNoParent()
|
||||
span.hasName("bookById")
|
||||
.hasParent(trace.getSpan(0))
|
||||
.hasAttributesSatisfyingExactly(
|
||||
equalTo(AttributeKey.stringKey("graphql.operation.type"), "query"),
|
||||
normalizedQueryEqualsTo(
|
||||
AttributeKey.stringKey("graphql.document"),
|
||||
"{ bookById(id: ?) { name } }")),
|
||||
span -> span.hasName("fetchBookById").hasParent(trace.getSpan(0))));
|
||||
equalTo(AttributeKey.stringKey("graphql.field.path"), "/bookById"),
|
||||
equalTo(AttributeKey.stringKey("graphql.field.name"), "bookById")));
|
||||
}
|
||||
assertions.add(
|
||||
span ->
|
||||
span.hasName("fetchBookById")
|
||||
.hasParent(trace.getSpan(hasDataFetcherSpans() ? 1 : 0)));
|
||||
trace.hasSpansSatisfyingExactly(assertions);
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
|
@ -280,15 +319,16 @@ public abstract class AbstractGraphqlTest {
|
|||
.hasNoParent()
|
||||
.hasAttributesSatisfyingExactly(
|
||||
equalTo(
|
||||
AttributeKey.stringKey("graphql.operation.name"), "addNewBook"),
|
||||
GraphqlIncubatingAttributes.GRAPHQL_OPERATION_NAME,
|
||||
"addNewBook"),
|
||||
equalTo(
|
||||
AttributeKey.stringKey("graphql.operation.type"), "mutation"),
|
||||
GraphqlIncubatingAttributes.GRAPHQL_OPERATION_TYPE, "mutation"),
|
||||
normalizedQueryEqualsTo(
|
||||
AttributeKey.stringKey("graphql.document"),
|
||||
GraphqlIncubatingAttributes.GRAPHQL_DOCUMENT,
|
||||
"mutation addNewBook { addBook(id: ?, name: ?, author: ?) { id } }"))));
|
||||
}
|
||||
|
||||
private static AttributeAssertion normalizedQueryEqualsTo(
|
||||
protected static AttributeAssertion normalizedQueryEqualsTo(
|
||||
AttributeKey<String> key, String value) {
|
||||
return satisfies(
|
||||
key,
|
||||
|
|
Loading…
Reference in New Issue