diff --git a/service/config/sample.yml b/service/config/sample.yml index c7976c7e9..da183ac4d 100644 --- a/service/config/sample.yml +++ b/service/config/sample.yml @@ -549,3 +549,12 @@ asnTable: objectKey: asn.tsv maxSize: 100000 refreshInterval: PT10S + +callQualitySurvey: + pubSubPublisher: + project: example-project + topic: example-topic + credentialConfiguration: | + { + "credential": "configuration" + } diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerConfiguration.java b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerConfiguration.java index d5dd0d133..1e9f760ce 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerConfiguration.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerConfiguration.java @@ -21,6 +21,7 @@ import org.whispersystems.textsecuregcm.configuration.AppleDeviceCheckConfigurat import org.whispersystems.textsecuregcm.configuration.AwsCredentialsProviderFactory; import org.whispersystems.textsecuregcm.configuration.BadgesConfiguration; import org.whispersystems.textsecuregcm.configuration.BraintreeConfiguration; +import org.whispersystems.textsecuregcm.configuration.CallQualitySurveyConfiguration; import org.whispersystems.textsecuregcm.configuration.Cdn3StorageManagerConfiguration; import org.whispersystems.textsecuregcm.configuration.CdnConfiguration; import org.whispersystems.textsecuregcm.configuration.CircuitBreakerConfiguration; @@ -353,6 +354,11 @@ public class WhisperServerConfiguration extends Configuration { @JsonProperty private S3ObjectMonitorFactory asnTable; + @Valid + @NotNull + @JsonProperty + private CallQualitySurveyConfiguration callQualitySurvey; + public TlsKeyStoreConfiguration getTlsKeyStoreConfiguration() { return tlsKeyStore; } @@ -591,4 +597,8 @@ public class WhisperServerConfiguration extends Configuration { public S3ObjectMonitorFactory getAsnTableConfiguration() { return asnTable; } + + public CallQualitySurveyConfiguration getCallQualitySurveyConfiguration() { + return callQualitySurvey; + } } diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java index 61a7e94d3..e065ec000 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java @@ -179,6 +179,7 @@ import org.whispersystems.textsecuregcm.mappers.RegistrationServiceSenderExcepti import org.whispersystems.textsecuregcm.mappers.ServerRejectedExceptionMapper; import org.whispersystems.textsecuregcm.mappers.SubscriptionExceptionMapper; import org.whispersystems.textsecuregcm.metrics.BackupMetrics; +import org.whispersystems.textsecuregcm.metrics.CallQualitySurveyManager; import org.whispersystems.textsecuregcm.metrics.MessageMetrics; import org.whispersystems.textsecuregcm.metrics.MetricsApplicationEventListener; import org.whispersystems.textsecuregcm.metrics.MetricsHttpChannelListener; @@ -582,6 +583,10 @@ public class WhisperServerService extends Application asnInfoProviderSupplier; + private final PublisherInterface pubSubPublisher; + private final Clock clock; + private final Executor pubSubCallbackExecutor; + + private final String PUB_SUB_MESSAGE_COUNTER_NAME = MetricsUtil.name(CallQualitySurveyManager.class, "pubSubMessage"); + + private static final Logger logger = LoggerFactory.getLogger(CallQualitySurveyManager.class); + + public CallQualitySurveyManager(final Supplier asnInfoProviderSupplier, + final PublisherInterface pubSubPublisher, + final Clock clock, + final Executor pubSubCallbackExecutor) { + + this.asnInfoProviderSupplier = asnInfoProviderSupplier; + this.pubSubPublisher = pubSubPublisher; + this.clock = clock; + this.pubSubCallbackExecutor = pubSubCallbackExecutor; + } + + public void submitCallQualitySurvey(final SubmitCallQualitySurveyRequest submitCallQualitySurveyRequest, + final String remoteAddress, + final String userAgentString) { + + final CallQualitySurveyResponsePubSubMessage.Builder pubSubMessageBuilder = + CallQualitySurveyResponsePubSubMessage.newBuilder() + .setResponseId(UUID.randomUUID().toString()) + .setSubmissionTimestamp(clock.millis() * 1000); + + try { + final UserAgent userAgent = UserAgentUtil.parseUserAgentString(userAgentString); + + pubSubMessageBuilder.setClientPlatform(userAgent.platform().name().toLowerCase(Locale.ROOT)); + pubSubMessageBuilder.setClientVersion(userAgent.version().toString()); + + if (StringUtils.isNotBlank(userAgent.additionalSpecifiers())) { + pubSubMessageBuilder.setClientUaAdditionalSpecifiers(userAgent.additionalSpecifiers()); + } + } catch (final UnrecognizedUserAgentException _) { + } + + asnInfoProviderSupplier.get().lookup(remoteAddress) + .ifPresent(asnInfo -> pubSubMessageBuilder.setAsnRegion(asnInfo.regionCode())); + + if (submitCallQualitySurveyRequest.hasUserSatisfied()) { + pubSubMessageBuilder.setUserSatisfied(submitCallQualitySurveyRequest.getUserSatisfied()); + } + + pubSubMessageBuilder.addAllCallQualityIssues(submitCallQualitySurveyRequest.getCallQualityIssuesList()); + + if (submitCallQualitySurveyRequest.hasAdditionalIssuesDescription()) { + pubSubMessageBuilder.setAdditionalIssuesDescription(submitCallQualitySurveyRequest.getAdditionalIssuesDescription()); + } + + if (submitCallQualitySurveyRequest.hasDebugLogUrl()) { + pubSubMessageBuilder.setDebugLogUrl(submitCallQualitySurveyRequest.getDebugLogUrl()); + } + + if (submitCallQualitySurveyRequest.hasStartTimestamp()) { + pubSubMessageBuilder.setStartTimestamp(submitCallQualitySurveyRequest.getStartTimestamp()); + } + + if (submitCallQualitySurveyRequest.hasEndTimestamp()) { + pubSubMessageBuilder.setEndTimestamp(submitCallQualitySurveyRequest.getEndTimestamp()); + } + + if (submitCallQualitySurveyRequest.hasCallType()) { + pubSubMessageBuilder.setCallType(submitCallQualitySurveyRequest.getCallType()); + } + + if (submitCallQualitySurveyRequest.hasSuccess()) { + pubSubMessageBuilder.setSuccess(submitCallQualitySurveyRequest.getSuccess()); + } + + if (submitCallQualitySurveyRequest.hasCallEndReason()) { + pubSubMessageBuilder.setCallEndReason(submitCallQualitySurveyRequest.getCallEndReason()); + } + + if (submitCallQualitySurveyRequest.hasRttMedian()) { + pubSubMessageBuilder.setRttMedian(submitCallQualitySurveyRequest.getRttMedian()); + } + + if (submitCallQualitySurveyRequest.hasJitterMedian()) { + pubSubMessageBuilder.setJitterMedian(submitCallQualitySurveyRequest.getJitterMedian()); + } + + if (submitCallQualitySurveyRequest.hasPacketLossFraction()) { + pubSubMessageBuilder.setPacketLossFraction(submitCallQualitySurveyRequest.getPacketLossFraction()); + } + + if (submitCallQualitySurveyRequest.hasCallTelemetry()) { + pubSubMessageBuilder.setCallTelemetry(submitCallQualitySurveyRequest.getCallTelemetry()); + } + + GoogleApiUtil.toCompletableFuture(pubSubPublisher.publish(PubsubMessage.newBuilder() + .setData(pubSubMessageBuilder.build().toByteString()) + .build()), pubSubCallbackExecutor) + .whenComplete((_, throwable) -> { + if (throwable != null) { + logger.warn("Failed to publish call quality survey pub/sub message", throwable); + } + + Metrics.counter(PUB_SUB_MESSAGE_COUNTER_NAME, "success", String.valueOf(throwable == null)) + .increment(); + }); + } +} diff --git a/service/src/main/proto/CallQualitySurveyPubSub.proto b/service/src/main/proto/CallQualitySurveyPubSub.proto new file mode 100644 index 000000000..abce776f5 --- /dev/null +++ b/service/src/main/proto/CallQualitySurveyPubSub.proto @@ -0,0 +1,88 @@ +// Copyright 2025 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +// Note: proto2 is de-facto required here because BigQuery pub/sub +// subscriptions demand strict matching of "modes" (i.e. nullability), and +// the BigQuery subscription system doesn't recognize proto3 fields as +// "required". +syntax = "proto2"; + +package org.signal.calling.survey; + +option java_multiple_files = true; + +message CallQualitySurveyResponsePubSubMessage { + // A unique identifier for this call quality survey response + required string response_id = 1; + + // The time at which this call quality survey response was received in + // microseconds since the epoch (see + // https://cloud.google.com/bigquery/docs/reference/standard-sql/data-types#timestamp_type) + required int64 submission_timestamp = 2; + + // The geographic region (an ISO 3166-1 alpha-2 region code) associated with + // the IP address of the client that submitted this call quality survey + // response + optional string asn_region = 3; + + // The platform of the client that submitted this call quality survey response + optional string client_platform = 4; + + // The semantic version of the client that submitted this call quality survey + // response + optional string client_version = 5; + + // Any additional specifiers (e.g. "Windows 10.0.19045 libsignal/0.81.1") from + // the caller's user-agent string + optional string client_ua_additional_specifiers = 6; + + // Indicates whether the user was generally satisfied with the quality of the + // call + optional bool user_satisfied = 7; + + // A list of call quality issues selected by the user + repeated string call_quality_issues = 8; + + // A free-form description of any additional issues as written by the user + optional string additional_issues_description = 9; + + // A URL for a set of debug logs associated with the call if the user chose to + // submit debug logs + optional string debug_log_url = 10; + + // The time at which the call started in microseconds since the epoch (see + // https://cloud.google.com/bigquery/docs/reference/standard-sql/data-types#timestamp_type) + optional int64 start_timestamp = 11; + + // The time at which the call ended in microseconds since the epoch (see + // https://cloud.google.com/bigquery/docs/reference/standard-sql/data-types#timestamp_type) + optional int64 end_timestamp = 12; + + // The type of call; note that direct voice calls can become video calls and + // vice versa, and this field indicates which mode was selected at call + // initiation time. At the time of writing, expected call types are + // "direct_voice", "direct_video", "group", and "call_link". + optional string call_type = 13; + + // Indicates whether the call completed without error or if it terminated + // abnormally + optional bool success = 14; + + // A client-defined, but human-readable reason for call termination + optional string call_end_reason = 15; + + // The median round-trip time, measured in milliseconds, for packets over the + // duration of the call + optional float rtt_median = 16; + + // The median jitter, measured in milliseconds, for the duration of the call + optional float jitter_median = 17; + + // The fraction of all packets lost over the duration of the call + optional float packet_loss_fraction = 18; + + // Technical, machine-generated data about the quality and mechanics of a + // call; this is a serialized protobuf entity generated (and, critically, + // explained to the user!) by the calling library + optional bytes call_telemetry = 19; +} diff --git a/service/src/main/proto/org/signal/chat/call_quality.proto b/service/src/main/proto/org/signal/chat/call_quality.proto new file mode 100644 index 000000000..8b9acca42 --- /dev/null +++ b/service/src/main/proto/org/signal/chat/call_quality.proto @@ -0,0 +1,77 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +syntax = "proto3"; + +option java_multiple_files = true; + +package org.signal.chat.calling.quality; + +// Provides methods for submitting call quality surveys +service CallQuality { + + // Submits a call quality survey response. + // + // This RPC may fail with a `RESOURCE_EXHAUSTED` status if a rate limit for + // submitting survey responses has been exceeded, in which case a + // `retry-after` header containing an ISO 8601 duration string will be present + // in the response trailers. + rpc SubmitCallQualitySurvey(SubmitCallQualitySurveyRequest) returns (SubmitCallQualitySurveyResponse) {} +} + +message SubmitCallQualitySurveyRequest { + // Indicates whether the caller was generally satisfied with the quality of + // the call + optional bool user_satisfied = 1; + + // A list of call quality issues selected by the caller + repeated string call_quality_issues = 2; + + // A free-form description of any additional issues as written by the caller + optional string additional_issues_description = 3; + + // A URL for a set of debug logs associated with the call if the caller chose + // to submit debug logs + optional string debug_log_url = 4; + + // The time at which the call started in microseconds since the epoch (see + // https://cloud.google.com/bigquery/docs/reference/standard-sql/data-types#timestamp_type) + optional int64 start_timestamp = 5; + + // The time at which the call ended in microseconds since the epoch (see + // https://cloud.google.com/bigquery/docs/reference/standard-sql/data-types#timestamp_type) + optional int64 end_timestamp = 6; + + // The type of call; note that direct voice calls can become video calls and + // vice versa, and this field indicates which mode was selected at call + // initiation time. At the time of writing, expected call types are + // "direct_voice", "direct_video", "group", and "call_link". + optional string call_type = 7; + + // Indicates whether the call completed without error or if it terminated + // abnormally + optional bool success = 8; + + // A client-defined, but human-readable reason for call termination + optional string call_end_reason = 9; + + // The median round-trip time, measured in milliseconds, for packets over the + // duration of the call + optional float rtt_median = 10; + + // The median jitter, measured in milliseconds, for the duration of the call + optional float jitter_median = 11; + + // The fraction of all packets lost over the duration of the call + optional float packet_loss_fraction = 12; + + // Machine-generated telemetry from the call; this is a serialized protobuf + // entity generated (and, critically, explained to the user!) by the calling + // library + optional bytes call_telemetry = 13; +} + +message SubmitCallQualitySurveyResponse { +} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/metrics/CallQualitySurveyManagerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/metrics/CallQualitySurveyManagerTest.java new file mode 100644 index 000000000..f7b0dbcb0 --- /dev/null +++ b/service/src/test/java/org/whispersystems/textsecuregcm/metrics/CallQualitySurveyManagerTest.java @@ -0,0 +1,119 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.textsecuregcm.metrics; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.google.api.core.ApiFuture; +import com.google.cloud.pubsub.v1.PublisherInterface; +import com.google.protobuf.ByteString; +import com.google.protobuf.InvalidProtocolBufferException; +import com.google.pubsub.v1.PubsubMessage; +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.ThreadLocalRandom; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.signal.calling.survey.CallQualitySurveyResponsePubSubMessage; +import org.signal.chat.calling.quality.SubmitCallQualitySurveyRequest; +import org.whispersystems.textsecuregcm.asn.AsnInfo; +import org.whispersystems.textsecuregcm.asn.AsnInfoProvider; +import org.whispersystems.textsecuregcm.util.TestClock; +import org.whispersystems.textsecuregcm.util.TestRandomUtil; + +class CallQualitySurveyManagerTest { + + private AsnInfoProvider asnInfoProvider; + private PublisherInterface pubsubPublisher; + + private CallQualitySurveyManager callQualitySurveyManager; + + private static final TestClock CLOCK = TestClock.pinned(Instant.now()); + + private static final String USER_AGENT = "Signal-iOS/7.78.0.1041 iOS/18.3.2 libsignal/0.80.3"; + private static final String REMOTE_ADDRESS = "127.0.0.1"; + + @BeforeEach + void setUp() { + asnInfoProvider = mock(AsnInfoProvider.class); + pubsubPublisher = mock(PublisherInterface.class); + + callQualitySurveyManager = new CallQualitySurveyManager(() -> asnInfoProvider, pubsubPublisher, CLOCK, Runnable::run); + } + + @Test + void submitCallQualitySurvey() throws InvalidProtocolBufferException { + final long asn = 1234; + final String asnRegion = "US"; + + final byte[] telemetryBytes = TestRandomUtil.nextBytes(32); + + final float rttMedian = ThreadLocalRandom.current().nextFloat(); + final float jitterMedian = ThreadLocalRandom.current().nextFloat(); + final float packetLossFraction = ThreadLocalRandom.current().nextFloat(); + + when(asnInfoProvider.lookup(REMOTE_ADDRESS)).thenReturn(Optional.of(new AsnInfo(asn, asnRegion))); + + final SubmitCallQualitySurveyRequest request = SubmitCallQualitySurveyRequest.newBuilder() + .setUserSatisfied(false) + .addCallQualityIssues("too_hot") + .addCallQualityIssues("too_cold") + .setAdditionalIssuesDescription("But this one is just right") + .setDebugLogUrl("https://example.com/") + .setStartTimestamp(123456789) + .setEndTimestamp(987654321) + .setCallType("direct_video") + .setSuccess(true) + .setCallEndReason("caller_hang_up") + .setRttMedian(rttMedian) + .setJitterMedian(jitterMedian) + .setPacketLossFraction(packetLossFraction) + .setCallTelemetry(ByteString.copyFrom(telemetryBytes)) + .build(); + + //noinspection unchecked + when(pubsubPublisher.publish(any())).thenReturn(mock(ApiFuture.class)); + + assertDoesNotThrow(() -> callQualitySurveyManager.submitCallQualitySurvey(request, REMOTE_ADDRESS, USER_AGENT)); + + final ArgumentCaptor pubsubMessageCaptor = ArgumentCaptor.forClass(PubsubMessage.class); + + verify(pubsubPublisher).publish(pubsubMessageCaptor.capture()); + + final CallQualitySurveyResponsePubSubMessage callQualitySurveyResponsePubSubMessage = + CallQualitySurveyResponsePubSubMessage.parseFrom(pubsubMessageCaptor.getValue().getData()); + + assertEquals(4, UUID.fromString(callQualitySurveyResponsePubSubMessage.getResponseId()).version()); + assertEquals("ios", callQualitySurveyResponsePubSubMessage.getClientPlatform()); + assertEquals("7.78.0.1041", callQualitySurveyResponsePubSubMessage.getClientVersion()); + assertEquals("iOS/18.3.2 libsignal/0.80.3", callQualitySurveyResponsePubSubMessage.getClientUaAdditionalSpecifiers()); + assertEquals(asnRegion, callQualitySurveyResponsePubSubMessage.getAsnRegion()); + assertFalse(callQualitySurveyResponsePubSubMessage.getUserSatisfied()); + assertEquals(List.of("too_hot", "too_cold"), callQualitySurveyResponsePubSubMessage.getCallQualityIssuesList()); + assertEquals("But this one is just right", callQualitySurveyResponsePubSubMessage.getAdditionalIssuesDescription()); + assertEquals("https://example.com/", callQualitySurveyResponsePubSubMessage.getDebugLogUrl()); + assertEquals(123456789, callQualitySurveyResponsePubSubMessage.getStartTimestamp()); + assertEquals(987654321, callQualitySurveyResponsePubSubMessage.getEndTimestamp()); + assertEquals("direct_video", callQualitySurveyResponsePubSubMessage.getCallType()); + assertTrue(callQualitySurveyResponsePubSubMessage.getSuccess()); + assertEquals("caller_hang_up", callQualitySurveyResponsePubSubMessage.getCallEndReason()); + assertEquals(rttMedian, callQualitySurveyResponsePubSubMessage.getRttMedian()); + assertEquals(jitterMedian, callQualitySurveyResponsePubSubMessage.getJitterMedian()); + assertEquals(packetLossFraction, callQualitySurveyResponsePubSubMessage.getPacketLossFraction()); + assertArrayEquals(telemetryBytes, callQualitySurveyResponsePubSubMessage.getCallTelemetry().toByteArray()); + } +} diff --git a/service/src/test/resources/config/test.yml b/service/src/test/resources/config/test.yml index bfcd30405..52c05ea46 100644 --- a/service/src/test/resources/config/test.yml +++ b/service/src/test/resources/config/test.yml @@ -545,3 +545,7 @@ asnTable: objectKey: asn.tsv maxSize: 100000 refreshInterval: PT10S + +callQualitySurvey: + pubSubPublisher: + type: stub