diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/metrics/MetricsRequestEventListener.java b/service/src/main/java/org/whispersystems/textsecuregcm/metrics/MetricsRequestEventListener.java index 120f24fd1..6b06274fb 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/metrics/MetricsRequestEventListener.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/metrics/MetricsRequestEventListener.java @@ -11,22 +11,37 @@ 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.internal.process.MappableException; import org.glassfish.jersey.server.monitoring.RequestEvent; import org.glassfish.jersey.server.monitoring.RequestEventListener; +import org.whispersystems.textsecuregcm.util.ua.ClientPlatform; +import org.whispersystems.textsecuregcm.util.ua.UnrecognizedUserAgentException; +import org.whispersystems.textsecuregcm.util.ua.UserAgent; +import org.whispersystems.textsecuregcm.util.ua.UserAgentUtil; import java.util.ArrayList; import java.util.List; +import java.util.Set; /** * Gathers and reports request-level metrics. */ class MetricsRequestEventListener implements RequestEventListener { - 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 TRAFFIC_SOURCE_TAG = "trafficSource"; + static final String REQUEST_COUNTER_NAME = MetricRegistry.name(MetricsRequestEventListener.class, "request"); + static final String PATH_TAG = "path"; + static final String STATUS_CODE_TAG = "status"; + static final String TRAFFIC_SOURCE_TAG = "trafficSource"; + + static final String ANDROID_REQUEST_COUNTER_NAME = MetricRegistry.name(MetricsRequestEventListener.class, "androidRequest"); + static final String DESKTOP_REQUEST_COUNTER_NAME = MetricRegistry.name(MetricsRequestEventListener.class, "desktopRequest"); + static final String OS_TAG = "os"; + static final String SDK_TAG = "sdkVersion"; + + private static final Set ACCEPTABLE_DESKTOP_OS_STRINGS = Set.of("linux", "macos", "windows"); + + private static final String ANDROID_SDK_PREFIX = "Android/"; + private static final int MIN_ANDROID_SDK_VERSION = 19; + private static final int MAX_ANDROID_SDK_VERSION = 50; private final TrafficSource trafficSource; private final MeterRegistry meterRegistry; @@ -53,11 +68,46 @@ class MetricsRequestEventListener implements RequestEventListener { final List userAgentValues = event.getContainerRequest().getRequestHeader("User-Agent"); tags.addAll(UserAgentTagUtil.getUserAgentTags(userAgentValues != null ? userAgentValues.stream().findFirst().orElse(null) : null)); - meterRegistry.counter(COUNTER_NAME, tags).increment(); + meterRegistry.counter(REQUEST_COUNTER_NAME, tags).increment(); + + try { + final UserAgent userAgent = UserAgentUtil.parseUserAgentString(userAgentValues != null ? userAgentValues.stream().findFirst().orElse(null) : null); + + recordDesktopOperatingSystem(userAgent); + recordAndroidSdkVersion(userAgent); + } catch (final UnrecognizedUserAgentException ignored) { + } } } } + @VisibleForTesting + void recordDesktopOperatingSystem(final UserAgent userAgent) { + if (userAgent.getPlatform() == ClientPlatform.DESKTOP) { + if (userAgent.getAdditionalSpecifiers().map(String::toLowerCase).map(ACCEPTABLE_DESKTOP_OS_STRINGS::contains).orElse(false)) { + meterRegistry.counter(DESKTOP_REQUEST_COUNTER_NAME, OS_TAG, userAgent.getAdditionalSpecifiers().get().toLowerCase()).increment(); + } + } + } + + @VisibleForTesting + void recordAndroidSdkVersion(final UserAgent userAgent) { + if (userAgent.getPlatform() == ClientPlatform.ANDROID) { + userAgent.getAdditionalSpecifiers().ifPresent(additionalSpecifiers -> { + if (additionalSpecifiers.startsWith(ANDROID_SDK_PREFIX)) { + try { + final int sdkVersion = Integer.parseInt(additionalSpecifiers, ANDROID_SDK_PREFIX.length(), additionalSpecifiers.length(), 10); + + if (sdkVersion >= MIN_ANDROID_SDK_VERSION && sdkVersion <= MAX_ANDROID_SDK_VERSION) { + meterRegistry.counter(ANDROID_REQUEST_COUNTER_NAME, SDK_TAG, String.valueOf(sdkVersion)); + } + } catch (final NumberFormatException ignored) { + } + } + }); + } + } + @VisibleForTesting static String getPathTemplate(final ExtendedUriInfo uriInfo) { final StringBuilder pathBuilder = new StringBuilder(); diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/metrics/MetricsRequestEventListenerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/metrics/MetricsRequestEventListenerTest.java index 9261ac1bb..5f768fff9 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/metrics/MetricsRequestEventListenerTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/metrics/MetricsRequestEventListenerTest.java @@ -7,11 +7,14 @@ package org.whispersystems.textsecuregcm.metrics; import com.fasterxml.jackson.databind.ObjectMapper; import com.google.protobuf.InvalidProtocolBufferException; +import com.vdurmont.semver4j.Semver; import io.dropwizard.jersey.DropwizardResourceConfig; import io.dropwizard.jersey.jackson.JacksonMessageBodyProvider; import io.micrometer.core.instrument.Counter; import io.micrometer.core.instrument.MeterRegistry; import io.micrometer.core.instrument.Tag; +import junitparams.JUnitParamsRunner; +import junitparams.Parameters; import org.eclipse.jetty.websocket.api.RemoteEndpoint; import org.eclipse.jetty.websocket.api.Session; import org.eclipse.jetty.websocket.api.UpgradeRequest; @@ -24,7 +27,10 @@ import org.glassfish.jersey.server.monitoring.RequestEvent; import org.glassfish.jersey.uri.UriTemplate; import org.junit.Before; import org.junit.Test; +import org.junit.runner.RunWith; import org.mockito.ArgumentCaptor; +import org.whispersystems.textsecuregcm.util.ua.ClientPlatform; +import org.whispersystems.textsecuregcm.util.ua.UserAgent; import org.whispersystems.websocket.WebSocketResourceProvider; import org.whispersystems.websocket.auth.WebsocketAuthValueFactoryProvider; import org.whispersystems.websocket.logging.WebsocketRequestLog; @@ -40,6 +46,7 @@ import java.util.Arrays; import java.util.Collections; import java.util.HashSet; import java.util.LinkedList; +import java.util.List; import java.util.Optional; import java.util.Set; @@ -47,11 +54,15 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertTrue; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyIterable; +import static org.mockito.ArgumentMatchers.anyVararg; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +@RunWith(JUnitParamsRunner.class) public class MetricsRequestEventListenerTest { private MeterRegistry meterRegistry; @@ -89,11 +100,11 @@ public class MetricsRequestEventListenerTest { when(event.getContainerResponse()).thenReturn(response); final ArgumentCaptor> tagCaptor = ArgumentCaptor.forClass(Iterable.class); - when(meterRegistry.counter(eq(MetricsRequestEventListener.COUNTER_NAME), any(Iterable.class))).thenReturn(counter); + when(meterRegistry.counter(eq(MetricsRequestEventListener.REQUEST_COUNTER_NAME), any(Iterable.class))).thenReturn(counter); listener.onEvent(event); - verify(meterRegistry).counter(eq(MetricsRequestEventListener.COUNTER_NAME), tagCaptor.capture()); + verify(meterRegistry).counter(eq(MetricsRequestEventListener.REQUEST_COUNTER_NAME), tagCaptor.capture()); final Iterable tagIterable = tagCaptor.getValue(); final Set tags = new HashSet<>(); @@ -147,7 +158,7 @@ public class MetricsRequestEventListenerTest { when(request.getHeader("User-Agent")).thenReturn("Signal-Android 4.53.7 (Android 8.1)"); final ArgumentCaptor> tagCaptor = ArgumentCaptor.forClass(Iterable.class); - when(meterRegistry.counter(eq(MetricsRequestEventListener.COUNTER_NAME), any(Iterable.class))).thenReturn(counter); + when(meterRegistry.counter(eq(MetricsRequestEventListener.REQUEST_COUNTER_NAME), any(Iterable.class))).thenReturn(counter); provider.onWebSocketConnect(session); @@ -162,7 +173,7 @@ public class MetricsRequestEventListenerTest { assertThat(response.getStatus()).isEqualTo(200); - verify(meterRegistry).counter(eq(MetricsRequestEventListener.COUNTER_NAME), tagCaptor.capture()); + verify(meterRegistry).counter(eq(MetricsRequestEventListener.REQUEST_COUNTER_NAME), tagCaptor.capture()); final Iterable tagIterable = tagCaptor.getValue(); final Set tags = new HashSet<>(); @@ -203,7 +214,7 @@ public class MetricsRequestEventListenerTest { when(session.getRemote()).thenReturn(remoteEndpoint); final ArgumentCaptor> tagCaptor = ArgumentCaptor.forClass(Iterable.class); - when(meterRegistry.counter(eq(MetricsRequestEventListener.COUNTER_NAME), any(Iterable.class))).thenReturn(counter); + when(meterRegistry.counter(eq(MetricsRequestEventListener.REQUEST_COUNTER_NAME), any(Iterable.class))).thenReturn(counter); provider.onWebSocketConnect(session); @@ -218,7 +229,7 @@ public class MetricsRequestEventListenerTest { assertThat(response.getStatus()).isEqualTo(200); - verify(meterRegistry).counter(eq(MetricsRequestEventListener.COUNTER_NAME), tagCaptor.capture()); + verify(meterRegistry).counter(eq(MetricsRequestEventListener.REQUEST_COUNTER_NAME), tagCaptor.capture()); final Iterable tagIterable = tagCaptor.getValue(); final Set tags = new HashSet<>(); @@ -234,6 +245,63 @@ public class MetricsRequestEventListenerTest { assertTrue(tags.containsAll(UserAgentTagUtil.UNRECOGNIZED_TAGS)); } + @Test + @Parameters(method = "argumentsForTestRecordDesktopOperatingSystem") + public void testRecordDesktopOperatingSystem(final UserAgent userAgent, final String expectedOperatingSystem) { + when(meterRegistry.counter(eq(MetricsRequestEventListener.DESKTOP_REQUEST_COUNTER_NAME), (String)any())).thenReturn(counter); + listener.recordDesktopOperatingSystem(userAgent); + + if (expectedOperatingSystem != null) { + final ArgumentCaptor tagCaptor = ArgumentCaptor.forClass(String.class); + verify(meterRegistry).counter(eq(MetricsRequestEventListener.DESKTOP_REQUEST_COUNTER_NAME), tagCaptor.capture()); + + assertEquals(List.of(MetricsRequestEventListener.OS_TAG, expectedOperatingSystem), tagCaptor.getAllValues()); + } else { + verify(meterRegistry, never()).counter(eq(MetricsRequestEventListener.DESKTOP_REQUEST_COUNTER_NAME)); + verify(meterRegistry, never()).counter(eq(MetricsRequestEventListener.DESKTOP_REQUEST_COUNTER_NAME), (String)any()); + } + } + + private static Object argumentsForTestRecordDesktopOperatingSystem() { + return new Object[] { + new Object[] { new UserAgent(ClientPlatform.DESKTOP, new Semver("1.2.3"), "Linux"), "linux" }, + new Object[] { new UserAgent(ClientPlatform.DESKTOP, new Semver("1.2.3"), "macOS"), "macos" }, + new Object[] { new UserAgent(ClientPlatform.DESKTOP, new Semver("1.2.3"), "Windows"), "windows" }, + new Object[] { new UserAgent(ClientPlatform.DESKTOP, new Semver("1.2.3")), null }, + new Object[] { new UserAgent(ClientPlatform.ANDROID, new Semver("4.68.3"), "Android/25"), null }, + new Object[] { new UserAgent(ClientPlatform.IOS, new Semver("3.9.0"), "(iPhone; iOS 12.2; Scale/3.00)"), null }, + }; + } + + @Test + @Parameters(method = "argumentsForTestRecordAndroidSdkVersion") + public void testRecordAndroidSdkVersion(final UserAgent userAgent, final String expectedSdkVersion) { + when(meterRegistry.counter(eq(MetricsRequestEventListener.ANDROID_REQUEST_COUNTER_NAME), (String)any())).thenReturn(counter); + listener.recordAndroidSdkVersion(userAgent); + + if (expectedSdkVersion != null) { + final ArgumentCaptor tagCaptor = ArgumentCaptor.forClass(String.class); + verify(meterRegistry).counter(eq(MetricsRequestEventListener.ANDROID_REQUEST_COUNTER_NAME), tagCaptor.capture()); + + assertEquals(List.of(MetricsRequestEventListener.SDK_TAG, expectedSdkVersion), tagCaptor.getAllValues()); + } else { + verify(meterRegistry, never()).counter(eq(MetricsRequestEventListener.ANDROID_REQUEST_COUNTER_NAME)); + verify(meterRegistry, never()).counter(eq(MetricsRequestEventListener.ANDROID_REQUEST_COUNTER_NAME), (String)any()); + } + } + + private static Object argumentsForTestRecordAndroidSdkVersion() { + return new Object[] { + new Object[] { new UserAgent(ClientPlatform.ANDROID, new Semver("4.68.3"), "Android/1"), null }, + new Object[] { new UserAgent(ClientPlatform.ANDROID, new Semver("4.68.3"), "Android/25"), "25" }, + new Object[] { new UserAgent(ClientPlatform.ANDROID, new Semver("4.68.3"), "Android/700000"), null }, + new Object[] { new UserAgent(ClientPlatform.ANDROID, new Semver("4.68.3"), "Android/"), null }, + new Object[] { new UserAgent(ClientPlatform.ANDROID, new Semver("4.68.3"), null), null }, + new Object[] { new UserAgent(ClientPlatform.DESKTOP, new Semver("1.2.3"), "Linux"), null }, + new Object[] { new UserAgent(ClientPlatform.IOS, new Semver("3.9.0"), "(iPhone; iOS 12.2; Scale/3.00)"), null } + }; + } + private static SubProtocol.WebSocketResponseMessage getResponse(ArgumentCaptor responseCaptor) throws InvalidProtocolBufferException { return SubProtocol.WebSocketMessage.parseFrom(responseCaptor.getValue().array()).getResponse(); }