From 826ad85d20c533dfc5221f3ae9c01a8f71a5a21a Mon Sep 17 00:00:00 2001 From: Nilay Shah <58663029+njshah301@users.noreply.github.com> Date: Thu, 8 Jan 2026 00:43:19 +0530 Subject: [PATCH] Add endpoint to trigger MoSAPI metrics export (#2923) This commit introduces a new backend endpoint at `/_dr/task/triggerMosApiServiceState` that initiates the process of fetching the latest service states for all TLDs from the MoSAPI endpoint and exporting them as metrics to Cloud Monitoring. The key changes include: - A new `TriggerServiceStateAction` class that handles the GET request to the new endpoint. - Logic within `MosApiStateService` to concurrently fetch states for all configured TLDs. - A new `MosApiMetrics` class (currently a placeholder) responsible for sending the collected states to the monitoring service. - Unit tests for the new action and the updated service logic. This endpoint will be called periodically to ensure that the MosApi service health metrics are kept up-to-date. --- .../registry/module/RequestComponent.java | 3 + .../google/registry/mosapi/MosApiMetrics.java | 34 +++++++++ .../registry/mosapi/MosApiStateService.java | 44 ++++++++++++ .../mosapi/TriggerServiceStateAction.java | 59 ++++++++++++++++ .../mosapi/MosApiStateServiceTest.java | 44 +++++++++++- .../mosapi/TriggerServiceStateActionTest.java | 69 +++++++++++++++++++ .../google/registry/module/routing.txt | 1 + 7 files changed, 253 insertions(+), 1 deletion(-) create mode 100644 core/src/main/java/google/registry/mosapi/MosApiMetrics.java create mode 100644 core/src/main/java/google/registry/mosapi/TriggerServiceStateAction.java create mode 100644 core/src/test/java/google/registry/mosapi/TriggerServiceStateActionTest.java diff --git a/core/src/main/java/google/registry/module/RequestComponent.java b/core/src/main/java/google/registry/module/RequestComponent.java index 8ff3a3ae4..551b05d8a 100644 --- a/core/src/main/java/google/registry/module/RequestComponent.java +++ b/core/src/main/java/google/registry/module/RequestComponent.java @@ -63,6 +63,7 @@ import google.registry.module.ReadinessProbeAction.ReadinessProbeActionPubApi; import google.registry.module.ReadinessProbeAction.ReadinessProbeConsoleAction; import google.registry.monitoring.whitebox.WhiteboxModule; import google.registry.mosapi.GetServiceStateAction; +import google.registry.mosapi.TriggerServiceStateAction; import google.registry.mosapi.module.MosApiRequestModule; import google.registry.rdap.RdapAutnumAction; import google.registry.rdap.RdapDomainAction; @@ -339,6 +340,8 @@ interface RequestComponent { TmchSmdrlAction tmchSmdrlAction(); + TriggerServiceStateAction triggerServiceStateAction(); + UpdateRegistrarRdapBaseUrlsAction updateRegistrarRdapBaseUrlsAction(); UpdateUserGroupAction updateUserGroupAction(); diff --git a/core/src/main/java/google/registry/mosapi/MosApiMetrics.java b/core/src/main/java/google/registry/mosapi/MosApiMetrics.java new file mode 100644 index 000000000..c2756d461 --- /dev/null +++ b/core/src/main/java/google/registry/mosapi/MosApiMetrics.java @@ -0,0 +1,34 @@ +// 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 com.google.common.flogger.FluentLogger; +import google.registry.mosapi.MosApiModels.TldServiceState; +import jakarta.inject.Inject; +import java.util.List; + +/** Metrics Exporter for MoSAPI. */ +public class MosApiMetrics { + + private static final FluentLogger logger = FluentLogger.forEnclosingClass(); + + @Inject + public MosApiMetrics() {} + + public void recordStates(List states) { + // b/467541269: Logic to push status to Cloud Monitoring goes here + logger.atInfo().log("MoSAPI record metrics logic will be implemented from here"); + } +} diff --git a/core/src/main/java/google/registry/mosapi/MosApiStateService.java b/core/src/main/java/google/registry/mosapi/MosApiStateService.java index 51cf34f29..a51a0651c 100644 --- a/core/src/main/java/google/registry/mosapi/MosApiStateService.java +++ b/core/src/main/java/google/registry/mosapi/MosApiStateService.java @@ -26,8 +26,11 @@ import google.registry.mosapi.MosApiModels.ServiceStatus; import google.registry.mosapi.MosApiModels.TldServiceState; import jakarta.inject.Inject; import jakarta.inject.Named; +import java.util.List; +import java.util.Objects; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutorService; +import java.util.stream.Collectors; /** A service that provides business logic for interacting with MoSAPI Service State. */ public class MosApiStateService { @@ -38,15 +41,19 @@ public class MosApiStateService { private final ImmutableSet tlds; + private final MosApiMetrics mosApiMetrics; + private static final String DOWN_STATUS = "Down"; private static final String FETCH_ERROR_STATUS = "ERROR"; @Inject public MosApiStateService( ServiceMonitoringClient serviceMonitoringClient, + MosApiMetrics mosApiMetrics, @Config("mosapiTlds") ImmutableSet tlds, @Named("mosapiTldExecutor") ExecutorService tldExecutor) { this.serviceMonitoringClient = serviceMonitoringClient; + this.mosApiMetrics = mosApiMetrics; this.tlds = tlds; this.tldExecutor = tldExecutor; } @@ -107,4 +114,41 @@ public class MosApiStateService { } return new ServiceStateSummary(rawState.tld(), rawState.status(), activeIncidents); } + + /** Triggers monitoring exposure for all configured TLDs. */ + public void triggerMetricsForAllServiceStateSummaries() { + ImmutableList> futures = + tlds.stream() + .map( + tld -> + CompletableFuture.supplyAsync( + () -> { + try { + return serviceMonitoringClient.getTldServiceState(tld); + } catch (MosApiException e) { + // Log the error but don't rethrow as RuntimeException + logger.atWarning().withCause(e).log( + "Failed to fetch state for TLD: %s", tld); + return null; // Return null so the stream keeps moving + } + }, + tldExecutor)) + .collect(toImmutableList()); + + List allStates = + futures.stream() + .map(CompletableFuture::join) + .filter(Objects::nonNull) + .collect(Collectors.toList()); + + if (!allStates.isEmpty()) { + try { + mosApiMetrics.recordStates(allStates); + } catch (Exception e) { + logger.atSevere().withCause(e).log("Failed to submit MoSAPI metrics batch."); + } + } else { + logger.atWarning().log("No successful TLD states fetched; skipping metrics push."); + } + } } diff --git a/core/src/main/java/google/registry/mosapi/TriggerServiceStateAction.java b/core/src/main/java/google/registry/mosapi/TriggerServiceStateAction.java new file mode 100644 index 000000000..908d29a2f --- /dev/null +++ b/core/src/main/java/google/registry/mosapi/TriggerServiceStateAction.java @@ -0,0 +1,59 @@ +// 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 com.google.common.flogger.FluentLogger; +import com.google.common.net.MediaType; +import google.registry.request.Action; +import google.registry.request.HttpException.InternalServerErrorException; +import google.registry.request.Response; +import google.registry.request.auth.Auth; +import jakarta.inject.Inject; + +/** + * An action that triggers Metrics action for the current MoSAPI service state result for all TLDs. + */ +@Action( + service = Action.Service.BACKEND, + path = TriggerServiceStateAction.PATH, + method = Action.Method.GET, + auth = Auth.AUTH_ADMIN) +public class TriggerServiceStateAction implements Runnable { + + private static final FluentLogger logger = FluentLogger.forEnclosingClass(); + + public static final String PATH = "/_dr/task/triggerMosApiServiceState"; + private final MosApiStateService stateService; + private final Response response; + + @Inject + public TriggerServiceStateAction(MosApiStateService stateService, Response response) { + this.stateService = stateService; + this.response = response; + } + + @Override + public void run() { + response.setContentType(MediaType.PLAIN_TEXT_UTF_8); + try { + stateService.triggerMetricsForAllServiceStateSummaries(); + response.setStatus(200); + response.setPayload("MoSAPI metrics triggered successfully for all TLDs."); + } catch (Exception e) { + logger.atSevere().withCause(e).log("Error triggering MoSAPI metrics."); + throw new InternalServerErrorException("Failed to process MoSAPI metrics."); + } + } +} diff --git a/core/src/test/java/google/registry/mosapi/MosApiStateServiceTest.java b/core/src/test/java/google/registry/mosapi/MosApiStateServiceTest.java index e4108a74c..4e67c9f17 100644 --- a/core/src/test/java/google/registry/mosapi/MosApiStateServiceTest.java +++ b/core/src/test/java/google/registry/mosapi/MosApiStateServiceTest.java @@ -16,6 +16,8 @@ package google.registry.mosapi; import static com.google.common.truth.Truth.assertThat; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import com.google.common.collect.ImmutableList; @@ -39,6 +41,7 @@ import org.mockito.junit.jupiter.MockitoExtension; class MosApiStateServiceTest { @Mock private ServiceMonitoringClient client; + @Mock private MosApiMetrics metrics; private final ExecutorService executor = MoreExecutors.newDirectExecutorService(); @@ -46,7 +49,7 @@ class MosApiStateServiceTest { @BeforeEach void setUp() { - service = new MosApiStateService(client, ImmutableSet.of("tld1", "tld2"), executor); + service = new MosApiStateService(client, metrics, ImmutableSet.of("tld1", "tld2"), executor); } @Test @@ -127,4 +130,43 @@ class MosApiStateServiceTest { assertThat(summary2.overallStatus()).isEqualTo("ERROR"); assertThat(summary2.activeIncidents()).isEmpty(); } + + @Test + void testTriggerMetricsForAllServiceStateSummaries_success() throws Exception { + TldServiceState state1 = new TldServiceState("tld1", 1L, "Up", ImmutableMap.of()); + TldServiceState state2 = new TldServiceState("tld2", 2L, "Up", ImmutableMap.of()); + + when(client.getTldServiceState("tld1")).thenReturn(state1); + when(client.getTldServiceState("tld2")).thenReturn(state2); + + service.triggerMetricsForAllServiceStateSummaries(); + + verify(metrics) + .recordStates( + argThat( + states -> + states.size() == 2 + && states.stream() + .anyMatch(s -> s.tld().equals("tld1") && s.status().equals("Up")) + && states.stream() + .anyMatch(s -> s.tld().equals("tld2") && s.status().equals("Up")))); + } + + @Test + void testTriggerMetricsForAllServiceStateSummaries_partialFailure_recordsErrorMetric() + throws Exception { + TldServiceState state1 = new TldServiceState("tld1", 1L, "Up", ImmutableMap.of()); + when(client.getTldServiceState("tld1")).thenReturn(state1); + when(client.getTldServiceState("tld2")).thenThrow(new MosApiException("Network Error", null)); + + service.triggerMetricsForAllServiceStateSummaries(); + + verify(metrics) + .recordStates( + argThat( + states -> + states.size() == 1 + && states.stream() + .anyMatch(s -> s.tld().equals("tld1") && s.status().equals("Up")))); + } } diff --git a/core/src/test/java/google/registry/mosapi/TriggerServiceStateActionTest.java b/core/src/test/java/google/registry/mosapi/TriggerServiceStateActionTest.java new file mode 100644 index 000000000..9f7fd0c3b --- /dev/null +++ b/core/src/test/java/google/registry/mosapi/TriggerServiceStateActionTest.java @@ -0,0 +1,69 @@ +// 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.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +import com.google.common.net.MediaType; +import google.registry.request.HttpException.InternalServerErrorException; +import google.registry.testing.FakeResponse; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; + +/** Unit tests for {@link TriggerServiceStateActionTest}. */ +@ExtendWith(MockitoExtension.class) +public class TriggerServiceStateActionTest { + + private final MosApiStateService stateService = mock(MosApiStateService.class); + private final FakeResponse response = new FakeResponse(); + private TriggerServiceStateAction action; + + @BeforeEach + void beforeEach() { + action = new TriggerServiceStateAction(stateService, response); + } + + @Test + void testRun_success() { + action.run(); + + verify(stateService).triggerMetricsForAllServiceStateSummaries(); + + assertThat(response.getContentType()).isEqualTo(MediaType.PLAIN_TEXT_UTF_8); + assertThat(response.getStatus()).isEqualTo(200); + assertThat(response.getPayload()) + .isEqualTo("MoSAPI metrics triggered successfully for all TLDs."); + } + + @Test + void testRun_failure_throwsInternalServerError() { + doThrow(new RuntimeException("Database error")) + .when(stateService) + .triggerMetricsForAllServiceStateSummaries(); + + InternalServerErrorException thrown = + assertThrows(InternalServerErrorException.class, () -> action.run()); + + assertThat(thrown.getMessage()).contains("Failed to process MoSAPI metrics."); + + assertThat(response.getContentType()).isEqualTo(MediaType.PLAIN_TEXT_UTF_8); + } +} diff --git a/core/src/test/resources/google/registry/module/routing.txt b/core/src/test/resources/google/registry/module/routing.txt index db704bf75..afb28e832 100644 --- a/core/src/test/resources/google/registry/module/routing.txt +++ b/core/src/test/resources/google/registry/module/routing.txt @@ -55,6 +55,7 @@ BACKEND /_dr/task/syncRegistrarsSheet SyncRegistrarsSheetA BACKEND /_dr/task/tmchCrl TmchCrlAction POST y APP ADMIN BACKEND /_dr/task/tmchDnl TmchDnlAction POST y APP ADMIN BACKEND /_dr/task/tmchSmdrl TmchSmdrlAction POST y APP ADMIN +BACKEND /_dr/task/triggerMosApiServiceState TriggerServiceStateAction GET n APP ADMIN BACKEND /_dr/task/updateRegistrarRdapBaseUrls UpdateRegistrarRdapBaseUrlsAction GET y APP ADMIN BACKEND /_dr/task/uploadBsaUnavailableNames UploadBsaUnavailableDomainsAction GET,POST n APP ADMIN BACKEND /_dr/task/wipeOutContactHistoryPii WipeOutContactHistoryPiiAction GET n APP ADMIN