diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java index 7950721ff..050291102 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java @@ -82,6 +82,7 @@ import org.whispersystems.textsecuregcm.mappers.RateLimitExceededExceptionMapper import org.whispersystems.textsecuregcm.metrics.CpuUsageGauge; import org.whispersystems.textsecuregcm.metrics.FileDescriptorGauge; import org.whispersystems.textsecuregcm.metrics.FreeMemoryGauge; +import org.whispersystems.textsecuregcm.metrics.MetricsApplicationEventListener; import org.whispersystems.textsecuregcm.metrics.NetworkReceivedGauge; import org.whispersystems.textsecuregcm.metrics.NetworkSentGauge; import org.whispersystems.textsecuregcm.providers.RedisClientFactory; @@ -316,6 +317,8 @@ public class WhisperServerService extends Application accountAuthFilter = new BasicCredentialAuthFilter.Builder().setAuthenticator(accountAuthenticator).buildAuthFilter (); AuthFilter disabledPermittedAccountAuthFilter = new BasicCredentialAuthFilter.Builder().setAuthenticator(disabledPermittedAccountAuthenticator).buildAuthFilter(); + environment.jersey().register(new MetricsApplicationEventListener()); + environment.jersey().register(new PolymorphicAuthDynamicFeature<>(ImmutableMap.of(Account.class, accountAuthFilter, DisabledPermittedAccount.class, disabledPermittedAccountAuthFilter))); environment.jersey().register(new PolymorphicAuthValueFactoryProvider.Binder<>(ImmutableSet.of(Account.class, DisabledPermittedAccount.class))); diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/metrics/MetricsApplicationEventListener.java b/service/src/main/java/org/whispersystems/textsecuregcm/metrics/MetricsApplicationEventListener.java new file mode 100644 index 000000000..b014c8b4f --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/metrics/MetricsApplicationEventListener.java @@ -0,0 +1,23 @@ +package org.whispersystems.textsecuregcm.metrics; + +import org.glassfish.jersey.server.monitoring.ApplicationEvent; +import org.glassfish.jersey.server.monitoring.ApplicationEventListener; +import org.glassfish.jersey.server.monitoring.RequestEvent; +import org.glassfish.jersey.server.monitoring.RequestEventListener; + +/** + * Delegates request events to a listener that captures and reports request-level metrics. + */ +public class MetricsApplicationEventListener implements ApplicationEventListener { + + private final MetricsRequestEventListener metricsRequestEventListener = new MetricsRequestEventListener(); + + @Override + public void onEvent(final ApplicationEvent event) { + } + + @Override + public RequestEventListener onRequest(final RequestEvent requestEvent) { + return metricsRequestEventListener; + } +} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/metrics/MetricsRequestEventListener.java b/service/src/main/java/org/whispersystems/textsecuregcm/metrics/MetricsRequestEventListener.java new file mode 100644 index 000000000..2fb5312a4 --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/metrics/MetricsRequestEventListener.java @@ -0,0 +1,102 @@ +package org.whispersystems.textsecuregcm.metrics; + +import com.codahale.metrics.MetricRegistry; +import com.google.common.annotations.VisibleForTesting; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.Metrics; +import io.micrometer.core.instrument.Tag; +import org.glassfish.jersey.server.ExtendedUriInfo; +import org.glassfish.jersey.server.monitoring.RequestEvent; +import org.glassfish.jersey.server.monitoring.RequestEventListener; +import org.whispersystems.textsecuregcm.util.Pair; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Gathers and reports request-level metrics. + */ +class MetricsRequestEventListener implements RequestEventListener { + + static final int MAX_VERSIONS = 10_000; + + static final String COUNTER_NAME = MetricRegistry.name(MetricsRequestEventListener.class, "request"); + + static final String PATH_TAG = "path"; + static final String STATUS_CODE_TAG = "status"; + static final String PLATFORM_TAG = "platform"; + static final String VERSION_TAG = "clientVersion"; + + static final List OVERFLOW_TAGS = List.of(Tag.of(PLATFORM_TAG, "overflow"), Tag.of(VERSION_TAG, "overflow")); + static final List UNRECOGNIZED_TAGS = List.of(Tag.of(PLATFORM_TAG, "unrecognized"), Tag.of(VERSION_TAG, "unrecognized")); + + private static final Pattern USER_AGENT_PATTERN = Pattern.compile("Signal-([^ ]+) ([^ ]+).*$", Pattern.CASE_INSENSITIVE); + + private final MeterRegistry meterRegistry; + private final Set> seenVersions = new HashSet<>(); + + public MetricsRequestEventListener() { + this(Metrics.globalRegistry); + } + + @VisibleForTesting + MetricsRequestEventListener(final MeterRegistry meterRegistry) { + this.meterRegistry = meterRegistry; + } + + @Override + public void onEvent(final RequestEvent event) { + if (event.getType() == RequestEvent.Type.FINISHED) { + if (!event.getUriInfo().getMatchedTemplates().isEmpty()) { + final List tags = new ArrayList<>(4); + tags.add(Tag.of(PATH_TAG, getPathTemplate(event.getUriInfo()))); + tags.add(Tag.of(STATUS_CODE_TAG, String.valueOf(event.getContainerResponse().getStatus()))); + + event.getContainerRequest().getRequestHeader("User-Agent") + .stream() + .findFirst() + .map(this::getUserAgentTags) + .ifPresent(tags::addAll); + + meterRegistry.counter(COUNTER_NAME, tags).increment(); + } + } + } + + @VisibleForTesting + static String getPathTemplate(final ExtendedUriInfo uriInfo) { + final StringBuilder pathBuilder = new StringBuilder(); + + for (int i = uriInfo.getMatchedTemplates().size() - 1; i >= 0; i--) { + pathBuilder.append(uriInfo.getMatchedTemplates().get(i).getTemplate()); + } + + return pathBuilder.toString(); + } + + @VisibleForTesting + List getUserAgentTags(final String userAgent) { + final Matcher matcher = USER_AGENT_PATTERN.matcher(userAgent); + final List tags; + + if (matcher.matches()) { + final Pair platformAndVersion = new Pair<>(matcher.group(1).toLowerCase(), matcher.group(2)); + + final boolean allowVersion; + + synchronized (seenVersions) { + allowVersion = seenVersions.contains(platformAndVersion) || (seenVersions.size() < MAX_VERSIONS && seenVersions.add(platformAndVersion)); + } + + tags = allowVersion ? List.of(Tag.of(PLATFORM_TAG, platformAndVersion.first()), Tag.of(VERSION_TAG, platformAndVersion.second())) : OVERFLOW_TAGS; + } else { + tags = UNRECOGNIZED_TAGS; + } + + return tags; + } +} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/metrics/MetricsRequestEventListenerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/metrics/MetricsRequestEventListenerTest.java new file mode 100644 index 000000000..eb593e40e --- /dev/null +++ b/service/src/test/java/org/whispersystems/textsecuregcm/metrics/MetricsRequestEventListenerTest.java @@ -0,0 +1,127 @@ +package org.whispersystems.textsecuregcm.metrics; + +import io.micrometer.core.instrument.Counter; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.Tag; +import org.bouncycastle.ocsp.Req; +import org.glassfish.jersey.server.ContainerRequest; +import org.glassfish.jersey.server.ContainerResponse; +import org.glassfish.jersey.server.ExtendedUriInfo; +import org.glassfish.jersey.server.monitoring.RequestEvent; +import org.glassfish.jersey.uri.UriTemplate; +import org.junit.Before; +import org.junit.Test; +import org.mockito.ArgumentCaptor; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Optional; +import java.util.Set; + +import static org.junit.Assert.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class MetricsRequestEventListenerTest { + + private MeterRegistry meterRegistry; + private Counter counter; + + private MetricsRequestEventListener listener; + + @Before + public void setup() { + meterRegistry = mock(MeterRegistry.class); + counter = mock(Counter.class); + + listener = new MetricsRequestEventListener(meterRegistry); + } + + @Test + @SuppressWarnings("unchecked") + public void testOnEvent() { + final String path = "/test"; + final int statusCode = 200; + + final ExtendedUriInfo uriInfo = mock(ExtendedUriInfo.class); + when(uriInfo.getMatchedTemplates()).thenReturn(Collections.singletonList(new UriTemplate(path))); + + final ContainerRequest request = mock(ContainerRequest.class); + when(request.getRequestHeader("User-Agent")).thenReturn(Collections.singletonList("Signal-Android 4.53.7 (Android 8.1)")); + + final ContainerResponse response = mock(ContainerResponse.class); + when(response.getStatus()).thenReturn(statusCode); + + final RequestEvent event = mock(RequestEvent.class); + when(event.getType()).thenReturn(RequestEvent.Type.FINISHED); + when(event.getUriInfo()).thenReturn(uriInfo); + when(event.getContainerRequest()).thenReturn(request); + when(event.getContainerResponse()).thenReturn(response); + + final ArgumentCaptor> tagCaptor = ArgumentCaptor.forClass(Iterable.class); + when(meterRegistry.counter(eq(MetricsRequestEventListener.COUNTER_NAME), any(Iterable.class))).thenReturn(counter); + + listener.onEvent(event); + + verify(meterRegistry).counter(eq(MetricsRequestEventListener.COUNTER_NAME), tagCaptor.capture()); + + final Iterable tagIterable = tagCaptor.getValue(); + final Set tags = new HashSet<>(); + + for (final Tag tag : tagIterable) { + tags.add(tag); + } + + assertEquals(4, tags.size()); + assertTrue(tags.contains(Tag.of(MetricsRequestEventListener.PATH_TAG, path))); + assertTrue(tags.contains(Tag.of(MetricsRequestEventListener.STATUS_CODE_TAG, String.valueOf(statusCode)))); + assertTrue(tags.contains(Tag.of(MetricsRequestEventListener.PLATFORM_TAG, "android"))); + assertTrue(tags.contains(Tag.of(MetricsRequestEventListener.VERSION_TAG, "4.53.7"))); + } + + @Test + public void testGetPathTemplate() { + final UriTemplate firstComponent = new UriTemplate("/first"); + final UriTemplate secondComponent = new UriTemplate("/second"); + final UriTemplate thirdComponent = new UriTemplate("/{param}/{moreDifferentParam}"); + + final ExtendedUriInfo uriInfo = mock(ExtendedUriInfo.class); + when(uriInfo.getMatchedTemplates()).thenReturn(Arrays.asList(thirdComponent, secondComponent, firstComponent)); + + assertEquals("/first/second/{param}/{moreDifferentParam}", MetricsRequestEventListener.getPathTemplate(uriInfo)); + } + + @Test + public void testGetUserAgentTags() { + assertEquals(MetricsRequestEventListener.UNRECOGNIZED_TAGS, + listener.getUserAgentTags("This is obviously not a reasonable User-Agent string.")); + + final List tags = listener.getUserAgentTags("Signal-Android 4.53.7 (Android 8.1)"); + + assertEquals(2, tags.size()); + assertTrue(tags.contains(Tag.of(MetricsRequestEventListener.PLATFORM_TAG, "android"))); + assertTrue(tags.contains(Tag.of(MetricsRequestEventListener.VERSION_TAG, "4.53.7"))); + } + + @Test + public void testGetUserAgentTagsFlooded() { + for (int i = 0; i < MetricsRequestEventListener.MAX_VERSIONS; i++) { + listener.getUserAgentTags(String.format("Signal-Android 1.0.%d (Android 8.1)", i)); + } + + assertEquals(MetricsRequestEventListener.OVERFLOW_TAGS, + listener.getUserAgentTags("Signal-Android 2.0.0 (Android 8.1)")); + + final List tags = listener.getUserAgentTags("Signal-Android 1.0.0 (Android 8.1)"); + + assertEquals(2, tags.size()); + assertTrue(tags.contains(Tag.of(MetricsRequestEventListener.PLATFORM_TAG, "android"))); + assertTrue(tags.contains(Tag.of(MetricsRequestEventListener.VERSION_TAG, "1.0.0"))); + } +}