diff --git a/build.gradle b/build.gradle index e72b5df9..76c69e9e 100644 --- a/build.gradle +++ b/build.gradle @@ -225,7 +225,9 @@ subprojects { junit5_runtime : "org.junit.jupiter:junit-jupiter-engine:${junit5Version}", junit5_params : "org.junit.jupiter:junit-jupiter-params:${junit5Version}", mockito : "org.mockito:mockito-inline:${mockitoVersion}", + mockito_jupiter: "org.mockito:mockito-junit-jupiter:${mockitoVersion}", slf4j_simple: "org.slf4j:slf4j-simple:${slf4jVersion}", + spring_boot_starter_test: "org.springframework.boot:spring-boot-starter-test:${springVersion}", opentelemetry_sdk_testing: "io.opentelemetry:opentelemetry-sdk-testing:${openTelemetryVersion}", test_containers: "org.testcontainers:testcontainers:${testContainersVersion}", wiremock : "com.github.tomakehurst:wiremock-jre8:${wiremockVersion}", diff --git a/javaagent-extensions/gcp-auth/README.md b/javaagent-extensions/gcp-auth/README.md new file mode 100644 index 00000000..9a2c2071 --- /dev/null +++ b/javaagent-extensions/gcp-auth/README.md @@ -0,0 +1,68 @@ +# Google Cloud Authentication Extension for OpenTelemetry Java Agent + +The Google Cloud Auth Extension allows the users to export telemetry from their applications auto-instrumented using the OpenTelemetry Java Agent to Google Cloud using the built-in OTLP exporters. +The extension takes care of the necessary configuration required to authenticate to GCP to successfully export telemetry. + +## Prerequisites + +### Ensure the presence of Google Cloud Credentials on your machine/environment + +```shell +gcloud auth application-default login +``` +Executing this command will save your application credentials to default path which will depend on the type of machine - +- Linux, macOS: `$HOME/.config/gcloud/application_default_credentials.json` +- Windows: `%APPDATA%\gcloud\application_default_credentials.json` + +**NOTE: This method of authentication is not recommended for production environments.** + +Next, export the credentials to `GOOGLE_APPLICATION_CREDENTIALS` environment variable - + +For Linux & MacOS: +```shell +export GOOGLE_APPLICATION_CREDENTIALS=$HOME/.config/gcloud/application_default_credentials.json +``` + +These credentials are built-in running in a Google App Engine, Google Cloud Shell or Google Compute Engine environment. + +### Configuring the extension + +The extension can be configured either by environment variables or system properties. + +Here is a list of configurable options for the extension: + + - `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. + - If this option is not configured, the extension would infer GCP Project ID from the application default credentials. For more information on application default credentials, see [here](https://cloud.google.com/docs/authentication/application-default-credentials). + +## Usage + +The OpenTelemetry Java Agent Extension can be easily added to any Java application by modifying the startup command to the application. +For more information on Extensions, see the [documentation here](https://github.com/open-telemetry/opentelemetry-java-instrumentation/blob/main/examples/extension/README.md). + +Below is a snippet showing how to add the extension to a Java application using the Gradle build system. + +```gradle +// Specify OpenTelemetry Autoinstrumentation Java Agent Path. +def otelAgentPath = +// Specify the path for Google Cloud Authentication Extension for the Java Agent. +def extensionPath = +def googleCloudProjectId = +def googleOtlpEndpoint = + +application { + ... + "-javaagent:${otelAgentPath}", + "-Dotel.javaagent.extensions=${extensionPath}", + // Configure the GCP Auth extension using system properties. + // This can also be configured using environment variables. + "-Dgoogle.cloud.project=${googleCloudProjectId}", + // Configure auto instrumentation. + "-Dotel.exporter.otlp.traces.endpoint=${googleOtlpEndpoint}", + '-Dotel.java.global-autoconfigure.enabled=true', + // Optionally enable the built-in GCP resource detector + '-Dotel.resource.providers.gcp.enabled=true' + '-Dotel.traces.exporter=otlp', + '-Dotel.metrics.exporter=logging', +} +``` diff --git a/javaagent-extensions/gcp-auth/build.gradle b/javaagent-extensions/gcp-auth/build.gradle new file mode 100644 index 00000000..0f6b96d7 --- /dev/null +++ b/javaagent-extensions/gcp-auth/build.gradle @@ -0,0 +1,124 @@ +/* + * Copyright 2024 Google LLC + * + * 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. + */ +plugins { + id 'java' + id 'java-library' + id 'com.github.johnrengelman.shadow' + id 'org.springframework.boot' version '2.7.18' +} + +description = 'OpenTelemetry Java Agent Extension that enables authentication support for OTLP exporters' + +tasks { + assemble.dependsOn(shadowJar) +} + +shadowJar { + archiveFileName.set("gcp-auth-extension.jar") +} + +jar { + // Disable standard jar + enabled = false +} + +// Custom configurations used to facilitate running the integration test +configurations { + agent +} + +dependencies { + annotationProcessor(libraries.auto_service) + // We use `compileOnly` dependency because during runtime all necessary classes are provided by javaagent itself. + compileOnly(libraries.auto_service_annotations) + compileOnly(libraries.opentelemetry_api) + compileOnly(libraries.opentelemetry_otlp_exporter) + compileOnly(libraries.opentelemetry_sdk_autoconf) + compileOnly(libraries.opentelemetry_autoconfigure_spi) + + // Only dependencies added to `implementation` configuration will be picked up by Shadow plugin + // and added to the resulting jar for our extension's distribution. + implementation(libraries.google_auth) + + // test dependencies + testRuntimeOnly(testLibraries.junit5_runtime) + testImplementation(testLibraries.junit5) + testImplementation(libraries.opentelemetry_api) + testImplementation(libraries.opentelemetry_otlp_exporter) + testImplementation(testLibraries.opentelemetry_sdk_testing) + testImplementation(testLibraries.mockito) + testImplementation(testLibraries.mockito_jupiter) + testImplementation(libraries.opentelemetry_sdk_autoconf) + // for implementing smoke test application + testImplementation(libraries.spring_boot_starter_web) + testImplementation(testLibraries.spring_boot_starter_test) + testImplementation("org.mock-server:mockserver-netty:5.15.0") + testImplementation("org.awaitility:awaitility:4.2.2") + testImplementation("io.opentelemetry.proto:opentelemetry-proto:1.4.0-alpha") + testImplementation("io.opentelemetry.instrumentation:opentelemetry-instrumentation-annotations:2.8.0") + // OTel instrumentation used in the sample app to facilitate integration testing + agent agentLibraries.agent +} + +// task to copy and rename the Java Auto-Instrumentation Agent into 'libs' folder +tasks.register('copyAgent', Copy) { + into layout.buildDirectory.dir("libs") + from configurations.agent { + rename "opentelemetry-javaagent(.*).jar", "otel-agent.jar" + } +} + +def builtLibsDir = layout.buildDirectory.dir("libs").get().toString() +def javaAgentJarPath = builtLibsDir + "/otel-agent.jar" +def authExtensionJarPath = builtLibsDir + "/gcp-auth-extension.jar" + +// this task is run as part of the integration test so it is necessary to +// configure this +tasks.named('bootJar').configure { + dependsOn('copyAgent') +} + +build { + // disable bootJar in build since it only runs as part of test + tasks.named('bootJar').configure { + enabled = false + } +} + +test { + dependsOn 'shadowJar' + dependsOn 'copyAgent' + useJUnitPlatform() + + def fakeCredsFilePath = project.file("src/test/resources/fakecreds.json").getAbsolutePath() + + environment("GOOGLE_CLOUD_QUOTA_PROJECT", "quota-project-id") + environment("GOOGLE_APPLICATION_CREDENTIALS", fakeCredsFilePath.toString()) + jvmArgs = [ + "-javaagent:${javaAgentJarPath}", + "-Dotel.javaagent.extensions=${authExtensionJarPath}", + "-Dgoogle.cloud.project=my-gcp-project", + "-Dotel.java.global-autoconfigure.enabled=true", + "-Dotel.exporter.otlp.endpoint=http://localhost:4318", + "-Dotel.resource.providers.gcp.enabled=true", + "-Dotel.traces.exporter=otlp", + "-Dotel.bsp.schedule.delay=2000", + "-Dotel.metrics.exporter=none", + "-Dotel.logs.exporter=none", + "-Dotel.exporter.otlp.protocol=http/protobuf", + "-Dmockserver.logLevel=off" + ] +} diff --git a/javaagent-extensions/gcp-auth/gradle.properties b/javaagent-extensions/gcp-auth/gradle.properties new file mode 100644 index 00000000..a88514b4 --- /dev/null +++ b/javaagent-extensions/gcp-auth/gradle.properties @@ -0,0 +1 @@ +release.enabled=false diff --git a/javaagent-extensions/gcp-auth/src/main/java/com/google/cloud/opentelemetry/extension/auth/ConfigurableOption.java b/javaagent-extensions/gcp-auth/src/main/java/com/google/cloud/opentelemetry/extension/auth/ConfigurableOption.java new file mode 100644 index 00000000..1ed27772 --- /dev/null +++ b/javaagent-extensions/gcp-auth/src/main/java/com/google/cloud/opentelemetry/extension/auth/ConfigurableOption.java @@ -0,0 +1,108 @@ +/* + * Copyright 2024 Google LLC + * + * 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 com.google.cloud.opentelemetry.extension.auth; + +import io.opentelemetry.sdk.autoconfigure.spi.ConfigurationException; +import java.util.Locale; +import java.util.function.Supplier; + +/** + * An enum representing configurable options for a GCP Authentication Extension. Each option has a + * user-readable name and can be configured using environment variables or system properties. + */ +public enum ConfigurableOption { + /** + * Represents the Google Cloud Project ID option. Can be configured using the environment variable + * `GOOGLE_CLOUD_PROJECT` or the system property `google.cloud.project`. + */ + GOOGLE_CLOUD_PROJECT("Google Cloud Project ID"); + + private static final String OPTION_NOT_CONFIGURED_MSG = + "GCP Authentication Extension not configured properly: %s not configured. Configure it by exporting environment variable %s or system property %s"; + + private final String userReadableName; + private final String environmentVariableName; + private final String systemPropertyName; + + ConfigurableOption(String userReadableName) { + this.userReadableName = userReadableName; + this.environmentVariableName = this.name(); + this.systemPropertyName = + this.environmentVariableName.toLowerCase(Locale.ENGLISH).replace('_', '.'); + } + + /** + * Returns the environment variable name associated with this option. + * + * @return the environment variable name (e.g., GOOGLE_CLOUD_PROJECT) + */ + String getEnvironmentVariable() { + return this.environmentVariableName; + } + + /** + * Returns the system property name associated with this option. + * + * @return the system property name (e.g., google.cloud.project) + */ + String getSystemProperty() { + return this.systemPropertyName; + } + + /** + * Retrieves the configured value for this option. This method checks the environment variable + * first and then the system property. + * + * @return The configured value as a string, or throws an exception if not configured. + * @throws ConfigurationException if neither the environment variable nor the system property is + * set. + */ + String getConfiguredValue() throws ConfigurationException { + String envVar = System.getenv(this.getEnvironmentVariable()); + String sysProp = System.getProperty(this.getSystemProperty()); + + if (envVar != null && !envVar.isEmpty()) { + return envVar; + } else if (sysProp != null && !sysProp.isEmpty()) { + return sysProp; + } else { + throw new ConfigurationException( + String.format( + OPTION_NOT_CONFIGURED_MSG, + this.userReadableName, + this.getEnvironmentVariable(), + this.getSystemProperty())); + } + } + + /** + * Retrieves the value for this option, prioritizing environment variables and system properties. + * If neither an environment variable nor a system property is set for this option, the provided + * fallback function is used to determine the value. + * + * @param fallback A {@link Supplier} that provides the default value for the option when it is + * not explicitly configured via an environment variable or system property. + * @return The configured value for the option, obtained from the environment variable, system + * property, or the fallback function, in that order of precedence. + */ + String getConfiguredValueWithFallback(Supplier fallback) { + try { + return this.getConfiguredValue(); + } catch (ConfigurationException e) { + return fallback.get(); + } + } +} diff --git a/javaagent-extensions/gcp-auth/src/main/java/com/google/cloud/opentelemetry/extension/auth/GcpAuthAutoConfigurationCustomizerProvider.java b/javaagent-extensions/gcp-auth/src/main/java/com/google/cloud/opentelemetry/extension/auth/GcpAuthAutoConfigurationCustomizerProvider.java new file mode 100644 index 00000000..f2309564 --- /dev/null +++ b/javaagent-extensions/gcp-auth/src/main/java/com/google/cloud/opentelemetry/extension/auth/GcpAuthAutoConfigurationCustomizerProvider.java @@ -0,0 +1,136 @@ +/* + * Copyright 2024 Google LLC + * + * 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 com.google.cloud.opentelemetry.extension.auth; + +import com.google.auth.oauth2.GoogleCredentials; +import com.google.auto.service.AutoService; +import com.google.cloud.opentelemetry.extension.auth.GoogleAuthException.Reason; +import io.opentelemetry.api.common.AttributeKey; +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.exporter.otlp.http.trace.OtlpHttpSpanExporter; +import io.opentelemetry.exporter.otlp.http.trace.OtlpHttpSpanExporterBuilder; +import io.opentelemetry.exporter.otlp.trace.OtlpGrpcSpanExporter; +import io.opentelemetry.exporter.otlp.trace.OtlpGrpcSpanExporterBuilder; +import io.opentelemetry.sdk.autoconfigure.spi.AutoConfigurationCustomizer; +import io.opentelemetry.sdk.autoconfigure.spi.AutoConfigurationCustomizerProvider; +import io.opentelemetry.sdk.autoconfigure.spi.ConfigProperties; +import io.opentelemetry.sdk.resources.Resource; +import io.opentelemetry.sdk.trace.export.SpanExporter; +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; + +/** + * An AutoConfigurationCustomizerProvider for Google Cloud Platform (GCP) OpenTelemetry (OTLP) + * integration. + * + *

This class is registered as a service provider using {@link AutoService} and is responsible + * for customizing the OpenTelemetry configuration for GCP specific behavior. It retrieves Google + * Application Default Credentials (ADC) and adds them as authorization headers to the configured + * {@link SpanExporter}. It also sets default properties and resource attributes for GCP + * integration. + * + * @see AutoConfigurationCustomizerProvider + * @see GoogleCredentials + */ +@AutoService(AutoConfigurationCustomizerProvider.class) +public class GcpAuthAutoConfigurationCustomizerProvider + implements AutoConfigurationCustomizerProvider { + + static final String QUOTA_USER_PROJECT_HEADER = "X-Goog-User-Project"; + static final String GCP_USER_PROJECT_ID_KEY = "gcp.project_id"; + + /** + * Customizes the provided {@link AutoConfigurationCustomizer}. + * + *

This method attempts to retrieve Google Application Default Credentials (ADC) and performs + * the following: - Adds authorization headers to the configured {@link SpanExporter} based on the + * retrieved credentials. - Adds default properties for OTLP endpoint and resource attributes for + * GCP integration. + * + * @param autoConfiguration the AutoConfigurationCustomizer to customize. + * @throws GoogleAuthException if there's an error retrieving Google Application Default + * Credentials. + * @throws io.opentelemetry.sdk.autoconfigure.spi.ConfigurationException if required options are + * not configured through environment variables or system properties. + */ + @Override + public void customize(AutoConfigurationCustomizer autoConfiguration) { + try { + GoogleCredentials credentials = GoogleCredentials.getApplicationDefault(); + autoConfiguration + .addSpanExporterCustomizer( + (exporter, configProperties) -> addAuthorizationHeaders(exporter, credentials)) + .addResourceCustomizer(this::customizeResource); + } catch (IOException e) { + throw new GoogleAuthException(Reason.FAILED_ADC_RETRIEVAL, e); + } + } + + @Override + public int order() { + return Integer.MAX_VALUE - 1; + } + + // Adds authorization headers to the calls made by the OtlpGrpcSpanExporter and + // OtlpHttpSpanExporter. + private SpanExporter addAuthorizationHeaders( + SpanExporter exporter, GoogleCredentials credentials) { + if (exporter instanceof OtlpHttpSpanExporter) { + OtlpHttpSpanExporterBuilder builder = + ((OtlpHttpSpanExporter) exporter) + .toBuilder().setHeaders(() -> getRequiredHeaderMap(credentials)); + return builder.build(); + } else if (exporter instanceof OtlpGrpcSpanExporter) { + OtlpGrpcSpanExporterBuilder builder = + ((OtlpGrpcSpanExporter) exporter) + .toBuilder().setHeaders(() -> getRequiredHeaderMap(credentials)); + return builder.build(); + } + return exporter; + } + + private Map getRequiredHeaderMap(GoogleCredentials credentials) { + Map gcpHeaders = new HashMap<>(); + try { + credentials.refreshIfExpired(); + } catch (IOException e) { + throw new GoogleAuthException(Reason.FAILED_ADC_REFRESH, e); + } + gcpHeaders.put(QUOTA_USER_PROJECT_HEADER, credentials.getQuotaProjectId()); + gcpHeaders.put("Authorization", "Bearer " + credentials.getAccessToken().getTokenValue()); + return gcpHeaders; + } + + // Updates the current resource with the attributes required for ingesting OTLP data on GCP. + private Resource customizeResource(Resource resource, ConfigProperties configProperties) { + String gcpProjectId = + ConfigurableOption.GOOGLE_CLOUD_PROJECT.getConfiguredValueWithFallback( + () -> { + try { + GoogleCredentials googleCredentials = GoogleCredentials.getApplicationDefault(); + return googleCredentials.getQuotaProjectId(); + } catch (IOException e) { + throw new GoogleAuthException(Reason.FAILED_ADC_RETRIEVAL, e); + } + }); + + Resource res = + Resource.create( + Attributes.of(AttributeKey.stringKey(GCP_USER_PROJECT_ID_KEY), gcpProjectId)); + return resource.merge(res); + } +} diff --git a/javaagent-extensions/gcp-auth/src/main/java/com/google/cloud/opentelemetry/extension/auth/GoogleAuthException.java b/javaagent-extensions/gcp-auth/src/main/java/com/google/cloud/opentelemetry/extension/auth/GoogleAuthException.java new file mode 100644 index 00000000..5e15fc60 --- /dev/null +++ b/javaagent-extensions/gcp-auth/src/main/java/com/google/cloud/opentelemetry/extension/auth/GoogleAuthException.java @@ -0,0 +1,62 @@ +/* + * Copyright 2024 Google LLC + * + * 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 com.google.cloud.opentelemetry.extension.auth; + +/** + * An unchecked exception indicating a failure during Google authentication. This exception is + * thrown when there are issues with retrieving or refreshing Google Application Default Credentials + * (ADC). + */ +public class GoogleAuthException extends RuntimeException { + + /** + * Constructs a new {@code GoogleAuthException} with the specified reason and cause. + * + * @param reason the reason for the authentication failure. + * @param cause the underlying cause of the exception (e.g., an IOException). + */ + GoogleAuthException(Reason reason, Throwable cause) { + super(reason.message, cause); + } + + /** Enumerates the possible reasons for a Google authentication failure. */ + enum Reason { + /** Indicates a failure to retrieve Google Application Default Credentials. */ + FAILED_ADC_RETRIEVAL("Unable to retrieve Google Application Default Credentials."), + /** Indicates a failure to retrieve Google Application Default Credentials. */ + FAILED_ADC_REFRESH("Unable to refresh Google Application Default Credentials."); + + private final String message; + + /** + * Constructs a new {@code Reason} with the specified message. + * + * @param message the message describing the reason. + */ + Reason(String message) { + this.message = message; + } + + /** + * Returns the message associated with this reason. + * + * @return the message describing the reason. + */ + public String getMessage() { + return message; + } + } +} diff --git a/javaagent-extensions/gcp-auth/src/test/java/com/google/cloud/opentelemetry/extension/auth/GcpAuthAutoConfigurationCustomizerProviderTest.java b/javaagent-extensions/gcp-auth/src/test/java/com/google/cloud/opentelemetry/extension/auth/GcpAuthAutoConfigurationCustomizerProviderTest.java new file mode 100644 index 00000000..dc74c123 --- /dev/null +++ b/javaagent-extensions/gcp-auth/src/test/java/com/google/cloud/opentelemetry/extension/auth/GcpAuthAutoConfigurationCustomizerProviderTest.java @@ -0,0 +1,257 @@ +/* + * Copyright 2024 Google LLC + * + * 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 com.google.cloud.opentelemetry.extension.auth; + +import static com.google.cloud.opentelemetry.extension.auth.GcpAuthAutoConfigurationCustomizerProvider.GCP_USER_PROJECT_ID_KEY; +import static com.google.cloud.opentelemetry.extension.auth.GcpAuthAutoConfigurationCustomizerProvider.QUOTA_USER_PROJECT_HEADER; +import static org.awaitility.Awaitility.await; +import static org.junit.jupiter.api.Assertions.*; + +import com.google.auth.oauth2.AccessToken; +import com.google.auth.oauth2.GoogleCredentials; +import com.google.common.collect.ImmutableMap; +import io.opentelemetry.api.common.AttributeKey; +import io.opentelemetry.api.trace.Span; +import io.opentelemetry.context.Scope; +import io.opentelemetry.exporter.otlp.http.trace.OtlpHttpSpanExporter; +import io.opentelemetry.exporter.otlp.http.trace.OtlpHttpSpanExporterBuilder; +import io.opentelemetry.exporter.otlp.trace.OtlpGrpcSpanExporter; +import io.opentelemetry.exporter.otlp.trace.OtlpGrpcSpanExporterBuilder; +import io.opentelemetry.sdk.OpenTelemetrySdk; +import io.opentelemetry.sdk.autoconfigure.AutoConfiguredOpenTelemetrySdk; +import io.opentelemetry.sdk.autoconfigure.AutoConfiguredOpenTelemetrySdkBuilder; +import io.opentelemetry.sdk.autoconfigure.internal.AutoConfigureUtil; +import io.opentelemetry.sdk.autoconfigure.internal.ComponentLoader; +import io.opentelemetry.sdk.autoconfigure.internal.SpiHelper; +import io.opentelemetry.sdk.autoconfigure.spi.ConfigProperties; +import io.opentelemetry.sdk.autoconfigure.spi.traces.ConfigurableSpanExporterProvider; +import io.opentelemetry.sdk.common.CompletableResultCode; +import io.opentelemetry.sdk.trace.data.SpanData; +import io.opentelemetry.sdk.trace.export.SpanExporter; +import java.time.Duration; +import java.time.Instant; +import java.util.AbstractMap.SimpleEntry; +import java.util.Collection; +import java.util.Collections; +import java.util.Date; +import java.util.Map; +import java.util.Map.Entry; +import java.util.Set; +import java.util.function.Supplier; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.mockito.Mockito; +import org.mockito.MockitoAnnotations; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class GcpAuthAutoConfigurationCustomizerProviderTest { + + @Mock private GoogleCredentials mockedGoogleCredentials; + + @Captor private ArgumentCaptor> spanDataCollectionCaptor; + @Captor private ArgumentCaptor>> headerSupplierCaptor; + + private final Map otelProperties = + ImmutableMap.of( + "otel.bsp.schedule.delay", // span exporter + "10", + "otel.traces.exporter", + "otlp", + "otel.metrics.exporter", + "none", + "otel.logs.exporter", + "none"); + + @BeforeEach + public void setup() { + MockitoAnnotations.openMocks(this); + Mockito.when(mockedGoogleCredentials.getQuotaProjectId()).thenReturn("test-project"); + Mockito.when(mockedGoogleCredentials.getAccessToken()) + .thenReturn(new AccessToken("fake", new Date())); + } + + @Test + public void testCustomizerOtlpHttp() { + OtlpHttpSpanExporter mockOtlpHttpSpanExporter = Mockito.mock(OtlpHttpSpanExporter.class); + OtlpHttpSpanExporterBuilder otlpSpanExporterBuilder = OtlpHttpSpanExporter.builder(); + OtlpHttpSpanExporterBuilder spyOtlpHttpSpanExporterBuilder = + Mockito.spy(otlpSpanExporterBuilder); + Mockito.when(spyOtlpHttpSpanExporterBuilder.build()).thenReturn(mockOtlpHttpSpanExporter); + + Mockito.when(mockOtlpHttpSpanExporter.export(Mockito.anyCollection())) + .thenReturn(CompletableResultCode.ofSuccess()); + Mockito.when(mockOtlpHttpSpanExporter.toBuilder()).thenReturn(spyOtlpHttpSpanExporterBuilder); + + try (MockedStatic googleCredentialsMockedStatic = + Mockito.mockStatic(GoogleCredentials.class)) { + googleCredentialsMockedStatic + .when(GoogleCredentials::getApplicationDefault) + .thenReturn(mockedGoogleCredentials); + + OpenTelemetrySdk sdk = buildOpenTelemetrySdkWithExporter(mockOtlpHttpSpanExporter); + generateTestSpan(sdk); + + await() + .atMost(Duration.ofSeconds(10)) + .untilAsserted( + () -> { + Mockito.verify(mockOtlpHttpSpanExporter, Mockito.times(1)).toBuilder(); + Mockito.verify(spyOtlpHttpSpanExporterBuilder, Mockito.times(1)) + .setHeaders(headerSupplierCaptor.capture()); + assertEquals(2, headerSupplierCaptor.getValue().get().size()); + verifyAuthHeaders(headerSupplierCaptor.getValue().get()); + + Mockito.verify(mockOtlpHttpSpanExporter, Mockito.atLeast(1)) + .export(spanDataCollectionCaptor.capture()); + spanDataCollectionCaptor + .getValue() + .forEach( + spanData -> { + assertEquals( + "test-project", + spanData + .getAttributes() + .get(AttributeKey.stringKey(GCP_USER_PROJECT_ID_KEY))); + assertTrue( + spanData + .getAttributes() + .asMap() + .containsKey(AttributeKey.stringKey("work_loop"))); + }); + }); + } + } + + @Test + public void testCustomizerOtlpGrpc() { + OtlpGrpcSpanExporter mockOtlpGrpcSpanExporter = Mockito.mock(OtlpGrpcSpanExporter.class); + OtlpGrpcSpanExporterBuilder otlpSpanExporterBuilder = OtlpGrpcSpanExporter.builder(); + OtlpGrpcSpanExporterBuilder spyOtlpGrpcSpanExporterBuilder = + Mockito.spy(otlpSpanExporterBuilder); + Mockito.when(spyOtlpGrpcSpanExporterBuilder.build()).thenReturn(mockOtlpGrpcSpanExporter); + + Mockito.when(mockOtlpGrpcSpanExporter.export(Mockito.anyCollection())) + .thenReturn(CompletableResultCode.ofSuccess()); + Mockito.when(mockOtlpGrpcSpanExporter.toBuilder()).thenReturn(spyOtlpGrpcSpanExporterBuilder); + + try (MockedStatic googleCredentialsMockedStatic = + Mockito.mockStatic(GoogleCredentials.class)) { + googleCredentialsMockedStatic + .when(GoogleCredentials::getApplicationDefault) + .thenReturn(mockedGoogleCredentials); + + OpenTelemetrySdk sdk = buildOpenTelemetrySdkWithExporter(mockOtlpGrpcSpanExporter); + generateTestSpan(sdk); + + await() + .atMost(Duration.ofSeconds(10)) + .untilAsserted( + () -> { + Mockito.verify(mockOtlpGrpcSpanExporter, Mockito.times(1)).toBuilder(); + Mockito.verify(spyOtlpGrpcSpanExporterBuilder, Mockito.times(1)) + .setHeaders(headerSupplierCaptor.capture()); + assertEquals(2, headerSupplierCaptor.getValue().get().size()); + verifyAuthHeaders(headerSupplierCaptor.getValue().get()); + + Mockito.verify(mockOtlpGrpcSpanExporter, Mockito.atLeast(1)) + .export(spanDataCollectionCaptor.capture()); + spanDataCollectionCaptor + .getValue() + .forEach( + spanData -> { + assertEquals( + "test-project", + spanData + .getAttributes() + .get(AttributeKey.stringKey(GCP_USER_PROJECT_ID_KEY))); + assertTrue( + spanData + .getAttributes() + .asMap() + .containsKey(AttributeKey.stringKey("work_loop"))); + }); + }); + } + } + + private OpenTelemetrySdk buildOpenTelemetrySdkWithExporter(SpanExporter spanExporter) { + SpiHelper spiHelper = + SpiHelper.create(GcpAuthAutoConfigurationCustomizerProviderTest.class.getClassLoader()); + AutoConfiguredOpenTelemetrySdkBuilder builder = + AutoConfiguredOpenTelemetrySdk.builder().addPropertiesSupplier(() -> otelProperties); + AutoConfigureUtil.setComponentLoader( + builder, + new ComponentLoader() { + @SuppressWarnings("unchecked") + @Override + public Iterable load(Class spiClass) { + if (spiClass == ConfigurableSpanExporterProvider.class) { + return Collections.singletonList( + (T) + new ConfigurableSpanExporterProvider() { + @Override + public SpanExporter createExporter(ConfigProperties configProperties) { + return spanExporter; + } + + @Override + public String getName() { + return "otlp"; + } + }); + } + return spiHelper.load(spiClass); + } + }); + return builder.build().getOpenTelemetrySdk(); + } + + private void verifyAuthHeaders(Map headers) { + Set> headerEntrySet = headers.entrySet(); + assertTrue( + headerEntrySet.contains(new SimpleEntry<>(QUOTA_USER_PROJECT_HEADER, "test-project"))); + assertTrue(headerEntrySet.contains(new SimpleEntry<>("Authorization", "Bearer fake"))); + } + + private void generateTestSpan(OpenTelemetrySdk openTelemetrySdk) { + Span span = openTelemetrySdk.getTracer("test").spanBuilder("sample").startSpan(); + try (Scope ignored = span.makeCurrent()) { + long workOutput = busyloop(); + span.setAttribute("work_loop", workOutput); + } finally { + span.end(); + } + } + + // loop to simulate work done + private long busyloop() { + Instant start = Instant.now(); + Instant end; + long counter = 0; + do { + counter++; + end = Instant.now(); + } while (Duration.between(start, end).toMillis() < 1000); + System.out.println("Busy work done, counted " + counter + " times in one second."); + return counter; + } +} diff --git a/javaagent-extensions/gcp-auth/src/test/java/com/google/cloud/opentelemetry/extension/auth/GcpAuthExtensionEndToEndTest.java b/javaagent-extensions/gcp-auth/src/test/java/com/google/cloud/opentelemetry/extension/auth/GcpAuthExtensionEndToEndTest.java new file mode 100644 index 00000000..35b63cf7 --- /dev/null +++ b/javaagent-extensions/gcp-auth/src/test/java/com/google/cloud/opentelemetry/extension/auth/GcpAuthExtensionEndToEndTest.java @@ -0,0 +1,227 @@ +/* + * Copyright 2024 Google LLC + * + * 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 com.google.cloud.opentelemetry.extension.auth; + +import static com.google.cloud.opentelemetry.extension.auth.GcpAuthAutoConfigurationCustomizerProvider.GCP_USER_PROJECT_ID_KEY; +import static com.google.cloud.opentelemetry.extension.auth.GcpAuthAutoConfigurationCustomizerProvider.QUOTA_USER_PROJECT_HEADER; +import static org.awaitility.Awaitility.await; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockserver.model.HttpRequest.request; +import static org.mockserver.model.HttpResponse.response; +import static org.mockserver.stop.Stop.stopQuietly; + +import com.google.cloud.opentelemetry.extension.auth.springapp.Application; +import com.google.protobuf.InvalidProtocolBufferException; +import io.opentelemetry.proto.collector.trace.v1.ExportTraceServiceRequest; +import io.opentelemetry.proto.common.v1.AnyValue; +import io.opentelemetry.proto.common.v1.KeyValue; +import io.opentelemetry.proto.trace.v1.ResourceSpans; +import java.net.URI; +import java.security.KeyManagementException; +import java.security.NoSuchAlgorithmException; +import java.security.cert.X509Certificate; +import java.util.Arrays; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; +import javax.net.ssl.HttpsURLConnection; +import javax.net.ssl.SSLContext; +import javax.net.ssl.TrustManager; +import javax.net.ssl.X509TrustManager; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.mockserver.client.MockServerClient; +import org.mockserver.integration.ClientAndServer; +import org.mockserver.model.Body; +import org.mockserver.model.Headers; +import org.mockserver.model.HttpRequest; +import org.mockserver.model.JsonBody; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.http.ResponseEntity; + +@SpringBootTest( + classes = {Application.class}, + webEnvironment = WebEnvironment.RANDOM_PORT) +public class GcpAuthExtensionEndToEndTest { + + @LocalServerPort private int testApplicationPort; // port at which the spring app is running + + @Autowired private TestRestTemplate template; + + // The port at which the backend server will receive telemetry + private static final int EXPORTER_ENDPOINT_PORT = 4318; + // The port at which the mock GCP OAuth 2.0 server will run + private static final int MOCK_GCP_OAUTH2_PORT = 8090; + + // Backend server to which the application under test will export traces + // the export config is specified in the build.gradle file. + private static ClientAndServer backendServer; + + // Mock server to intercept calls to the GCP OAuth 2.0 server and provide fake credentials + private static ClientAndServer mockGcpOAuth2Server; + + private static final String DUMMY_GCP_QUOTA_PROJECT = System.getenv("GOOGLE_CLOUD_QUOTA_PROJECT"); + private static final String DUMMY_GCP_PROJECT = System.getProperty("google.cloud.project"); + + @BeforeAll + public static void setup() throws NoSuchAlgorithmException, KeyManagementException { + // Setup proxy host(s) + System.setProperty("http.proxyHost", "localhost"); + System.setProperty("http.proxyPort", MOCK_GCP_OAUTH2_PORT + ""); + System.setProperty("https.proxyHost", "localhost"); + System.setProperty("https.proxyPort", MOCK_GCP_OAUTH2_PORT + ""); + System.setProperty("http.nonProxyHost", "localhost"); + System.setProperty("https.nonProxyHost", "localhost"); + + // Disable SSL validation for integration test + // The OAuth2 token validation requires SSL validation + disableSSLValidation(); + + // Set up mock OTLP backend server to which traces will be exported + backendServer = ClientAndServer.startClientAndServer(EXPORTER_ENDPOINT_PORT); + backendServer.when(request()).respond(response().withStatusCode(200)); + + // Set up the mock gcp metadata server to provide fake credentials + String accessTokenResponse = + "{\"access_token\": \"fake.access_token\",\"expires_in\": 3600, \"token_type\": \"Bearer\"}"; + mockGcpOAuth2Server = ClientAndServer.startClientAndServer(MOCK_GCP_OAUTH2_PORT); + + MockServerClient mockServerClient = + new MockServerClient("localhost", MOCK_GCP_OAUTH2_PORT).withSecure(true); + + // mock the token refresh - always respond with 200 + mockServerClient + .when(request().withMethod("POST").withPath("/token")) + .respond( + response() + .withStatusCode(200) + .withHeader("Content-Type", "application/json") + .withBody(new JsonBody(accessTokenResponse))); + } + + @AfterAll + public static void teardown() { + // Stop the backend server + stopQuietly(backendServer); + stopQuietly(mockGcpOAuth2Server); + } + + @Test + public void authExtensionSmokeTest() { + ResponseEntity a = + template.getForEntity( + URI.create("http://localhost:" + testApplicationPort + "/ping"), String.class); + + await() + .atMost(10, TimeUnit.SECONDS) + .untilAsserted( + () -> { + HttpRequest[] requests = backendServer.retrieveRecordedRequests(request()); + List extractedHeaders = extractHeadersFromRequests(requests); + verifyRequestHeaders(extractedHeaders); + + List extractedResourceSpans = + extractResourceSpansFromRequests(requests); + verifyResourceAttributes(extractedResourceSpans); + }); + } + + // Helper methods + + private static void disableSSLValidation() + throws NoSuchAlgorithmException, KeyManagementException { + TrustManager[] trustAllCerts = + new TrustManager[] { + new X509TrustManager() { + @Override + public void checkClientTrusted(X509Certificate[] chain, String authType) { + System.out.println("Reached checkClientTrusted"); + } + + @Override + public void checkServerTrusted(X509Certificate[] chain, String authType) { + System.out.println("Reached checkServerTrusted"); + } + + @Override + public X509Certificate[] getAcceptedIssuers() { + System.out.println("No acceptedIssuers"); + return null; + } + } + }; + SSLContext sc = SSLContext.getInstance("SSL"); + sc.init(null, trustAllCerts, new java.security.SecureRandom()); + HttpsURLConnection.setDefaultSSLSocketFactory(sc.getSocketFactory()); + } + + private void verifyResourceAttributes(List extractedResourceSpans) { + extractedResourceSpans.forEach( + resourceSpan -> + assertTrue( + resourceSpan + .getResource() + .getAttributesList() + .contains( + KeyValue.newBuilder() + .setKey(GCP_USER_PROJECT_ID_KEY) + .setValue(AnyValue.newBuilder().setStringValue(DUMMY_GCP_PROJECT)) + .build()))); + } + + private void verifyRequestHeaders(List extractedHeaders) { + assertFalse(extractedHeaders.isEmpty()); + // verify if extension added the required headers + extractedHeaders.forEach( + headers -> { + assertTrue(headers.containsEntry(QUOTA_USER_PROJECT_HEADER, DUMMY_GCP_QUOTA_PROJECT)); + assertTrue(headers.containsEntry("Authorization", "Bearer fake.access_token")); + }); + } + + private List extractHeadersFromRequests(HttpRequest[] requests) { + return Arrays.stream(requests).map(HttpRequest::getHeaders).collect(Collectors.toList()); + } + + /** + * Extract resource spans from http requests received by a telemetry collector. + * + * @param requests Request received by a http server trace collector + * @return spans extracted from the request body + */ + private List extractResourceSpansFromRequests(HttpRequest[] requests) { + return Arrays.stream(requests) + .map(HttpRequest::getBody) + .flatMap(body -> getExportTraceServiceRequest(body).stream()) + .flatMap(r -> r.getResourceSpansList().stream()) + .collect(Collectors.toList()); + } + + private Optional getExportTraceServiceRequest(Body body) { + try { + return Optional.ofNullable(ExportTraceServiceRequest.parseFrom(body.getRawBytes())); + } catch (InvalidProtocolBufferException e) { + return Optional.empty(); + } + } +} diff --git a/javaagent-extensions/gcp-auth/src/test/java/com/google/cloud/opentelemetry/extension/auth/springapp/Application.java b/javaagent-extensions/gcp-auth/src/test/java/com/google/cloud/opentelemetry/extension/auth/springapp/Application.java new file mode 100644 index 00000000..e4455c4f --- /dev/null +++ b/javaagent-extensions/gcp-auth/src/test/java/com/google/cloud/opentelemetry/extension/auth/springapp/Application.java @@ -0,0 +1,26 @@ +/* + * Copyright 2024 Google LLC + * + * 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 com.google.cloud.opentelemetry.extension.auth.springapp; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class Application { + public static void main(String[] args) { + SpringApplication.run(Application.class, args); + } +} diff --git a/javaagent-extensions/gcp-auth/src/test/java/com/google/cloud/opentelemetry/extension/auth/springapp/Controller.java b/javaagent-extensions/gcp-auth/src/test/java/com/google/cloud/opentelemetry/extension/auth/springapp/Controller.java new file mode 100644 index 00000000..cb8fe88b --- /dev/null +++ b/javaagent-extensions/gcp-auth/src/test/java/com/google/cloud/opentelemetry/extension/auth/springapp/Controller.java @@ -0,0 +1,49 @@ +/* + * Copyright 2024 Google LLC + * + * 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 com.google.cloud.opentelemetry.extension.auth.springapp; + +import io.opentelemetry.instrumentation.annotations.WithSpan; +import java.time.Duration; +import java.time.Instant; +import java.util.Random; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class Controller { + + private final Random random = new Random(); + + @GetMapping("/ping") + public String ping() { + int busyTime = random.nextInt(200); + long ctr = busyloop(busyTime); + System.out.println("Busy work done, counted " + ctr + " times in " + busyTime + " ms"); + return "pong"; + } + + @WithSpan + private long busyloop(int busyMillis) { + Instant start = Instant.now(); + Instant end; + long counter = 0; + do { + counter++; + end = Instant.now(); + } while (Duration.between(start, end).toMillis() < busyMillis); + return counter; + } +} diff --git a/javaagent-extensions/gcp-auth/src/test/resources/fakecreds.json b/javaagent-extensions/gcp-auth/src/test/resources/fakecreds.json new file mode 100644 index 00000000..b52122ca --- /dev/null +++ b/javaagent-extensions/gcp-auth/src/test/resources/fakecreds.json @@ -0,0 +1,13 @@ +{ + "type": "service_account", + "project_id": "quota-project-id", + "private_key_id": "aljmafmlamlmmasma", + "private_key": "-----BEGIN PRIVATE KEY-----\nMIICdgIBADANBgkqhkiG9w0BAQEFAASCAmAwggJcAgEAAoGBALX0PQoe1igW12ikv1bN/r9lN749y2ijmbc/mFHPyS3hNTyOCjDvBbXYbDhQJzWVUikh4mvGBA07qTj79Xc3yBDfKP2IeyYQIFe0t0zkd7R9Zdn98Y2rIQC47aAbDfubtkU1U72t4zL11kHvoa0/RuFZjncvlr42X7be7lYh4p3NAgMBAAECgYASk5wDw4Az2ZkmeuN6Fk/y9H+Lcb2pskJIXjrL533vrDWGOC48LrsThMQPv8cxBky8HFSEklPpkfTF95tpD43iVwJRB/GrCtGTw65IfJ4/tI09h6zGc4yqvIo1cHX/LQ+SxKLGyir/dQM925rGt/VojxY5ryJR7GLbCzxPnJm/oQJBANwOCO6D2hy1LQYJhXh7O+RLtA/tSnT1xyMQsGT+uUCMiKS2bSKx2wxo9k7h3OegNJIu1q6nZ6AbxDK8H3+d0dUCQQDTrPSXagBxzp8PecbaCHjzNRSQE2in81qYnrAFNB4o3DpHyMMY6s5ALLeHKscEWnqP8Ur6X4PvzZecCWU9BKAZAkAutLPknAuxSCsUOvUfS1i87ex77Ot+w6POp34pEX+UWb+u5iFn2cQacDTHLV1LtE80L8jVLSbrbrlH43H0DjU5AkEAgidhycxS86dxpEljnOMCw8CKoUBd5I880IUahEiUltk7OLJYS/Ts1wbn3kPOVX3wyJs8WBDtBkFrDHW2ezth2QJADj3e1YhMVdjJW5jqwlD/VNddGjgzyunmiZg0uOXsHXbytYmsA545S8KRQFaJKFXYYFo2kOjqOiC1T2cAzMDjCQ==\n-----END PRIVATE KEY-----\n", + "client_email": "sample@appspot.gserviceaccount.com", + "client_id": "100000000000000000221", + "auth_uri": "https://accounts.google.com/o/oauth2/auth", + "token_uri": "https://oauth2.googleapis.com/token", + "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs", + "client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/quota-project-id%40appspot.gserviceaccount.com", + "universe_domain": "googleapis.com" +} diff --git a/settings.gradle b/settings.gradle index 8cc35cdc..2b283d02 100644 --- a/settings.gradle +++ b/settings.gradle @@ -43,6 +43,7 @@ include ":examples-spring-sleuth" include ":examples-spring" include ":propagators-gcp" include ":shared-resourcemapping" +include ":gcp-auth-extension" def javaVersion = Jvm.current().javaVersion @@ -107,3 +108,6 @@ project(':examples-spring').projectDir = project(':examples-otlpmetrics-function').projectDir = "$rootDir/examples/otlpmetrics-function" as File + +project(':gcp-auth-extension').projectDir = + "$rootDir/javaagent-extensions/gcp-auth" as File