Add GCP authentication extension (#1631)

This commit is contained in:
Pranav Sharma 2025-01-10 15:05:10 -05:00 committed by GitHub
parent deb9746f80
commit 7480d43b1f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 1046 additions and 0 deletions

View File

@ -33,6 +33,9 @@ components:
gcp-resources:
- jsuereth
- psx95
gcp-auth-extension:
- jsuereth
- psx95
jfr-connection:
- breedx-splk
- jeanbisutti

View File

@ -32,6 +32,7 @@ component_names["compressors/"]="Compressors"
component_names["consistent-sampling/"]="Consistent sampling"
component_names["disk-buffering/"]="Disk buffering"
component_names["gcp-resources/"]="GCP Resources"
component_names["gcp-auth-extension/"]="GCP authentication extension"
component_names["inferred-spans/"]="Inferred spans"
component_names["jfr-connection/"]="JFR connection"
component_names["jfr-events/"]="JFR events"

View File

@ -18,6 +18,7 @@ feature or via instrumentation, this project is hopefully for you.
| alpha | [zstd Compressor](./compressors/compressor-zstd/README.md) |
| alpha | [Consistent Sampling](./consistent-sampling/README.md) |
| alpha | [Disk Buffering](./disk-buffering/README.md) |
| alpha | [GCP Authentication Extension](./gcp-auth-extension/README.md) |
| beta | [GCP Resources](./gcp-resources/README.md) |
| beta | [Inferred Spans](./inferred-spans/README.md) |
| alpha | [JFR Connection](./jfr-connection/README.md) |

View File

@ -0,0 +1,124 @@
# Google Cloud Authentication Extension
The Google Cloud Auth Extension allows the users to export telemetry from their applications 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
### With OpenTelemetry Java agent
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 = <OpenTelemetry Java Agent location>
// Specify the path for Google Cloud Authentication Extension for the Java Agent.
def extensionPath = <Google Cloud Authentication Extension location>
def googleCloudProjectId = <Your Google Cloud Project ID>
def googleOtlpEndpoint = <Google Cloud OTLP endpoint>
val autoconf_config = listOf(
"-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'
)
application {
...
applicationDefaultJvmArgs = autoconf_config
...
}
```
### Without OpenTelemetry Java agent
This extension can be used without the OpenTelemetry Java agent by leveraging the [OpenTelemetry SDK Autoconfigure](https://github.com/open-telemetry/opentelemetry-java/blob/main/sdk-extensions/autoconfigure/README.md) module.\
When using the autoconfigured SDK, simply adding this extension as a dependency automatically configures authentication headers and resource attributes for spans, enabling export to Google Cloud.
Below is a snippet showing how to use this extension as a dependency when the application is not instrumented using the OpenTelemetry Java agent.
```gradle
dependencies {
implementation("io.opentelemetry:opentelemetry-api")
implementation("io.opentelemetry:opentelemetry-sdk")
implementation("io.opentelemetry:opentelemetry-exporter-otlp")
implementation("io.opentelemetry:opentelemetry-sdk-extension-autoconfigure")
// include the auth extension dependency
implementation("io.opentelemetry.contrib:opentelemetry-gcp-auth-extension")
// other dependencies
...
}
val autoconf_config = listOf(
'-Dgoogle.cloud.project=your-gcp-project-id',
'-Dotel.exporter.otlp.endpoint=https://your.otlp.endpoint:1234',
'-Dotel.traces.exporter=otlp',
'-Dotel.java.global-autoconfigure.enabled=true'
// any additional args
...
)
application {
applicationDefaultJvmArgs = autoconf_config
// additional configuration
...
}
```
## Component Owners
- [Josh Suereth](https://github.com/jsuereth), Google
- [Pranav Sharma](https://github.com/psx95), Google
Learn more about component owners in [component_owners.yml](../.github/component_owners.yml).

View File

@ -0,0 +1,114 @@
plugins {
id("otel.java-conventions")
id("otel.publish-conventions")
id("com.github.johnrengelman.shadow")
id("org.springframework.boot") version "2.7.18"
}
description = "OpenTelemetry extension that provides GCP authentication support for OTLP exporters"
otelJava.moduleName.set("io.opentelemetry.contrib.gcp.auth")
val agent: Configuration by configurations.creating {
isCanBeResolved = true
isCanBeConsumed = false
}
dependencies {
annotationProcessor("com.google.auto.service:auto-service")
// We use `compileOnly` dependency because during runtime all necessary classes are provided by
// javaagent itself.
compileOnly("com.google.auto.service:auto-service-annotations")
compileOnly("io.opentelemetry:opentelemetry-api")
compileOnly("io.opentelemetry:opentelemetry-sdk-extension-autoconfigure")
compileOnly("io.opentelemetry:opentelemetry-exporter-otlp")
// Only dependencies added to `implementation` configuration will be picked up by Shadow plugin
implementation("com.google.auth:google-auth-library-oauth2-http:1.30.1")
// Test dependencies
testCompileOnly("com.google.auto.service:auto-service-annotations")
testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine")
testImplementation("org.junit.jupiter:junit-jupiter-api")
testImplementation("io.opentelemetry:opentelemetry-api")
testImplementation("io.opentelemetry:opentelemetry-exporter-otlp")
testImplementation("io.opentelemetry:opentelemetry-sdk-testing")
testImplementation("io.opentelemetry:opentelemetry-sdk-extension-autoconfigure")
testImplementation("io.opentelemetry.instrumentation:opentelemetry-instrumentation-annotations")
testImplementation("org.awaitility:awaitility")
testImplementation("org.mockito:mockito-inline")
testImplementation("org.mockito:mockito-junit-jupiter")
testImplementation("org.mock-server:mockserver-netty:5.15.0")
testImplementation("io.opentelemetry.proto:opentelemetry-proto:1.4.0-alpha")
testImplementation("org.springframework.boot:spring-boot-starter-web:2.7.18")
testImplementation("org.springframework.boot:spring-boot-starter:2.7.18")
testImplementation("org.springframework.boot:spring-boot-starter-test:2.7.18")
agent("io.opentelemetry.javaagent:opentelemetry-javaagent")
}
tasks {
test {
useJUnitPlatform()
// exclude integration test
exclude("io/opentelemetry/contrib/gcp/auth/GcpAuthExtensionEndToEndTest.class")
}
shadowJar {
archiveClassifier.set("")
}
jar {
// Disable standard jar
enabled = false
}
assemble {
dependsOn(shadowJar)
}
bootJar {
// disable bootJar in build since it only runs as part of test
enabled = false
}
}
val builtLibsDir = layout.buildDirectory.dir("libs").get().asFile.absolutePath
val javaAgentJarPath = "$builtLibsDir/otel-agent.jar"
val authExtensionJarPath = "${tasks.shadowJar.get().archiveFile.get()}"
tasks.register<Copy>("copyAgent") {
into(layout.buildDirectory.dir("libs"))
from(configurations.named("agent") {
rename("opentelemetry-javaagent(.*).jar", "otel-agent.jar")
})
}
tasks.register<Test>("IntegrationTest") {
dependsOn(tasks.shadowJar)
dependsOn(tasks.named("copyAgent"))
useJUnitPlatform()
// include only the integration test file
include("io/opentelemetry/contrib/gcp/auth/GcpAuthExtensionEndToEndTest.class")
val fakeCredsFilePath = project.file("src/test/resources/fakecreds.json").absolutePath
environment("GOOGLE_CLOUD_QUOTA_PROJECT", "quota-project-id")
environment("GOOGLE_APPLICATION_CREDENTIALS", fakeCredsFilePath)
jvmArgs = listOf(
"-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"
)
}

View File

@ -0,0 +1,2 @@
# TODO: uncomment when ready to mark as stable
# otel.stable=true

View File

@ -0,0 +1,93 @@
/*
* Copyright The OpenTelemetry Authors
* SPDX-License-Identifier: Apache-2.0
*/
package io.opentelemetry.contrib.gcp.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 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() {
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(
"GCP Authentication Extension not configured properly: %s not configured. Configure it by exporting environment variable %s or system property %s",
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<String> fallback) {
try {
return this.getConfiguredValue();
} catch (ConfigurationException e) {
return fallback.get();
}
}
}

View File

@ -0,0 +1,126 @@
/*
* Copyright The OpenTelemetry Authors
* SPDX-License-Identifier: Apache-2.0
*/
package io.opentelemetry.contrib.gcp.auth;
import com.google.auth.oauth2.GoogleCredentials;
import com.google.auto.service.AutoService;
import io.opentelemetry.api.common.AttributeKey;
import io.opentelemetry.api.common.Attributes;
import io.opentelemetry.contrib.gcp.auth.GoogleAuthException.Reason;
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.
*
* <p>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}.
*
* <p>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(GcpAuthAutoConfigurationCustomizerProvider::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 static 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 static Map<String, String> getRequiredHeaderMap(GoogleCredentials credentials) {
Map<String, String> 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 static 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);
}
}

View File

@ -0,0 +1,54 @@
/*
* Copyright The OpenTelemetry Authors
* SPDX-License-Identifier: Apache-2.0
*/
package io.opentelemetry.contrib.gcp.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 {
private static final long serialVersionUID = 149908685226796448L;
/**
* 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;
}
}
}

View File

@ -0,0 +1,245 @@
/*
* Copyright The OpenTelemetry Authors
* SPDX-License-Identifier: Apache-2.0
*/
package io.opentelemetry.contrib.gcp.auth;
import static io.opentelemetry.contrib.gcp.auth.GcpAuthAutoConfigurationCustomizerProvider.GCP_USER_PROJECT_ID_KEY;
import static io.opentelemetry.contrib.gcp.auth.GcpAuthAutoConfigurationCustomizerProvider.QUOTA_USER_PROJECT_HEADER;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
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.ArrayList;
import java.util.Collections;
import java.util.Date;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Set;
import java.util.concurrent.TimeUnit;
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<Supplier<Map<String, String>>> headerSupplierCaptor;
private static final ImmutableMap<String, String> otelProperties =
ImmutableMap.of(
"otel.traces.exporter",
"otlp",
"otel.metrics.exporter",
"none",
"otel.logs.exporter",
"none",
"otel.resource.attributes",
"foo=bar");
@BeforeEach
@SuppressWarnings("CannotMockMethod")
public void setup() {
MockitoAnnotations.openMocks(this);
Mockito.when(mockedGoogleCredentials.getQuotaProjectId()).thenReturn("test-project");
Mockito.when(mockedGoogleCredentials.getAccessToken())
.thenReturn(new AccessToken("fake", Date.from(Instant.now())));
}
@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.shutdown()).thenReturn(CompletableResultCode.ofSuccess());
List<SpanData> exportedSpans = new ArrayList<>();
Mockito.when(mockOtlpHttpSpanExporter.export(Mockito.anyCollection()))
.thenAnswer(
invocationOnMock -> {
exportedSpans.addAll(invocationOnMock.getArgument(0));
return CompletableResultCode.ofSuccess();
});
Mockito.when(mockOtlpHttpSpanExporter.toBuilder()).thenReturn(spyOtlpHttpSpanExporterBuilder);
try (MockedStatic<GoogleCredentials> googleCredentialsMockedStatic =
Mockito.mockStatic(GoogleCredentials.class)) {
googleCredentialsMockedStatic
.when(GoogleCredentials::getApplicationDefault)
.thenReturn(mockedGoogleCredentials);
OpenTelemetrySdk sdk = buildOpenTelemetrySdkWithExporter(mockOtlpHttpSpanExporter);
generateTestSpan(sdk);
CompletableResultCode code = sdk.shutdown();
CompletableResultCode joinResult = code.join(10, TimeUnit.SECONDS);
assertTrue(joinResult.isSuccess());
Mockito.verify(mockOtlpHttpSpanExporter, Mockito.times(1)).toBuilder();
Mockito.verify(spyOtlpHttpSpanExporterBuilder, Mockito.times(1))
.setHeaders(headerSupplierCaptor.capture());
assertEquals(2, headerSupplierCaptor.getValue().get().size());
assertThat(verifyAuthHeaders(headerSupplierCaptor.getValue().get())).isTrue();
Mockito.verify(mockOtlpHttpSpanExporter, Mockito.atLeast(1)).export(Mockito.anyCollection());
assertThat(exportedSpans)
.hasSizeGreaterThan(0)
.allSatisfy(
spanData -> {
assertThat(spanData.getResource().getAttributes().asMap())
.containsEntry(AttributeKey.stringKey(GCP_USER_PROJECT_ID_KEY), "test-project")
.containsEntry(AttributeKey.stringKey("foo"), "bar");
assertThat(spanData.getAttributes().asMap())
.containsKey(AttributeKey.longKey("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.shutdown()).thenReturn(CompletableResultCode.ofSuccess());
List<SpanData> exportedSpans = new ArrayList<>();
Mockito.when(mockOtlpGrpcSpanExporter.export(Mockito.anyCollection()))
.thenAnswer(
invocationOnMock -> {
exportedSpans.addAll(invocationOnMock.getArgument(0));
return CompletableResultCode.ofSuccess();
});
Mockito.when(mockOtlpGrpcSpanExporter.toBuilder()).thenReturn(spyOtlpGrpcSpanExporterBuilder);
try (MockedStatic<GoogleCredentials> googleCredentialsMockedStatic =
Mockito.mockStatic(GoogleCredentials.class)) {
googleCredentialsMockedStatic
.when(GoogleCredentials::getApplicationDefault)
.thenReturn(mockedGoogleCredentials);
OpenTelemetrySdk sdk = buildOpenTelemetrySdkWithExporter(mockOtlpGrpcSpanExporter);
generateTestSpan(sdk);
CompletableResultCode code = sdk.shutdown();
CompletableResultCode joinResult = code.join(10, TimeUnit.SECONDS);
assertTrue(joinResult.isSuccess());
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(Mockito.anyCollection());
assertThat(exportedSpans)
.hasSizeGreaterThan(0)
.allSatisfy(
spanData -> {
assertThat(spanData.getResource().getAttributes().asMap())
.containsEntry(AttributeKey.stringKey(GCP_USER_PROJECT_ID_KEY), "test-project")
.containsEntry(AttributeKey.stringKey("foo"), "bar");
assertThat(spanData.getAttributes().asMap())
.containsKey(AttributeKey.longKey("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 <T> List<T> load(Class<T> 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 static boolean verifyAuthHeaders(Map<String, String> headers) {
Set<Entry<String, String>> headerEntrySet = headers.entrySet();
return headerEntrySet.contains(new SimpleEntry<>(QUOTA_USER_PROJECT_HEADER, "test-project"))
&& headerEntrySet.contains(new SimpleEntry<>("Authorization", "Bearer fake"));
}
private static 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 static long busyloop() {
Instant start = Instant.now();
Instant end;
long counter = 0;
do {
counter++;
end = Instant.now();
} while (Duration.between(start, end).toMillis() < 1000);
return counter;
}
}

View File

@ -0,0 +1,213 @@
/*
* Copyright The OpenTelemetry Authors
* SPDX-License-Identifier: Apache-2.0
*/
package io.opentelemetry.contrib.gcp.auth;
import static io.opentelemetry.contrib.gcp.auth.GcpAuthAutoConfigurationCustomizerProvider.GCP_USER_PROJECT_ID_KEY;
import static io.opentelemetry.contrib.gcp.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.protobuf.InvalidProtocolBufferException;
import io.opentelemetry.contrib.gcp.auth.springapp.Application;
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.time.Duration;
import java.util.Arrays;
import java.util.List;
import java.util.Optional;
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;
@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() {
template.getForEntity(
URI.create("http://localhost:" + testApplicationPort + "/ping"), String.class);
await()
.atMost(Duration.ofSeconds(10))
.untilAsserted(
() -> {
HttpRequest[] requests = backendServer.retrieveRecordedRequests(request());
List<Headers> extractedHeaders = extractHeadersFromRequests(requests);
verifyRequestHeaders(extractedHeaders);
List<ResourceSpans> 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) {}
@Override
public void checkServerTrusted(X509Certificate[] chain, String authType) {}
@Override
public X509Certificate[] getAcceptedIssuers() {
return null;
}
}
};
SSLContext sc = SSLContext.getInstance("SSL");
sc.init(null, trustAllCerts, new java.security.SecureRandom());
HttpsURLConnection.setDefaultSSLSocketFactory(sc.getSocketFactory());
}
private static void verifyResourceAttributes(List<ResourceSpans> 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 static void verifyRequestHeaders(List<Headers> 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 static List<Headers> 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 static List<ResourceSpans> extractResourceSpansFromRequests(HttpRequest[] requests) {
return Arrays.stream(requests)
.map(HttpRequest::getBody)
.map(GcpAuthExtensionEndToEndTest::getExportTraceServiceRequest)
.filter(Optional::isPresent)
.map(Optional::get)
.flatMap(
exportTraceServiceRequest -> exportTraceServiceRequest.getResourceSpansList().stream())
.collect(Collectors.toList());
}
private static Optional<ExportTraceServiceRequest> getExportTraceServiceRequest(Body<?> body) {
try {
return Optional.ofNullable(ExportTraceServiceRequest.parseFrom(body.getRawBytes()));
} catch (InvalidProtocolBufferException e) {
return Optional.empty();
}
}
}

View File

@ -0,0 +1,17 @@
/*
* Copyright The OpenTelemetry Authors
* SPDX-License-Identifier: Apache-2.0
*/
package io.opentelemetry.contrib.gcp.auth.springapp;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
@SuppressWarnings("PrivateConstructorForUtilityClass")
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}

View File

@ -0,0 +1,38 @@
/*
* Copyright The OpenTelemetry Authors
* SPDX-License-Identifier: Apache-2.0
*/
package io.opentelemetry.contrib.gcp.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);
busyloop(busyTime);
return "pong";
}
@WithSpan
private static 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;
}
}

View File

@ -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": "",
"client_x509_cert_url": "",
"universe_domain": "googleapis.com"
}

View File

@ -0,0 +1 @@
mock-maker-inline

View File

@ -62,3 +62,4 @@ include(":gcp-resources")
include(":span-stacktrace")
include(":inferred-spans")
include(":opamp-client")
include(":gcp-auth-extension")