Add MosApiMetrics exporter (#2931)

* Add MosApiMetrics exporter with status code mapping

Introduces the metrics exporter for the MoSAPI system.

- Implements `MosApiMetrics` to export TLD and service states to Cloud Monitoring.
- Maps ICANN status codes to numeric gauges: 1 (UP), 0 (DOWN), and 2 (DISABLED/INCONCLUSIVE).
- Sets `MAX_TIMESERIES_PER_REQUEST` to 195 to respect Cloud Monitoring API limits

* Automate metric descriptor creation on startup in Cloud Monitoring

* Refactor MoSAPI metrics for resilience and standards

* Refactor and nits

- Kept projectName as part constant instead of inside method signature
- Added Summary logs for metrics execution
- Metric Executor defaults to Single Threaded

* junit test refactoring

* Fix Metric kind to GAUGE for all metrics

* Refactor MosApiMetrics to remove async ExecutorService

* Add LockHandler for Metric Descriptor creation

* Update LockHandler lease time to one hour and refactoring
This commit is contained in:
Nilay Shah
2026-01-29 14:53:05 +00:00
committed by GitHub
parent a138806199
commit 71c9407f07
8 changed files with 603 additions and 14 deletions
@@ -0,0 +1,236 @@
// Copyright 2026 The Nomulus Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package google.registry.mosapi;
import static com.google.common.truth.Truth.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import com.google.api.services.monitoring.v3.Monitoring;
import com.google.api.services.monitoring.v3.model.CreateTimeSeriesRequest;
import com.google.api.services.monitoring.v3.model.MetricDescriptor;
import com.google.api.services.monitoring.v3.model.TimeSeries;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
import google.registry.mosapi.MosApiModels.ServiceStatus;
import google.registry.mosapi.MosApiModels.TldServiceState;
import google.registry.request.lock.LockHandler;
import google.registry.testing.FakeClock;
import java.io.IOException;
import java.util.List;
import java.util.concurrent.Callable;
import org.joda.time.DateTime;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
/** Unit tests for {@link MosApiMetrics}. */
public class MosApiMetricsTest {
private static final String PROJECT_ID = "domain-registry-test";
private final Monitoring monitoringClient = mock(Monitoring.class);
private final Monitoring.Projects projects = mock(Monitoring.Projects.class);
private final LockHandler lockHandler = mock(LockHandler.class);
private final Monitoring.Projects.TimeSeries timeSeriesResource =
mock(Monitoring.Projects.TimeSeries.class);
private final Monitoring.Projects.TimeSeries.Create createRequest =
mock(Monitoring.Projects.TimeSeries.Create.class);
// Mocks for Metric Descriptors
private final Monitoring.Projects.MetricDescriptors metricDescriptorsResource =
mock(Monitoring.Projects.MetricDescriptors.class);
private final Monitoring.Projects.MetricDescriptors.Create createDescriptorRequest =
mock(Monitoring.Projects.MetricDescriptors.Create.class);
// Fixed Clock for deterministic testing
private final FakeClock clock = new FakeClock(DateTime.parse("2026-01-01T12:00:00Z"));
private MosApiMetrics mosApiMetrics;
@BeforeEach
void setUp() throws IOException, NoSuchFieldException, IllegalAccessException {
MosApiMetrics.isDescriptorInitialized.set(false);
when(monitoringClient.projects()).thenReturn(projects);
when(projects.timeSeries()).thenReturn(timeSeriesResource);
when(timeSeriesResource.create(anyString(), any(CreateTimeSeriesRequest.class)))
.thenReturn(createRequest);
// Setup for Metric Descriptors
when(projects.metricDescriptors()).thenReturn(metricDescriptorsResource);
when(metricDescriptorsResource.create(anyString(), any(MetricDescriptor.class)))
.thenReturn(createDescriptorRequest);
when(lockHandler.executeWithLocks(any(Callable.class), any(), any(), any()))
.thenAnswer(
invocation -> {
((Callable<?>) invocation.getArgument(0)).call();
return true;
});
mosApiMetrics = new MosApiMetrics(monitoringClient, PROJECT_ID, clock, lockHandler);
}
@Test
void testRecordStates_lazilyInitializesMetricDescriptors() throws IOException {
TldServiceState state = createTldState("test.tld", "UP", "UP");
mosApiMetrics.recordStates(ImmutableList.of(state));
ArgumentCaptor<MetricDescriptor> captor = ArgumentCaptor.forClass(MetricDescriptor.class);
verify(metricDescriptorsResource, times(3))
.create(eq("projects/" + PROJECT_ID), captor.capture());
List<MetricDescriptor> descriptors = captor.getAllValues();
// Verify TLD Status Descriptor
MetricDescriptor tldStatus =
descriptors.stream()
.filter(d -> d.getType().endsWith("tld_status"))
.findFirst()
.orElseThrow();
assertThat(tldStatus.getMetricKind()).isEqualTo("GAUGE");
assertThat(tldStatus.getValueType()).isEqualTo("INT64");
// Verify Service Status Descriptor
MetricDescriptor serviceStatus =
descriptors.stream()
.filter(d -> d.getType().endsWith("service_status"))
.findFirst()
.orElseThrow();
assertThat(serviceStatus.getMetricKind()).isEqualTo("GAUGE");
assertThat(serviceStatus.getValueType()).isEqualTo("INT64");
// Verify Emergency Usage Descriptor
MetricDescriptor emergencyUsage =
descriptors.stream()
.filter(d -> d.getType().endsWith("emergency_usage"))
.findFirst()
.orElseThrow();
assertThat(emergencyUsage.getMetricKind()).isEqualTo("GAUGE");
assertThat(emergencyUsage.getValueType()).isEqualTo("DOUBLE");
}
@Test
void testRecordStates_mapsStatusesToCorrectValues() throws IOException {
TldServiceState stateUp = createTldState("tld-up", "UP", "UP");
TldServiceState stateDown = createTldState("tld-down", "DOWN", "DOWN");
TldServiceState stateMaint = createTldState("tld-maint", "UP-INCONCLUSIVE", "DISABLED");
mosApiMetrics.recordStates(ImmutableList.of(stateUp, stateDown, stateMaint));
ArgumentCaptor<CreateTimeSeriesRequest> captor =
ArgumentCaptor.forClass(CreateTimeSeriesRequest.class);
verify(timeSeriesResource).create(eq("projects/" + PROJECT_ID), captor.capture());
List<TimeSeries> pushedSeries = captor.getValue().getTimeSeries();
// Verify TLD Status Mappings: 1 (UP), 0 (DOWN), 2 (UP-INCONCLUSIVE)
assertThat(getValueFor(pushedSeries, "tld-up", "tld_status")).isEqualTo(1);
assertThat(getValueFor(pushedSeries, "tld-down", "tld_status")).isEqualTo(0);
assertThat(getValueFor(pushedSeries, "tld-maint", "tld_status")).isEqualTo(2);
// Verify Service Status Mappings: UP -> 1, DOWN -> 0, DISABLED -> 2
assertThat(getValueFor(pushedSeries, "tld-up", "service_status")).isEqualTo(1);
assertThat(getValueFor(pushedSeries, "tld-down", "service_status")).isEqualTo(0);
assertThat(getValueFor(pushedSeries, "tld-maint", "service_status")).isEqualTo(2);
// 3. Verify Emergency Usage (DOUBLE)
assertThat(getValueFor(pushedSeries, "tld-up", "emergency_usage").doubleValue())
.isEqualTo(50.0);
assertThat(getValueFor(pushedSeries, "tld-down", "emergency_usage").doubleValue())
.isEqualTo(50.0);
assertThat(getValueFor(pushedSeries, "tld-maint", "emergency_usage").doubleValue())
.isEqualTo(50.0);
}
@Test
void testRecordStates_partitionsTimeSeries_atLimit() throws IOException {
ImmutableList<TldServiceState> largeBatch =
java.util.stream.IntStream.range(0, 70)
.mapToObj(i -> createTldState("tld-" + i, "UP", "UP"))
.collect(ImmutableList.toImmutableList());
mosApiMetrics.recordStates(largeBatch);
verify(timeSeriesResource, times(2))
.create(eq("projects/" + PROJECT_ID), any(CreateTimeSeriesRequest.class));
}
@Test
void testMetricStructure_containsExpectedLabelsAndResource() throws IOException {
TldServiceState state = createTldState("example.tld", "UP", "UP");
mosApiMetrics.recordStates(ImmutableList.of(state));
ArgumentCaptor<CreateTimeSeriesRequest> captor =
ArgumentCaptor.forClass(CreateTimeSeriesRequest.class);
verify(timeSeriesResource).create(anyString(), captor.capture());
TimeSeries ts = captor.getValue().getTimeSeries().get(0);
assertThat(ts.getMetric().getType()).startsWith("custom.googleapis.com/mosapi/");
assertThat(ts.getMetric().getLabels()).containsEntry("tld", "example.tld");
assertThat(ts.getResource().getType()).isEqualTo("global");
assertThat(ts.getResource().getLabels()).containsEntry("project_id", PROJECT_ID);
// Verify that the interval matches our fixed clock
assertThat(ts.getPoints().get(0).getInterval().getEndTime()).isEqualTo("2026-01-01T12:00:00Z");
}
/** Extracts the numeric value for a specific TLD and metric type from a list of TimeSeries. */
private Number getValueFor(List<TimeSeries> seriesList, String tld, String metricSuffix) {
String fullMetric = "custom.googleapis.com/mosapi/" + metricSuffix;
return seriesList.stream()
.filter(ts -> tld.equals(ts.getMetric().getLabels().get("tld")))
.filter(ts -> ts.getMetric().getType().equals(fullMetric))
.findFirst()
.map(
ts -> {
Double dVal = ts.getPoints().get(0).getValue().getDoubleValue();
if (dVal != null) {
return (Number) dVal;
}
// Fallback to Int64.
return (Number) ts.getPoints().get(0).getValue().getInt64Value();
})
.get();
}
@Test
void testRecordStates_skipsInitialization_ifLockNotAcquired() throws IOException {
when(lockHandler.executeWithLocks(any(Callable.class), any(), any(), any())).thenReturn(false);
TldServiceState state = createTldState("test.tld", "UP", "UP");
mosApiMetrics.recordStates(ImmutableList.of(state));
verify(metricDescriptorsResource, never()).create(anyString(), any());
}
/** Mocks a TldServiceState with a single service status. */
private TldServiceState createTldState(String tld, String tldStatus, String serviceStatus) {
ServiceStatus sStatus = mock(ServiceStatus.class);
when(sStatus.status()).thenReturn(serviceStatus);
when(sStatus.emergencyThreshold()).thenReturn(50.0);
TldServiceState state = mock(TldServiceState.class);
when(state.tld()).thenReturn(tld);
when(state.status()).thenReturn(tldStatus);
when(state.serviceStatuses()).thenReturn(ImmutableMap.of("dns", sStatus));
return state;
}
}
@@ -169,4 +169,23 @@ class MosApiStateServiceTest {
&& states.stream()
.anyMatch(s -> s.tld().equals("tld1") && s.status().equals("Up"))));
}
@Test
void testTriggerMetrics_filtersOutInvalidContractStates() throws Exception {
// 1. Valid State
TldServiceState validState = new TldServiceState("tld1", 1L, "Up", ImmutableMap.of());
// 2. Invalid State (Status is NULL)
// We instantiate it directly to simulate a bad response object.
TldServiceState invalidState = new TldServiceState("tld2", 2L, null, ImmutableMap.of());
when(client.getTldServiceState("tld1")).thenReturn(validState);
when(client.getTldServiceState("tld2")).thenReturn(invalidState);
service.triggerMetricsForAllServiceStateSummaries();
// Verify: Only the valid state (tld1) is passed to recordStates
verify(metrics)
.recordStates(argThat(states -> states.size() == 1 && states.get(0).tld().equals("tld1")));
}
}