diff --git a/opentelemetry/build.gradle b/opentelemetry/build.gradle index c856ad8dcb9..5c801e23618 100644 --- a/opentelemetry/build.gradle +++ b/opentelemetry/build.gradle @@ -12,7 +12,8 @@ dependencies { implementation libraries.guava, project(':grpc-core'), libraries.opentelemetry.api, - libraries.auto.value.annotations + libraries.auto.value.annotations, + libraries.animalsniffer.annotations testImplementation project(':grpc-testing'), project(':grpc-testing-proto'), diff --git a/opentelemetry/src/main/java/io/grpc/opentelemetry/GrpcOpenTelemetry.java b/opentelemetry/src/main/java/io/grpc/opentelemetry/GrpcOpenTelemetry.java index 4341b27daa4..6904340ac74 100644 --- a/opentelemetry/src/main/java/io/grpc/opentelemetry/GrpcOpenTelemetry.java +++ b/opentelemetry/src/main/java/io/grpc/opentelemetry/GrpcOpenTelemetry.java @@ -48,6 +48,9 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.function.Predicate; +import javax.annotation.Nullable; +import org.codehaus.mojo.animal_sniffer.IgnoreJRERequirement; /** * The entrypoint for OpenTelemetry metrics functionality in gRPC. @@ -97,7 +100,8 @@ private GrpcOpenTelemetry(Builder builder) { this.resource = createMetricInstruments(meter, enableMetrics, disableDefault); this.optionalLabels = ImmutableList.copyOf(builder.optionalLabels); this.openTelemetryMetricsModule = new OpenTelemetryMetricsModule( - STOPWATCH_SUPPLIER, resource, optionalLabels, builder.plugins); + STOPWATCH_SUPPLIER, resource, optionalLabels, builder.plugins, + builder.targetFilter); this.openTelemetryTracingModule = new OpenTelemetryTracingModule(openTelemetrySdk); this.sink = new OpenTelemetryMetricSink(meter, enableMetrics, disableDefault, optionalLabels); } @@ -141,6 +145,11 @@ Tracer getTracer() { return this.openTelemetryTracingModule.getTracer(); } + @VisibleForTesting + TargetFilter getTargetAttributeFilter() { + return this.openTelemetryMetricsModule.getTargetAttributeFilter(); + } + /** * Registers GrpcOpenTelemetry globally, applying its configuration to all subsequently created * gRPC channels and servers. @@ -349,6 +358,13 @@ static boolean isMetricEnabled(String metricName, Map enableMet && !disableDefault; } + /** + * Internal interface to avoid storing a {@link java.util.function.Predicate} directly, ensuring + * compatibility with Android devices (API level < 24) that do not use library desugaring. + */ + interface TargetFilter { + boolean test(String target); + } /** * Builder for configuring {@link GrpcOpenTelemetry}. @@ -359,6 +375,8 @@ public static class Builder { private final Collection optionalLabels = new ArrayList<>(); private final Map enableMetrics = new HashMap<>(); private boolean disableAll; + @Nullable + private TargetFilter targetFilter; private Builder() {} @@ -421,6 +439,26 @@ Builder enableTracing(boolean enable) { return this; } + /** + * Sets an optional filter to control recording of the {@code grpc.target} metric + * attribute. + * + *

If the predicate returns {@code true}, the original target is recorded. Otherwise, + * the target is recorded as {@code "other"} to limit metric cardinality. + * + *

If unset, all targets are recorded as-is. + */ + @ExperimentalApi("https://github.com/grpc/grpc-java/issues/12595") + @IgnoreJRERequirement + public Builder targetAttributeFilter(@Nullable Predicate filter) { + if (filter == null) { + this.targetFilter = null; + } else { + this.targetFilter = filter::test; + } + return this; + } + /** * Returns a new {@link GrpcOpenTelemetry} built with the configuration of this {@link * Builder}. diff --git a/opentelemetry/src/main/java/io/grpc/opentelemetry/OpenTelemetryMetricsModule.java b/opentelemetry/src/main/java/io/grpc/opentelemetry/OpenTelemetryMetricsModule.java index 3e5137e0034..b05884305dc 100644 --- a/opentelemetry/src/main/java/io/grpc/opentelemetry/OpenTelemetryMetricsModule.java +++ b/opentelemetry/src/main/java/io/grpc/opentelemetry/OpenTelemetryMetricsModule.java @@ -45,6 +45,7 @@ import io.grpc.Status; import io.grpc.Status.Code; import io.grpc.StreamTracer; +import io.grpc.opentelemetry.GrpcOpenTelemetry.TargetFilter; import io.opentelemetry.api.baggage.Baggage; import io.opentelemetry.api.common.AttributesBuilder; import io.opentelemetry.context.Context; @@ -68,6 +69,10 @@ * tracer. It's the tracer that reports per-attempt stats, and the factory that reports the stats * of the overall RPC, such as RETRIES_PER_CALL, to OpenTelemetry. * + *

This module optionally applies a target attribute filter to limit the cardinality of + * the {@code grpc.target} attribute in client-side metrics by mapping disallowed targets + * to a stable placeholder value. + * *

On the server-side, there is only one ServerStream per each ServerCall, and ServerStream * starts earlier than the ServerCall. Therefore, only one tracer is created per stream/call, and * it's the tracer that reports the summary to OpenTelemetry. @@ -95,15 +100,30 @@ final class OpenTelemetryMetricsModule { private final boolean localityEnabled; private final boolean backendServiceEnabled; private final ImmutableList plugins; + @Nullable + private final TargetFilter targetAttributeFilter; OpenTelemetryMetricsModule(Supplier stopwatchSupplier, OpenTelemetryMetricsResource resource, Collection optionalLabels, List plugins) { + this(stopwatchSupplier, resource, optionalLabels, plugins, null); + } + + OpenTelemetryMetricsModule(Supplier stopwatchSupplier, + OpenTelemetryMetricsResource resource, + Collection optionalLabels, List plugins, + @Nullable TargetFilter targetAttributeFilter) { this.resource = checkNotNull(resource, "resource"); this.stopwatchSupplier = checkNotNull(stopwatchSupplier, "stopwatchSupplier"); this.localityEnabled = optionalLabels.contains(LOCALITY_KEY.getKey()); this.backendServiceEnabled = optionalLabels.contains(BACKEND_SERVICE_KEY.getKey()); this.plugins = ImmutableList.copyOf(plugins); + this.targetAttributeFilter = targetAttributeFilter; + } + + @VisibleForTesting + TargetFilter getTargetAttributeFilter() { + return targetAttributeFilter; } /** @@ -124,7 +144,15 @@ ClientInterceptor getClientInterceptor(String target) { pluginBuilder.add(plugin); } } - return new MetricsClientInterceptor(target, pluginBuilder.build()); + String filteredTarget = recordTarget(target); + return new MetricsClientInterceptor(filteredTarget, pluginBuilder.build()); + } + + String recordTarget(String target) { + if (targetAttributeFilter == null || target == null) { + return target; + } + return targetAttributeFilter.test(target) ? target : "other"; } static String recordMethodName(String fullMethodName, boolean isGeneratedMethod) { diff --git a/opentelemetry/src/test/java/io/grpc/opentelemetry/GrpcOpenTelemetryTest.java b/opentelemetry/src/test/java/io/grpc/opentelemetry/GrpcOpenTelemetryTest.java index 1ae7b755a48..16cb02c61c2 100644 --- a/opentelemetry/src/test/java/io/grpc/opentelemetry/GrpcOpenTelemetryTest.java +++ b/opentelemetry/src/test/java/io/grpc/opentelemetry/GrpcOpenTelemetryTest.java @@ -29,6 +29,7 @@ import io.grpc.MetricSink; import io.grpc.ServerBuilder; import io.grpc.internal.GrpcUtil; +import io.grpc.opentelemetry.GrpcOpenTelemetry.TargetFilter; import io.opentelemetry.api.OpenTelemetry; import io.opentelemetry.sdk.OpenTelemetrySdk; import io.opentelemetry.sdk.metrics.SdkMeterProvider; @@ -130,6 +131,18 @@ public void builderDefaults() { ); } + @Test + public void builderTargetAttributeFilter() { + GrpcOpenTelemetry module = GrpcOpenTelemetry.newBuilder() + .targetAttributeFilter(t -> t.contains("allowed.com")) + .build(); + + TargetFilter internalFilter = module.getTargetAttributeFilter(); + + assertThat(internalFilter.test("allowed.com")).isTrue(); + assertThat(internalFilter.test("example.com")).isFalse(); + } + @Test public void enableDisableMetrics() { GrpcOpenTelemetry.Builder builder = GrpcOpenTelemetry.newBuilder(); diff --git a/opentelemetry/src/test/java/io/grpc/opentelemetry/OpenTelemetryMetricsModuleTest.java b/opentelemetry/src/test/java/io/grpc/opentelemetry/OpenTelemetryMetricsModuleTest.java index 58759294fca..391f94cefea 100644 --- a/opentelemetry/src/test/java/io/grpc/opentelemetry/OpenTelemetryMetricsModuleTest.java +++ b/opentelemetry/src/test/java/io/grpc/opentelemetry/OpenTelemetryMetricsModuleTest.java @@ -51,6 +51,7 @@ import io.grpc.inprocess.InProcessChannelBuilder; import io.grpc.inprocess.InProcessServerBuilder; import io.grpc.internal.FakeClock; +import io.grpc.opentelemetry.GrpcOpenTelemetry.TargetFilter; import io.grpc.opentelemetry.OpenTelemetryMetricsModule.CallAttemptsTracerFactory; import io.grpc.opentelemetry.internal.OpenTelemetryConstants; import io.grpc.stub.MetadataUtils; @@ -1667,12 +1668,149 @@ public void serverBaggagePropagationToMetrics() { assertEquals("67", capturedBaggage.getEntryValue("user-id")); } + @Test + public void targetAttributeFilter_notSet_usesOriginalTarget() { + // Test that when no filter is set, the original target is used + String target = "dns:///example.com"; + OpenTelemetryMetricsResource resource = GrpcOpenTelemetry.createMetricInstruments(testMeter, + enabledMetricsMap, disableDefaultMetrics); + OpenTelemetryMetricsModule module = newOpenTelemetryMetricsModule(resource); + + Channel interceptedChannel = + ClientInterceptors.intercept( + grpcServerRule.getChannel(), module.getClientInterceptor(target)); + + ClientCall call = interceptedChannel.newCall(method, CALL_OPTIONS); + + // Make the call + Metadata headers = new Metadata(); + call.start(mockClientCallListener, headers); + + // End the call + call.halfClose(); + call.request(1); + + io.opentelemetry.api.common.Attributes attributes = io.opentelemetry.api.common.Attributes.of( + TARGET_KEY, target, + METHOD_KEY, method.getFullMethodName()); + + assertThat(openTelemetryTesting.getMetrics()) + .anySatisfy( + metric -> + assertThat(metric) + .hasInstrumentationScope(InstrumentationScopeInfo.create( + OpenTelemetryConstants.INSTRUMENTATION_SCOPE)) + .hasName(CLIENT_ATTEMPT_COUNT_INSTRUMENT_NAME) + .hasUnit("{attempt}") + .hasLongSumSatisfying( + longSum -> + longSum + .hasPointsSatisfying( + point -> + point + .hasAttributes(attributes)))); + } + + @Test + public void targetAttributeFilter_allowsTarget_usesOriginalTarget() { + // Test that when filter allows the target, the original target is used + String target = "dns:///example.com"; + OpenTelemetryMetricsResource resource = GrpcOpenTelemetry.createMetricInstruments(testMeter, + enabledMetricsMap, disableDefaultMetrics); + OpenTelemetryMetricsModule module = newOpenTelemetryMetricsModule(resource, + t -> t.contains("example.com")); + + Channel interceptedChannel = + ClientInterceptors.intercept( + grpcServerRule.getChannel(), module.getClientInterceptor(target)); + + ClientCall call = interceptedChannel.newCall(method, CALL_OPTIONS); + + // Make the call + Metadata headers = new Metadata(); + call.start(mockClientCallListener, headers); + + // End the call + call.halfClose(); + call.request(1); + + io.opentelemetry.api.common.Attributes attributes = io.opentelemetry.api.common.Attributes.of( + TARGET_KEY, target, + METHOD_KEY, method.getFullMethodName()); + + assertThat(openTelemetryTesting.getMetrics()) + .anySatisfy( + metric -> + assertThat(metric) + .hasInstrumentationScope(InstrumentationScopeInfo.create( + OpenTelemetryConstants.INSTRUMENTATION_SCOPE)) + .hasName(CLIENT_ATTEMPT_COUNT_INSTRUMENT_NAME) + .hasUnit("{attempt}") + .hasLongSumSatisfying( + longSum -> + longSum + .hasPointsSatisfying( + point -> + point + .hasAttributes(attributes)))); + } + + @Test + public void targetAttributeFilter_rejectsTarget_mapsToOther() { + // Test that when filter rejects the target, it is mapped to "other" + String target = "dns:///example.com"; + OpenTelemetryMetricsResource resource = GrpcOpenTelemetry.createMetricInstruments(testMeter, + enabledMetricsMap, disableDefaultMetrics); + OpenTelemetryMetricsModule module = newOpenTelemetryMetricsModule(resource, + t -> t.contains("allowed.com")); + + Channel interceptedChannel = + ClientInterceptors.intercept( + grpcServerRule.getChannel(), module.getClientInterceptor(target)); + + ClientCall call = interceptedChannel.newCall(method, CALL_OPTIONS); + + // Make the call + Metadata headers = new Metadata(); + call.start(mockClientCallListener, headers); + + // End the call + call.halfClose(); + call.request(1); + + io.opentelemetry.api.common.Attributes attributes = io.opentelemetry.api.common.Attributes.of( + TARGET_KEY, "other", + METHOD_KEY, method.getFullMethodName()); + + assertThat(openTelemetryTesting.getMetrics()) + .anySatisfy( + metric -> + assertThat(metric) + .hasInstrumentationScope(InstrumentationScopeInfo.create( + OpenTelemetryConstants.INSTRUMENTATION_SCOPE)) + .hasName(CLIENT_ATTEMPT_COUNT_INSTRUMENT_NAME) + .hasUnit("{attempt}") + .hasLongSumSatisfying( + longSum -> + longSum + .hasPointsSatisfying( + point -> + point + .hasAttributes(attributes)))); + } + private OpenTelemetryMetricsModule newOpenTelemetryMetricsModule( OpenTelemetryMetricsResource resource) { return new OpenTelemetryMetricsModule( fakeClock.getStopwatchSupplier(), resource, emptyList(), emptyList()); } + private OpenTelemetryMetricsModule newOpenTelemetryMetricsModule( + OpenTelemetryMetricsResource resource, TargetFilter filter) { + return new OpenTelemetryMetricsModule( + fakeClock.getStopwatchSupplier(), resource, emptyList(), emptyList(), filter); + } + static class CallInfo extends ServerCallInfo { private final MethodDescriptor methodDescriptor; private final Attributes attributes;