diff --git a/gcp-auth-extension/README.md b/gcp-auth-extension/README.md index 209283daa..5bb573c5a 100644 --- a/gcp-auth-extension/README.md +++ b/gcp-auth-extension/README.md @@ -34,14 +34,13 @@ The extension can be configured either by environment variables or system proper Here is a list of required and optional configuration available for the extension: -#### Required Config +#### Optional Config - `GOOGLE_CLOUD_PROJECT`: Environment variable that represents the Google Cloud Project ID to which the telemetry needs to be exported. - Can also be configured using `google.cloud.project` system property. - - This is a required option, the agent configuration will fail if this option is not set. - -#### Optional Config + - If neither of these options are set, the extension will attempt to infer the project id from the current credentials as a fallback, however notice that not all credentials implementations will be able to provide a project id, so the inference is only a best-effort attempt. + - **Important Note**: The agent configuration will fail if this option is not set and cannot be inferred. - `GOOGLE_CLOUD_QUOTA_PROJECT`: Environment variable that represents the Google Cloud Quota Project ID which will be charged for the GCP API usage. To learn more about a *quota project*, see the [Quota project overview](https://cloud.google.com/docs/quotas/quota-project) page. Additional details about configuring the *quota project* can be found on the [Set the quota project](https://cloud.google.com/docs/quotas/set-quota-project) page. diff --git a/gcp-auth-extension/build.gradle.kts b/gcp-auth-extension/build.gradle.kts index fa6d71290..6f2da2811 100644 --- a/gcp-auth-extension/build.gradle.kts +++ b/gcp-auth-extension/build.gradle.kts @@ -31,6 +31,7 @@ dependencies { testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine") testImplementation("org.junit.jupiter:junit-jupiter-api") testCompileOnly("org.junit.jupiter:junit-jupiter-params") + testImplementation("org.junit-pioneer:junit-pioneer") testImplementation("io.opentelemetry:opentelemetry-api") testImplementation("io.opentelemetry:opentelemetry-exporter-otlp") diff --git a/gcp-auth-extension/src/main/java/io/opentelemetry/contrib/gcp/auth/GcpAuthAutoConfigurationCustomizerProvider.java b/gcp-auth-extension/src/main/java/io/opentelemetry/contrib/gcp/auth/GcpAuthAutoConfigurationCustomizerProvider.java index 12f73d5bb..05f554b74 100644 --- a/gcp-auth-extension/src/main/java/io/opentelemetry/contrib/gcp/auth/GcpAuthAutoConfigurationCustomizerProvider.java +++ b/gcp-auth-extension/src/main/java/io/opentelemetry/contrib/gcp/auth/GcpAuthAutoConfigurationCustomizerProvider.java @@ -111,7 +111,9 @@ public void customize(@Nonnull AutoConfigurationCustomizer autoConfiguration) { .addMetricExporterCustomizer( (metricExporter, configProperties) -> customizeMetricExporter(metricExporter, credentials, configProperties)) - .addResourceCustomizer(GcpAuthAutoConfigurationCustomizerProvider::customizeResource); + .addResourceCustomizer( + (resource, configProperties) -> + customizeResource(resource, credentials, configProperties)); } @Override @@ -228,9 +230,19 @@ private static Map getRequiredHeaderMap( } // Updates the current resource with the attributes required for ingesting OTLP data on GCP. - private static Resource customizeResource(Resource resource, ConfigProperties configProperties) { - String gcpProjectId = - ConfigurableOption.GOOGLE_CLOUD_PROJECT.getConfiguredValue(configProperties); + // Note that credentials can be passed from `customize` function directly + private static Resource customizeResource( + Resource resource, GoogleCredentials credentials, ConfigProperties configProperties) { + String gcpProjectId; + try { + gcpProjectId = ConfigurableOption.GOOGLE_CLOUD_PROJECT.getConfiguredValue(configProperties); + } catch (io.opentelemetry.sdk.autoconfigure.spi.ConfigurationException e) { + gcpProjectId = credentials.getProjectId(); + if (gcpProjectId == null) { + // this exception will still contain the accurate message. + throw e; + } + } Resource res = Resource.create(Attributes.of(stringKey(GCP_USER_PROJECT_ID_KEY), gcpProjectId)); return resource.merge(res); } diff --git a/gcp-auth-extension/src/test/java/io/opentelemetry/contrib/gcp/auth/GcpAuthAutoConfigurationCustomizerProviderTest.java b/gcp-auth-extension/src/test/java/io/opentelemetry/contrib/gcp/auth/GcpAuthAutoConfigurationCustomizerProviderTest.java index d7a4f07fd..cee08d090 100644 --- a/gcp-auth-extension/src/test/java/io/opentelemetry/contrib/gcp/auth/GcpAuthAutoConfigurationCustomizerProviderTest.java +++ b/gcp-auth-extension/src/test/java/io/opentelemetry/contrib/gcp/auth/GcpAuthAutoConfigurationCustomizerProviderTest.java @@ -74,6 +74,7 @@ import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.MethodSource; +import org.junitpioneer.jupiter.ClearSystemProperty; import org.mockito.ArgumentCaptor; import org.mockito.Captor; import org.mockito.Mock; @@ -456,6 +457,92 @@ void testQuotaProjectBehavior(QuotaProjectIdTestBehavior testCase) throws IOExce } } + @ParameterizedTest + @MethodSource("provideProjectIdBehaviorTestCases") + @ClearSystemProperty(key = "google.cloud.project") + @ClearSystemProperty(key = "google.otel.auth.target.signals") + @SuppressWarnings("CannotMockMethod") + void testProjectIdBehavior(ProjectIdTestBehavior testCase) throws IOException { + + // configure environment according to test case + String userSpecifiedProjectId = testCase.getUserSpecifiedProjectId(); + if (userSpecifiedProjectId != null && !userSpecifiedProjectId.isEmpty()) { + System.setProperty( + ConfigurableOption.GOOGLE_CLOUD_PROJECT.getSystemProperty(), userSpecifiedProjectId); + } + System.setProperty( + ConfigurableOption.GOOGLE_OTEL_AUTH_TARGET_SIGNALS.getSystemProperty(), SIGNAL_TYPE_TRACES); + + // prepare request metadata (may or may not be called depending on test scenario) + AccessToken fakeAccessToken = new AccessToken("fake", Date.from(Instant.now())); + ImmutableMap> mockedRequestMetadata = + ImmutableMap.of( + "Authorization", + Collections.singletonList("Bearer " + fakeAccessToken.getTokenValue())); + Mockito.lenient() + .when(mockedGoogleCredentials.getRequestMetadata()) + .thenReturn(mockedRequestMetadata); + + // only mock getProjectId() if it will be called (i.e., user didn't specify project ID) + boolean shouldFallbackToCredentials = + userSpecifiedProjectId == null || userSpecifiedProjectId.isEmpty(); + if (shouldFallbackToCredentials) { + Mockito.when(mockedGoogleCredentials.getProjectId()) + .thenReturn(testCase.getCredentialsProjectId()); + } + + // prepare mock exporter + OtlpGrpcSpanExporter mockOtlpGrpcSpanExporter = Mockito.mock(OtlpGrpcSpanExporter.class); + OtlpGrpcSpanExporterBuilder spyOtlpGrpcSpanExporterBuilder = + Mockito.spy(OtlpGrpcSpanExporter.builder()); + List exportedSpans = new ArrayList<>(); + configureGrpcMockSpanExporter( + mockOtlpGrpcSpanExporter, spyOtlpGrpcSpanExporterBuilder, exportedSpans); + + try (MockedStatic googleCredentialsMockedStatic = + Mockito.mockStatic(GoogleCredentials.class)) { + googleCredentialsMockedStatic + .when(GoogleCredentials::getApplicationDefault) + .thenReturn(mockedGoogleCredentials); + + if (testCase.getExpectedToThrow()) { + // expect exception to be thrown when project ID is not available + assertThatThrownBy( + () -> { + OpenTelemetrySdk sdk = + buildOpenTelemetrySdkWithExporter(mockOtlpGrpcSpanExporter); + generateTestSpan(sdk); + sdk.shutdown().join(10, TimeUnit.SECONDS); + }) + .isInstanceOf(ConfigurationException.class); + // verify getProjectId() was called to attempt fallback + Mockito.verify(mockedGoogleCredentials, Mockito.times(1)).getProjectId(); + } else { + // export telemetry and verify resource attributes contain expected project ID + OpenTelemetrySdk sdk = buildOpenTelemetrySdkWithExporter(mockOtlpGrpcSpanExporter); + generateTestSpan(sdk); + CompletableResultCode code = sdk.shutdown(); + CompletableResultCode joinResult = code.join(10, TimeUnit.SECONDS); + assertThat(joinResult.isSuccess()).isTrue(); + + assertThat(exportedSpans).hasSizeGreaterThan(0); + for (SpanData spanData : exportedSpans) { + assertThat(spanData.getResource().getAttributes().asMap()) + .containsEntry( + AttributeKey.stringKey(GCP_USER_PROJECT_ID_KEY), + testCase.getExpectedProjectIdInResource()); + } + + // verify whether getProjectId() was called based on whether fallback was needed + if (shouldFallbackToCredentials) { + Mockito.verify(mockedGoogleCredentials, Mockito.times(1)).getProjectId(); + } else { + Mockito.verify(mockedGoogleCredentials, Mockito.never()).getProjectId(); + } + } + } + } + @ParameterizedTest @MethodSource("provideTargetSignalBehaviorTestCases") void testTargetSignalsBehavior(TargetSignalBehavior testCase) { @@ -680,6 +767,34 @@ private static Stream provideTargetSignalBehaviorTestCases() { * indicates that the mocked credentials are configured to provide DUMMY_GCP_QUOTA_PROJECT_ID as * the quota project ID. */ + private static Stream provideProjectIdBehaviorTestCases() { + return Stream.of( + // User specified project ID takes precedence + Arguments.of( + ProjectIdTestBehavior.builder() + .setUserSpecifiedProjectId(DUMMY_GCP_RESOURCE_PROJECT_ID) + .setCredentialsProjectId("credentials-project-id") + .setExpectedProjectIdInResource(DUMMY_GCP_RESOURCE_PROJECT_ID) + .setExpectedToThrow(false) + .build()), + // If user doesn't specify project ID, fallback to credentials.getProjectId() + Arguments.of( + ProjectIdTestBehavior.builder() + .setUserSpecifiedProjectId(null) + .setCredentialsProjectId("credentials-project-id") + .setExpectedProjectIdInResource("credentials-project-id") + .setExpectedToThrow(false) + .build()), + // If user doesn't specify and credentials.getProjectId() returns null, throw exception + Arguments.of( + ProjectIdTestBehavior.builder() + .setUserSpecifiedProjectId(null) + .setCredentialsProjectId(null) + .setExpectedProjectIdInResource(null) + .setExpectedToThrow(true) + .build())); + } + private static Stream provideQuotaBehaviorTestCases() { return Stream.of( // If quota project present in metadata, it will be used @@ -839,6 +954,42 @@ private static void configureGrpcMockMetricExporter( .thenReturn(MemoryMode.IMMUTABLE_DATA); } + @AutoValue + abstract static class ProjectIdTestBehavior { + // A null user specified project ID represents the use case where user omits specifying it + @Nullable + abstract String getUserSpecifiedProjectId(); + + // The project ID that credentials.getProjectId() returns (can be null) + @Nullable + abstract String getCredentialsProjectId(); + + // The expected project ID in the resource attributes (null if exception expected) + @Nullable + abstract String getExpectedProjectIdInResource(); + + // Whether an exception is expected to be thrown + abstract boolean getExpectedToThrow(); + + static Builder builder() { + return new AutoValue_GcpAuthAutoConfigurationCustomizerProviderTest_ProjectIdTestBehavior + .Builder(); + } + + @AutoValue.Builder + abstract static class Builder { + abstract Builder setUserSpecifiedProjectId(String projectId); + + abstract Builder setCredentialsProjectId(String projectId); + + abstract Builder setExpectedProjectIdInResource(String projectId); + + abstract Builder setExpectedToThrow(boolean expectedToThrow); + + abstract ProjectIdTestBehavior build(); + } + } + @AutoValue abstract static class QuotaProjectIdTestBehavior { // A null user specified quota represents the use case where user omits specifying quota