Skip to content

Commit c5f5ee0

Browse files
authored
opentelemetry: Add target attribute filter for metrics (#12587)
Introduce an optional Predicate<String> targetAttributeFilter to control how grpc.target is recorded in OpenTelemetry client metrics. When a filter is provided, targets rejected by the predicate are normalized to "other" to reduce grpc.target metric cardinality, while accepted targets are recorded as-is. If no filter is set, existing behavior is preserved. This change adds a new Builder API on GrpcOpenTelemetry to allow applications to configure the filter. Tests verify both the Builder wiring and the target normalization behavior. This is an optional API; annotation (e.g., experimental) can be added per maintainer guidance. Refs #12322 Related: gRFC A109 – Target Attribute Filter for OpenTelemetry Metrics grpc/proposal#528
1 parent f65127c commit c5f5ee0

File tree

5 files changed

+221
-3
lines changed

5 files changed

+221
-3
lines changed

opentelemetry/build.gradle

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@ dependencies {
1212
implementation libraries.guava,
1313
project(':grpc-core'),
1414
libraries.opentelemetry.api,
15-
libraries.auto.value.annotations
15+
libraries.auto.value.annotations,
16+
libraries.animalsniffer.annotations
1617

1718
testImplementation project(':grpc-testing'),
1819
project(':grpc-testing-proto'),

opentelemetry/src/main/java/io/grpc/opentelemetry/GrpcOpenTelemetry.java

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,9 @@
4848
import java.util.HashMap;
4949
import java.util.List;
5050
import java.util.Map;
51+
import java.util.function.Predicate;
52+
import javax.annotation.Nullable;
53+
import org.codehaus.mojo.animal_sniffer.IgnoreJRERequirement;
5154

5255
/**
5356
* The entrypoint for OpenTelemetry metrics functionality in gRPC.
@@ -97,7 +100,8 @@ private GrpcOpenTelemetry(Builder builder) {
97100
this.resource = createMetricInstruments(meter, enableMetrics, disableDefault);
98101
this.optionalLabels = ImmutableList.copyOf(builder.optionalLabels);
99102
this.openTelemetryMetricsModule = new OpenTelemetryMetricsModule(
100-
STOPWATCH_SUPPLIER, resource, optionalLabels, builder.plugins);
103+
STOPWATCH_SUPPLIER, resource, optionalLabels, builder.plugins,
104+
builder.targetFilter);
101105
this.openTelemetryTracingModule = new OpenTelemetryTracingModule(openTelemetrySdk);
102106
this.sink = new OpenTelemetryMetricSink(meter, enableMetrics, disableDefault, optionalLabels);
103107
}
@@ -141,6 +145,11 @@ Tracer getTracer() {
141145
return this.openTelemetryTracingModule.getTracer();
142146
}
143147

148+
@VisibleForTesting
149+
TargetFilter getTargetAttributeFilter() {
150+
return this.openTelemetryMetricsModule.getTargetAttributeFilter();
151+
}
152+
144153
/**
145154
* Registers GrpcOpenTelemetry globally, applying its configuration to all subsequently created
146155
* gRPC channels and servers.
@@ -349,6 +358,13 @@ static boolean isMetricEnabled(String metricName, Map<String, Boolean> enableMet
349358
&& !disableDefault;
350359
}
351360

361+
/**
362+
* Internal interface to avoid storing a {@link java.util.function.Predicate} directly, ensuring
363+
* compatibility with Android devices (API level < 24) that do not use library desugaring.
364+
*/
365+
interface TargetFilter {
366+
boolean test(String target);
367+
}
352368

353369
/**
354370
* Builder for configuring {@link GrpcOpenTelemetry}.
@@ -359,6 +375,8 @@ public static class Builder {
359375
private final Collection<String> optionalLabels = new ArrayList<>();
360376
private final Map<String, Boolean> enableMetrics = new HashMap<>();
361377
private boolean disableAll;
378+
@Nullable
379+
private TargetFilter targetFilter;
362380

363381
private Builder() {}
364382

@@ -421,6 +439,26 @@ Builder enableTracing(boolean enable) {
421439
return this;
422440
}
423441

442+
/**
443+
* Sets an optional filter to control recording of the {@code grpc.target} metric
444+
* attribute.
445+
*
446+
* <p>If the predicate returns {@code true}, the original target is recorded. Otherwise,
447+
* the target is recorded as {@code "other"} to limit metric cardinality.
448+
*
449+
* <p>If unset, all targets are recorded as-is.
450+
*/
451+
@ExperimentalApi("https://github.com/grpc/grpc-java/issues/12595")
452+
@IgnoreJRERequirement
453+
public Builder targetAttributeFilter(@Nullable Predicate<String> filter) {
454+
if (filter == null) {
455+
this.targetFilter = null;
456+
} else {
457+
this.targetFilter = filter::test;
458+
}
459+
return this;
460+
}
461+
424462
/**
425463
* Returns a new {@link GrpcOpenTelemetry} built with the configuration of this {@link
426464
* Builder}.

opentelemetry/src/main/java/io/grpc/opentelemetry/OpenTelemetryMetricsModule.java

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545
import io.grpc.Status;
4646
import io.grpc.Status.Code;
4747
import io.grpc.StreamTracer;
48+
import io.grpc.opentelemetry.GrpcOpenTelemetry.TargetFilter;
4849
import io.opentelemetry.api.baggage.Baggage;
4950
import io.opentelemetry.api.common.AttributesBuilder;
5051
import io.opentelemetry.context.Context;
@@ -68,6 +69,10 @@
6869
* tracer. It's the tracer that reports per-attempt stats, and the factory that reports the stats
6970
* of the overall RPC, such as RETRIES_PER_CALL, to OpenTelemetry.
7071
*
72+
* <p>This module optionally applies a target attribute filter to limit the cardinality of
73+
* the {@code grpc.target} attribute in client-side metrics by mapping disallowed targets
74+
* to a stable placeholder value.
75+
*
7176
* <p>On the server-side, there is only one ServerStream per each ServerCall, and ServerStream
7277
* starts earlier than the ServerCall. Therefore, only one tracer is created per stream/call, and
7378
* it's the tracer that reports the summary to OpenTelemetry.
@@ -95,15 +100,30 @@ final class OpenTelemetryMetricsModule {
95100
private final boolean localityEnabled;
96101
private final boolean backendServiceEnabled;
97102
private final ImmutableList<OpenTelemetryPlugin> plugins;
103+
@Nullable
104+
private final TargetFilter targetAttributeFilter;
98105

99106
OpenTelemetryMetricsModule(Supplier<Stopwatch> stopwatchSupplier,
100107
OpenTelemetryMetricsResource resource,
101108
Collection<String> optionalLabels, List<OpenTelemetryPlugin> plugins) {
109+
this(stopwatchSupplier, resource, optionalLabels, plugins, null);
110+
}
111+
112+
OpenTelemetryMetricsModule(Supplier<Stopwatch> stopwatchSupplier,
113+
OpenTelemetryMetricsResource resource,
114+
Collection<String> optionalLabels, List<OpenTelemetryPlugin> plugins,
115+
@Nullable TargetFilter targetAttributeFilter) {
102116
this.resource = checkNotNull(resource, "resource");
103117
this.stopwatchSupplier = checkNotNull(stopwatchSupplier, "stopwatchSupplier");
104118
this.localityEnabled = optionalLabels.contains(LOCALITY_KEY.getKey());
105119
this.backendServiceEnabled = optionalLabels.contains(BACKEND_SERVICE_KEY.getKey());
106120
this.plugins = ImmutableList.copyOf(plugins);
121+
this.targetAttributeFilter = targetAttributeFilter;
122+
}
123+
124+
@VisibleForTesting
125+
TargetFilter getTargetAttributeFilter() {
126+
return targetAttributeFilter;
107127
}
108128

109129
/**
@@ -124,7 +144,15 @@ ClientInterceptor getClientInterceptor(String target) {
124144
pluginBuilder.add(plugin);
125145
}
126146
}
127-
return new MetricsClientInterceptor(target, pluginBuilder.build());
147+
String filteredTarget = recordTarget(target);
148+
return new MetricsClientInterceptor(filteredTarget, pluginBuilder.build());
149+
}
150+
151+
String recordTarget(String target) {
152+
if (targetAttributeFilter == null || target == null) {
153+
return target;
154+
}
155+
return targetAttributeFilter.test(target) ? target : "other";
128156
}
129157

130158
static String recordMethodName(String fullMethodName, boolean isGeneratedMethod) {

opentelemetry/src/test/java/io/grpc/opentelemetry/GrpcOpenTelemetryTest.java

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
import io.grpc.MetricSink;
3030
import io.grpc.ServerBuilder;
3131
import io.grpc.internal.GrpcUtil;
32+
import io.grpc.opentelemetry.GrpcOpenTelemetry.TargetFilter;
3233
import io.opentelemetry.api.OpenTelemetry;
3334
import io.opentelemetry.sdk.OpenTelemetrySdk;
3435
import io.opentelemetry.sdk.metrics.SdkMeterProvider;
@@ -130,6 +131,18 @@ public void builderDefaults() {
130131
);
131132
}
132133

134+
@Test
135+
public void builderTargetAttributeFilter() {
136+
GrpcOpenTelemetry module = GrpcOpenTelemetry.newBuilder()
137+
.targetAttributeFilter(t -> t.contains("allowed.com"))
138+
.build();
139+
140+
TargetFilter internalFilter = module.getTargetAttributeFilter();
141+
142+
assertThat(internalFilter.test("allowed.com")).isTrue();
143+
assertThat(internalFilter.test("example.com")).isFalse();
144+
}
145+
133146
@Test
134147
public void enableDisableMetrics() {
135148
GrpcOpenTelemetry.Builder builder = GrpcOpenTelemetry.newBuilder();

opentelemetry/src/test/java/io/grpc/opentelemetry/OpenTelemetryMetricsModuleTest.java

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@
5151
import io.grpc.inprocess.InProcessChannelBuilder;
5252
import io.grpc.inprocess.InProcessServerBuilder;
5353
import io.grpc.internal.FakeClock;
54+
import io.grpc.opentelemetry.GrpcOpenTelemetry.TargetFilter;
5455
import io.grpc.opentelemetry.OpenTelemetryMetricsModule.CallAttemptsTracerFactory;
5556
import io.grpc.opentelemetry.internal.OpenTelemetryConstants;
5657
import io.grpc.stub.MetadataUtils;
@@ -1667,12 +1668,149 @@ public void serverBaggagePropagationToMetrics() {
16671668
assertEquals("67", capturedBaggage.getEntryValue("user-id"));
16681669
}
16691670

1671+
@Test
1672+
public void targetAttributeFilter_notSet_usesOriginalTarget() {
1673+
// Test that when no filter is set, the original target is used
1674+
String target = "dns:///example.com";
1675+
OpenTelemetryMetricsResource resource = GrpcOpenTelemetry.createMetricInstruments(testMeter,
1676+
enabledMetricsMap, disableDefaultMetrics);
1677+
OpenTelemetryMetricsModule module = newOpenTelemetryMetricsModule(resource);
1678+
1679+
Channel interceptedChannel =
1680+
ClientInterceptors.intercept(
1681+
grpcServerRule.getChannel(), module.getClientInterceptor(target));
1682+
1683+
ClientCall<String, String> call = interceptedChannel.newCall(method, CALL_OPTIONS);
1684+
1685+
// Make the call
1686+
Metadata headers = new Metadata();
1687+
call.start(mockClientCallListener, headers);
1688+
1689+
// End the call
1690+
call.halfClose();
1691+
call.request(1);
1692+
1693+
io.opentelemetry.api.common.Attributes attributes = io.opentelemetry.api.common.Attributes.of(
1694+
TARGET_KEY, target,
1695+
METHOD_KEY, method.getFullMethodName());
1696+
1697+
assertThat(openTelemetryTesting.getMetrics())
1698+
.anySatisfy(
1699+
metric ->
1700+
assertThat(metric)
1701+
.hasInstrumentationScope(InstrumentationScopeInfo.create(
1702+
OpenTelemetryConstants.INSTRUMENTATION_SCOPE))
1703+
.hasName(CLIENT_ATTEMPT_COUNT_INSTRUMENT_NAME)
1704+
.hasUnit("{attempt}")
1705+
.hasLongSumSatisfying(
1706+
longSum ->
1707+
longSum
1708+
.hasPointsSatisfying(
1709+
point ->
1710+
point
1711+
.hasAttributes(attributes))));
1712+
}
1713+
1714+
@Test
1715+
public void targetAttributeFilter_allowsTarget_usesOriginalTarget() {
1716+
// Test that when filter allows the target, the original target is used
1717+
String target = "dns:///example.com";
1718+
OpenTelemetryMetricsResource resource = GrpcOpenTelemetry.createMetricInstruments(testMeter,
1719+
enabledMetricsMap, disableDefaultMetrics);
1720+
OpenTelemetryMetricsModule module = newOpenTelemetryMetricsModule(resource,
1721+
t -> t.contains("example.com"));
1722+
1723+
Channel interceptedChannel =
1724+
ClientInterceptors.intercept(
1725+
grpcServerRule.getChannel(), module.getClientInterceptor(target));
1726+
1727+
ClientCall<String, String> call = interceptedChannel.newCall(method, CALL_OPTIONS);
1728+
1729+
// Make the call
1730+
Metadata headers = new Metadata();
1731+
call.start(mockClientCallListener, headers);
1732+
1733+
// End the call
1734+
call.halfClose();
1735+
call.request(1);
1736+
1737+
io.opentelemetry.api.common.Attributes attributes = io.opentelemetry.api.common.Attributes.of(
1738+
TARGET_KEY, target,
1739+
METHOD_KEY, method.getFullMethodName());
1740+
1741+
assertThat(openTelemetryTesting.getMetrics())
1742+
.anySatisfy(
1743+
metric ->
1744+
assertThat(metric)
1745+
.hasInstrumentationScope(InstrumentationScopeInfo.create(
1746+
OpenTelemetryConstants.INSTRUMENTATION_SCOPE))
1747+
.hasName(CLIENT_ATTEMPT_COUNT_INSTRUMENT_NAME)
1748+
.hasUnit("{attempt}")
1749+
.hasLongSumSatisfying(
1750+
longSum ->
1751+
longSum
1752+
.hasPointsSatisfying(
1753+
point ->
1754+
point
1755+
.hasAttributes(attributes))));
1756+
}
1757+
1758+
@Test
1759+
public void targetAttributeFilter_rejectsTarget_mapsToOther() {
1760+
// Test that when filter rejects the target, it is mapped to "other"
1761+
String target = "dns:///example.com";
1762+
OpenTelemetryMetricsResource resource = GrpcOpenTelemetry.createMetricInstruments(testMeter,
1763+
enabledMetricsMap, disableDefaultMetrics);
1764+
OpenTelemetryMetricsModule module = newOpenTelemetryMetricsModule(resource,
1765+
t -> t.contains("allowed.com"));
1766+
1767+
Channel interceptedChannel =
1768+
ClientInterceptors.intercept(
1769+
grpcServerRule.getChannel(), module.getClientInterceptor(target));
1770+
1771+
ClientCall<String, String> call = interceptedChannel.newCall(method, CALL_OPTIONS);
1772+
1773+
// Make the call
1774+
Metadata headers = new Metadata();
1775+
call.start(mockClientCallListener, headers);
1776+
1777+
// End the call
1778+
call.halfClose();
1779+
call.request(1);
1780+
1781+
io.opentelemetry.api.common.Attributes attributes = io.opentelemetry.api.common.Attributes.of(
1782+
TARGET_KEY, "other",
1783+
METHOD_KEY, method.getFullMethodName());
1784+
1785+
assertThat(openTelemetryTesting.getMetrics())
1786+
.anySatisfy(
1787+
metric ->
1788+
assertThat(metric)
1789+
.hasInstrumentationScope(InstrumentationScopeInfo.create(
1790+
OpenTelemetryConstants.INSTRUMENTATION_SCOPE))
1791+
.hasName(CLIENT_ATTEMPT_COUNT_INSTRUMENT_NAME)
1792+
.hasUnit("{attempt}")
1793+
.hasLongSumSatisfying(
1794+
longSum ->
1795+
longSum
1796+
.hasPointsSatisfying(
1797+
point ->
1798+
point
1799+
.hasAttributes(attributes))));
1800+
}
1801+
16701802
private OpenTelemetryMetricsModule newOpenTelemetryMetricsModule(
16711803
OpenTelemetryMetricsResource resource) {
16721804
return new OpenTelemetryMetricsModule(
16731805
fakeClock.getStopwatchSupplier(), resource, emptyList(), emptyList());
16741806
}
16751807

1808+
private OpenTelemetryMetricsModule newOpenTelemetryMetricsModule(
1809+
OpenTelemetryMetricsResource resource, TargetFilter filter) {
1810+
return new OpenTelemetryMetricsModule(
1811+
fakeClock.getStopwatchSupplier(), resource, emptyList(), emptyList(), filter);
1812+
}
1813+
16761814
static class CallInfo<ReqT, RespT> extends ServerCallInfo<ReqT, RespT> {
16771815
private final MethodDescriptor<ReqT, RespT> methodDescriptor;
16781816
private final Attributes attributes;

0 commit comments

Comments
 (0)