Skip to content

Commit 1ae84a0

Browse files
authored
chore: Add Exemplar Support in Cloud Monitoring Exporter (#3952)
1 parent 79c0684 commit 1ae84a0

File tree

3 files changed

+166
-8
lines changed

3 files changed

+166
-8
lines changed

google-cloud-spanner/src/main/java/com/google/cloud/spanner/SpannerCloudMonitoringExporter.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,8 @@ private CompletableResultCode exportSpannerClientMetrics(Collection<MetricData>
141141
List<TimeSeries> spannerTimeSeries;
142142
try {
143143
spannerTimeSeries =
144-
SpannerCloudMonitoringExporterUtils.convertToSpannerTimeSeries(spannerMetricData);
144+
SpannerCloudMonitoringExporterUtils.convertToSpannerTimeSeries(
145+
spannerMetricData, this.spannerProjectId);
145146
} catch (Throwable e) {
146147
logger.log(
147148
Level.WARNING,

google-cloud-spanner/src/main/java/com/google/cloud/spanner/SpannerCloudMonitoringExporterUtils.java

Lines changed: 78 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -36,17 +36,24 @@
3636
import com.google.api.MetricDescriptor.MetricKind;
3737
import com.google.api.MetricDescriptor.ValueType;
3838
import com.google.api.MonitoredResource;
39+
import com.google.monitoring.v3.DroppedLabels;
3940
import com.google.monitoring.v3.Point;
41+
import com.google.monitoring.v3.SpanContext;
4042
import com.google.monitoring.v3.TimeInterval;
4143
import com.google.monitoring.v3.TimeSeries;
4244
import com.google.monitoring.v3.TypedValue;
45+
import com.google.protobuf.Any;
46+
import com.google.protobuf.Timestamp;
4347
import com.google.protobuf.util.Timestamps;
4448
import io.opentelemetry.api.common.AttributeKey;
4549
import io.opentelemetry.api.common.Attributes;
4650
import io.opentelemetry.sdk.metrics.data.AggregationTemporality;
51+
import io.opentelemetry.sdk.metrics.data.DoubleExemplarData;
4752
import io.opentelemetry.sdk.metrics.data.DoublePointData;
53+
import io.opentelemetry.sdk.metrics.data.ExemplarData;
4854
import io.opentelemetry.sdk.metrics.data.HistogramData;
4955
import io.opentelemetry.sdk.metrics.data.HistogramPointData;
56+
import io.opentelemetry.sdk.metrics.data.LongExemplarData;
5057
import io.opentelemetry.sdk.metrics.data.LongPointData;
5158
import io.opentelemetry.sdk.metrics.data.MetricData;
5259
import io.opentelemetry.sdk.metrics.data.MetricDataType;
@@ -57,6 +64,7 @@
5764
import java.util.List;
5865
import java.util.logging.Level;
5966
import java.util.logging.Logger;
67+
import java.util.stream.Collectors;
6068

6169
class SpannerCloudMonitoringExporterUtils {
6270

@@ -69,7 +77,8 @@ static String getProjectId(Resource resource) {
6977
return resource.getAttributes().get(PROJECT_ID_KEY);
7078
}
7179

72-
static List<TimeSeries> convertToSpannerTimeSeries(List<MetricData> collection) {
80+
static List<TimeSeries> convertToSpannerTimeSeries(
81+
List<MetricData> collection, String projectId) {
7382
List<TimeSeries> allTimeSeries = new ArrayList<>();
7483

7584
for (MetricData metricData : collection) {
@@ -94,7 +103,8 @@ static List<TimeSeries> convertToSpannerTimeSeries(List<MetricData> collection)
94103
metricData.getData().getPoints().stream()
95104
.map(
96105
pointData ->
97-
convertPointToSpannerTimeSeries(metricData, pointData, monitoredResourceBuilder))
106+
convertPointToSpannerTimeSeries(
107+
metricData, pointData, monitoredResourceBuilder, projectId))
98108
.forEach(allTimeSeries::add);
99109
}
100110
return allTimeSeries;
@@ -103,7 +113,8 @@ static List<TimeSeries> convertToSpannerTimeSeries(List<MetricData> collection)
103113
private static TimeSeries convertPointToSpannerTimeSeries(
104114
MetricData metricData,
105115
PointData pointData,
106-
MonitoredResource.Builder monitoredResourceBuilder) {
116+
MonitoredResource.Builder monitoredResourceBuilder,
117+
String projectId) {
107118
TimeSeries.Builder builder =
108119
TimeSeries.newBuilder()
109120
.setMetricKind(convertMetricKind(metricData))
@@ -135,7 +146,7 @@ private static TimeSeries convertPointToSpannerTimeSeries(
135146
.setEndTime(Timestamps.fromNanos(pointData.getEpochNanos()))
136147
.build();
137148

138-
builder.addPoints(createPoint(metricData.getType(), pointData, timeInterval));
149+
builder.addPoints(createPoint(metricData.getType(), pointData, timeInterval, projectId));
139150

140151
return builder.build();
141152
}
@@ -191,15 +202,16 @@ private static ValueType convertValueType(MetricDataType metricDataType) {
191202
}
192203

193204
private static Point createPoint(
194-
MetricDataType type, PointData pointData, TimeInterval timeInterval) {
205+
MetricDataType type, PointData pointData, TimeInterval timeInterval, String projectId) {
195206
Point.Builder builder = Point.newBuilder().setInterval(timeInterval);
196207
switch (type) {
197208
case HISTOGRAM:
198209
case EXPONENTIAL_HISTOGRAM:
199210
return builder
200211
.setValue(
201212
TypedValue.newBuilder()
202-
.setDistributionValue(convertHistogramData((HistogramPointData) pointData))
213+
.setDistributionValue(
214+
convertHistogramData((HistogramPointData) pointData, projectId))
203215
.build())
204216
.build();
205217
case DOUBLE_GAUGE:
@@ -221,14 +233,73 @@ private static Point createPoint(
221233
}
222234
}
223235

224-
private static Distribution convertHistogramData(HistogramPointData pointData) {
236+
private static Distribution convertHistogramData(HistogramPointData pointData, String projectId) {
225237
return Distribution.newBuilder()
226238
.setCount(pointData.getCount())
227239
.setMean(pointData.getCount() == 0L ? 0.0D : pointData.getSum() / pointData.getCount())
228240
.setBucketOptions(
229241
BucketOptions.newBuilder()
230242
.setExplicitBuckets(Explicit.newBuilder().addAllBounds(pointData.getBoundaries())))
231243
.addAllBucketCounts(pointData.getCounts())
244+
.addAllExemplars(
245+
pointData.getExemplars().stream()
246+
.map(e -> mapExemplar(e, projectId))
247+
.collect(Collectors.toList()))
232248
.build();
233249
}
250+
251+
private static Distribution.Exemplar mapExemplar(ExemplarData exemplar, String projectId) {
252+
double value = 0;
253+
if (exemplar instanceof DoubleExemplarData) {
254+
value = ((DoubleExemplarData) exemplar).getValue();
255+
} else if (exemplar instanceof LongExemplarData) {
256+
value = ((LongExemplarData) exemplar).getValue();
257+
}
258+
259+
Distribution.Exemplar.Builder exemplarBuilder =
260+
Distribution.Exemplar.newBuilder()
261+
.setValue(value)
262+
.setTimestamp(mapTimestamp(exemplar.getEpochNanos()));
263+
if (exemplar.getSpanContext().isValid()) {
264+
exemplarBuilder.addAttachments(
265+
Any.pack(
266+
SpanContext.newBuilder()
267+
.setSpanName(
268+
makeSpanName(
269+
projectId,
270+
exemplar.getSpanContext().getTraceId(),
271+
exemplar.getSpanContext().getSpanId()))
272+
.build()));
273+
}
274+
if (!exemplar.getFilteredAttributes().isEmpty()) {
275+
exemplarBuilder.addAttachments(
276+
Any.pack(mapFilteredAttributes(exemplar.getFilteredAttributes())));
277+
}
278+
return exemplarBuilder.build();
279+
}
280+
281+
static final long NANO_PER_SECOND = (long) 1e9;
282+
283+
private static Timestamp mapTimestamp(long epochNanos) {
284+
return Timestamp.newBuilder()
285+
.setSeconds(epochNanos / NANO_PER_SECOND)
286+
.setNanos((int) (epochNanos % NANO_PER_SECOND))
287+
.build();
288+
}
289+
290+
private static String makeSpanName(String projectId, String traceId, String spanId) {
291+
return String.format("projects/%s/traces/%s/spans/%s", projectId, traceId, spanId);
292+
}
293+
294+
private static DroppedLabels mapFilteredAttributes(Attributes attributes) {
295+
DroppedLabels.Builder labels = DroppedLabels.newBuilder();
296+
attributes.forEach((k, v) -> labels.putLabel(cleanAttributeKey(k.getKey()), v.toString()));
297+
return labels.build();
298+
}
299+
300+
private static String cleanAttributeKey(String key) {
301+
// . is commonly used in OTel but disallowed in GCM label names,
302+
// https://cloud.google.com/monitoring/api/ref_v3/rest/v3/LabelDescriptor#:~:text=Matches%20the%20following%20regular%20expression%3A
303+
return key.replace('.', '_');
304+
}
234305
}

google-cloud-spanner/src/test/java/com/google/cloud/spanner/SpannerCloudMonitoringExporterTest.java

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,15 +42,21 @@
4242
import com.google.cloud.monitoring.v3.stub.MetricServiceStub;
4343
import com.google.common.collect.ImmutableList;
4444
import com.google.monitoring.v3.CreateTimeSeriesRequest;
45+
import com.google.monitoring.v3.DroppedLabels;
4546
import com.google.monitoring.v3.TimeSeries;
4647
import com.google.protobuf.Empty;
4748
import io.opentelemetry.api.common.Attributes;
49+
import io.opentelemetry.api.trace.SpanContext;
50+
import io.opentelemetry.api.trace.TraceFlags;
51+
import io.opentelemetry.api.trace.TraceState;
4852
import io.opentelemetry.sdk.common.InstrumentationScopeInfo;
4953
import io.opentelemetry.sdk.metrics.InstrumentType;
5054
import io.opentelemetry.sdk.metrics.data.AggregationTemporality;
55+
import io.opentelemetry.sdk.metrics.data.DoubleExemplarData;
5156
import io.opentelemetry.sdk.metrics.data.HistogramPointData;
5257
import io.opentelemetry.sdk.metrics.data.LongPointData;
5358
import io.opentelemetry.sdk.metrics.data.MetricData;
59+
import io.opentelemetry.sdk.metrics.internal.data.ImmutableDoubleExemplarData;
5460
import io.opentelemetry.sdk.metrics.internal.data.ImmutableHistogramData;
5561
import io.opentelemetry.sdk.metrics.internal.data.ImmutableHistogramPointData;
5662
import io.opentelemetry.sdk.metrics.internal.data.ImmutableLongPointData;
@@ -347,6 +353,86 @@ public void testExportingSumDataInBatches() {
347353
}
348354
}
349355

356+
@Test
357+
public void testExportingHistogramDataWithExemplars() {
358+
ArgumentCaptor<CreateTimeSeriesRequest> argumentCaptor =
359+
ArgumentCaptor.forClass(CreateTimeSeriesRequest.class);
360+
361+
UnaryCallable<CreateTimeSeriesRequest, Empty> mockCallable = mock(UnaryCallable.class);
362+
when(mockMetricServiceStub.createServiceTimeSeriesCallable()).thenReturn(mockCallable);
363+
ApiFuture<Empty> future = ApiFutures.immediateFuture(Empty.getDefaultInstance());
364+
when(mockCallable.futureCall(argumentCaptor.capture())).thenReturn(future);
365+
366+
long startEpoch = 10 * 1_000_000_000L;
367+
long endEpoch = 15 * 1_000_000_000L;
368+
long recordTimeEpoch = 12_123_456_789L;
369+
370+
DoubleExemplarData exemplar =
371+
ImmutableDoubleExemplarData.create(
372+
Attributes.builder().put("request_id", "test").build(),
373+
recordTimeEpoch,
374+
SpanContext.create(
375+
"0123456789abcdef0123456789abcdef",
376+
"0123456789abcdef",
377+
TraceFlags.getSampled(),
378+
TraceState.getDefault()),
379+
1.5);
380+
381+
HistogramPointData histogramPointData =
382+
ImmutableHistogramPointData.create(
383+
startEpoch,
384+
endEpoch,
385+
attributes,
386+
3d,
387+
true,
388+
1d,
389+
true,
390+
2d,
391+
Collections.singletonList(1.0),
392+
Arrays.asList(1L, 2L),
393+
Collections.singletonList(exemplar) // ← add exemplar
394+
);
395+
396+
MetricData histogramData =
397+
ImmutableMetricData.createDoubleHistogram(
398+
resource,
399+
scope,
400+
"spanner.googleapis.com/internal/client/" + OPERATION_LATENCIES_NAME,
401+
"description",
402+
"ms",
403+
ImmutableHistogramData.create(
404+
AggregationTemporality.CUMULATIVE, ImmutableList.of(histogramPointData)));
405+
406+
exporter.export(Collections.singletonList(histogramData));
407+
assertFalse(exporter.lastExportSkippedData());
408+
409+
CreateTimeSeriesRequest request = argumentCaptor.getValue();
410+
TimeSeries timeSeries = request.getTimeSeriesList().get(0);
411+
Distribution distribution = timeSeries.getPoints(0).getValue().getDistributionValue();
412+
413+
// Assert exemplar exists and has expected value
414+
assertThat(distribution.getExemplarsCount()).isEqualTo(1);
415+
Distribution.Exemplar exportedExemplar = distribution.getExemplars(0);
416+
assertThat(exportedExemplar.getValue()).isEqualTo(1.5);
417+
418+
// Assert timestamp mapping
419+
assertThat(exportedExemplar.getTimestamp().getSeconds())
420+
.isEqualTo(recordTimeEpoch / 1_000_000_000L);
421+
assertThat(exportedExemplar.getTimestamp().getNanos())
422+
.isEqualTo((int) (recordTimeEpoch % 1_000_000_000L));
423+
424+
// Assert attachments: SpanContext
425+
boolean hasSpanAttachment =
426+
exportedExemplar.getAttachmentsList().stream()
427+
.anyMatch(any -> any.is(com.google.monitoring.v3.SpanContext.class));
428+
assertThat(hasSpanAttachment).isTrue();
429+
430+
// Assert attachments: DroppedLabels (filtered attributes)
431+
boolean hasFilteredAttrs =
432+
exportedExemplar.getAttachmentsList().stream().anyMatch(any -> any.is(DroppedLabels.class));
433+
assertThat(hasFilteredAttrs).isTrue();
434+
}
435+
350436
@Test
351437
public void getAggregationTemporality() throws IOException {
352438
SpannerCloudMonitoringExporter actualExporter =

0 commit comments

Comments
 (0)