diff --git a/core/src/main/java/com/google/adk/telemetry/OtelHooks.java b/core/src/main/java/com/google/adk/telemetry/OtelHooks.java new file mode 100644 index 000000000..8839543cc --- /dev/null +++ b/core/src/main/java/com/google/adk/telemetry/OtelHooks.java @@ -0,0 +1,70 @@ +/* + * Copyright 2026 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.adk.telemetry; + +import com.google.auto.value.AutoValue; +import com.google.common.collect.ImmutableList; +import com.google.errorprone.annotations.CanIgnoreReturnValue; +import io.opentelemetry.sdk.logs.LogRecordProcessor; +import io.opentelemetry.sdk.metrics.export.MetricReader; +import io.opentelemetry.sdk.trace.SpanProcessor; + +/** + * OpenTelemetry plumbing an application contributes to the providers {@link TelemetrySetup} + * installs. + * + *

The three signals are independent. A caller that only exports metrics sets only {@link + * #metricReaders()}; the signals it left out are empty, and {@link TelemetrySetup} builds no + * provider of its own for them. + */ +@AutoValue +public abstract class OtelHooks { + + /** Span processors to add to the tracer provider. */ + public abstract ImmutableList spanProcessors(); + + /** Metric readers to register with the meter provider. */ + public abstract ImmutableList metricReaders(); + + /** Log record processors to add to the logger provider. */ + public abstract ImmutableList logRecordProcessors(); + + /** Returns a builder whose three signals all start out empty. */ + public static Builder builder() { + return new AutoValue_OtelHooks.Builder() + .spanProcessors(ImmutableList.of()) + .metricReaders(ImmutableList.of()) + .logRecordProcessors(ImmutableList.of()); + } + + /** Builder for {@link OtelHooks}. */ + @AutoValue.Builder + public abstract static class Builder { + + @CanIgnoreReturnValue + public abstract Builder spanProcessors(Iterable spanProcessors); + + @CanIgnoreReturnValue + public abstract Builder metricReaders(Iterable metricReaders); + + @CanIgnoreReturnValue + public abstract Builder logRecordProcessors( + Iterable logRecordProcessors); + + public abstract OtelHooks build(); + } +} diff --git a/core/src/main/java/com/google/adk/telemetry/TelemetrySetup.java b/core/src/main/java/com/google/adk/telemetry/TelemetrySetup.java new file mode 100644 index 000000000..8d69cf947 --- /dev/null +++ b/core/src/main/java/com/google/adk/telemetry/TelemetrySetup.java @@ -0,0 +1,486 @@ +/* + * Copyright 2026 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.adk.telemetry; + +import static java.nio.charset.StandardCharsets.UTF_8; + +import com.google.common.annotations.VisibleForTesting; +import com.google.common.base.CharMatcher; +import com.google.common.base.Splitter; +import com.google.common.base.Strings; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import io.opentelemetry.api.GlobalOpenTelemetry; +import io.opentelemetry.api.baggage.propagation.W3CBaggagePropagator; +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.api.common.AttributesBuilder; +import io.opentelemetry.api.trace.propagation.W3CTraceContextPropagator; +import io.opentelemetry.context.propagation.ContextPropagators; +import io.opentelemetry.context.propagation.TextMapPropagator; +import io.opentelemetry.exporter.otlp.http.logs.OtlpHttpLogRecordExporter; +import io.opentelemetry.exporter.otlp.http.logs.OtlpHttpLogRecordExporterBuilder; +import io.opentelemetry.exporter.otlp.http.metrics.OtlpHttpMetricExporter; +import io.opentelemetry.exporter.otlp.http.metrics.OtlpHttpMetricExporterBuilder; +import io.opentelemetry.exporter.otlp.http.trace.OtlpHttpSpanExporter; +import io.opentelemetry.exporter.otlp.http.trace.OtlpHttpSpanExporterBuilder; +import io.opentelemetry.sdk.OpenTelemetrySdk; +import io.opentelemetry.sdk.OpenTelemetrySdkBuilder; +import io.opentelemetry.sdk.logs.LogRecordProcessor; +import io.opentelemetry.sdk.logs.SdkLoggerProvider; +import io.opentelemetry.sdk.logs.SdkLoggerProviderBuilder; +import io.opentelemetry.sdk.logs.export.BatchLogRecordProcessor; +import io.opentelemetry.sdk.metrics.SdkMeterProvider; +import io.opentelemetry.sdk.metrics.SdkMeterProviderBuilder; +import io.opentelemetry.sdk.metrics.export.MetricReader; +import io.opentelemetry.sdk.metrics.export.PeriodicMetricReader; +import io.opentelemetry.sdk.resources.Resource; +import io.opentelemetry.sdk.trace.SdkTracerProvider; +import io.opentelemetry.sdk.trace.SdkTracerProviderBuilder; +import io.opentelemetry.sdk.trace.SpanProcessor; +import io.opentelemetry.sdk.trace.export.BatchSpanProcessor; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.time.Duration; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.function.BiConsumer; +import java.util.function.Consumer; +import java.util.function.Function; +import org.jspecify.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Installs the global OpenTelemetry providers that ADK exports its telemetry through. + * + *

An application that embeds ADK hands over its own span processors, metric readers and log + * record processors as {@link OtelHooks}, and they become the global providers that {@link Tracing} + * and {@link Metrics} write to. + * + *

Call this before anything else in the process reads the global OpenTelemetry instance. Reading + * it while it is unset registers a no-op instance in it, and the global can only be claimed once, + * so a read that happens first leaves nothing for this class to install. {@link Tracing} and {@link + * Metrics} both read it as they are class-loaded, which is early enough that touching an agent, a + * runner or a flow before calling this is enough to lose the setup. + */ +public final class TelemetrySetup { + + private static final Logger logger = LoggerFactory.getLogger(TelemetrySetup.class); + + private static final Function SYSTEM_ENVIRONMENT = System::getenv; + + private static final String OTLP_VARIABLE_PREFIX = "OTEL_EXPORTER_OTLP_"; + private static final String SERVICE_NAME_VARIABLE = "OTEL_SERVICE_NAME"; + private static final String RESOURCE_ATTRIBUTES_VARIABLE = "OTEL_RESOURCE_ATTRIBUTES"; + private static final String SERVICE_NAME_ATTRIBUTE = "service.name"; + + /** + * Installs global OpenTelemetry providers carrying {@code otelHooksToSetUp}, resourced with + * whatever the environment declares. + * + * @see #maybeSetOtelProviders(List, Resource) + */ + public static void maybeSetOtelProviders(List otelHooksToSetUp) { + maybeSetOtelProviders( + otelHooksToSetUp, environmentResource(SYSTEM_ENVIRONMENT), SYSTEM_ENVIRONMENT); + } + + /** + * Installs global OpenTelemetry providers carrying {@code otelHooksToSetUp}. + * + *

Every hooks value in the list contributes: a provider is built per signal from all the + * processors and readers given for it, carrying {@code otelResource}, and the instance installed + * propagates context as W3C trace context and baggage. + * + *

Java registers all three signals through one global instance, so claiming it for one signal + * claims it for all of them. A signal nobody asked for gets an empty provider that discards + * everything, and the application cannot install its own for that signal afterwards. + * + *

Generic OTLP exporters are added on top when the standard environment variables ask for + * them: {@code OTEL_EXPORTER_OTLP_ENDPOINT}, {@code OTEL_EXPORTER_OTLP_TRACES_ENDPOINT}, {@code + * OTEL_EXPORTER_OTLP_METRICS_ENDPOINT} and {@code OTEL_EXPORTER_OTLP_LOGS_ENDPOINT}. Those + * exporters also take the headers, timeout, compression and trusted certificates the matching + * {@code OTEL_EXPORTER_OTLP_*} variables declare, per signal or generically. See the OTLP + * exporter configuration for how they are used. + * + *

This is the "maybe" part: an application that registered its own OpenTelemetry before ADK + * started keeps it, and calling this anyway is not an error. With nothing at all to install, the + * global is left unclaimed so the application can still install its own later. + * + * @param otelHooksToSetUp per-signal processors and readers to add to the providers + * @param otelResource the resource the providers report themselves with + */ + public static void maybeSetOtelProviders( + List otelHooksToSetUp, Resource otelResource) { + maybeSetOtelProviders(otelHooksToSetUp, otelResource, SYSTEM_ENVIRONMENT); + } + + /** + * The body of {@link #maybeSetOtelProviders(List, Resource)}, reading configuration through + * {@code environment} rather than straight from the process environment so a test can drive it. + */ + @VisibleForTesting + static void maybeSetOtelProviders( + List otelHooksToSetUp, + Resource otelResource, + Function environment) { + OtelHooks otlpHooks = otlpHooksFromEnvironment(environment); + ImmutableList hooksToSetUp = + ImmutableList.builder().addAll(otelHooksToSetUp).add(otlpHooks).build(); + + List spanProcessors = new ArrayList<>(); + List metricReaders = new ArrayList<>(); + List logRecordProcessors = new ArrayList<>(); + for (OtelHooks otelHooks : hooksToSetUp) { + spanProcessors.addAll(otelHooks.spanProcessors()); + metricReaders.addAll(otelHooks.metricReaders()); + logRecordProcessors.addAll(otelHooks.logRecordProcessors()); + } + + // Java registers all three signals through a single global. Claiming it for providers with + // nothing behind them would lock the application out of installing its own. + if (spanProcessors.isEmpty() && metricReaders.isEmpty() && logRecordProcessors.isEmpty()) { + return; + } + + // Without this the builder defaults to no-op propagators, losing W3C traceparent for good. + OpenTelemetrySdkBuilder sdkBuilder = + OpenTelemetrySdk.builder() + .setPropagators( + ContextPropagators.create( + TextMapPropagator.composite( + W3CTraceContextPropagator.getInstance(), + W3CBaggagePropagator.getInstance()))); + if (!spanProcessors.isEmpty()) { + SdkTracerProviderBuilder tracerProvider = + SdkTracerProvider.builder().setResource(otelResource); + spanProcessors.forEach(tracerProvider::addSpanProcessor); + sdkBuilder.setTracerProvider(tracerProvider.build()); + } + if (!metricReaders.isEmpty()) { + SdkMeterProviderBuilder meterProvider = SdkMeterProvider.builder().setResource(otelResource); + metricReaders.forEach(meterProvider::registerMetricReader); + sdkBuilder.setMeterProvider(meterProvider.build()); + } + if (!logRecordProcessors.isEmpty()) { + SdkLoggerProviderBuilder loggerProvider = + SdkLoggerProvider.builder().setResource(otelResource); + logRecordProcessors.forEach(loggerProvider::addLogRecordProcessor); + sdkBuilder.setLoggerProvider(loggerProvider.build()); + } + + try { + GlobalOpenTelemetry.set(sdkBuilder.build()); + } catch (IllegalStateException alreadyRegistered) { + logger.warn( + "OpenTelemetry is already registered globally, so ADK installed nothing and the" + + " telemetry configured here is being discarded. Call maybeSetOtelProviders before" + + " anything else in the process reads the global OpenTelemetry instance."); + // The caller's processors and readers stay theirs to shut down; the OTLP exporters were + // built here from the environment and nothing else can reach them. + shutdown(otlpHooks); + } + } + + /** Exporters for whichever signals the standard OTLP environment variables ask to export. */ + @VisibleForTesting + static OtelHooks otlpHooksFromEnvironment(Function environment) { + OtelHooks.Builder otelHooks = OtelHooks.builder(); + + OtlpSettings traces = otlpSettings("TRACES", "/v1/traces", environment); + if (traces != null) { + OtlpHttpSpanExporterBuilder exporter = + OtlpHttpSpanExporter.builder().setEndpoint(traces.endpoint); + traces.applyTo( + exporter::addHeader, + exporter::setTimeout, + exporter::setCompression, + exporter::setTrustedCertificates); + otelHooks.spanProcessors( + ImmutableList.of(BatchSpanProcessor.builder(exporter.build()).build())); + } + + OtlpSettings metrics = otlpSettings("METRICS", "/v1/metrics", environment); + if (metrics != null) { + OtlpHttpMetricExporterBuilder exporter = + OtlpHttpMetricExporter.builder().setEndpoint(metrics.endpoint); + metrics.applyTo( + exporter::addHeader, + exporter::setTimeout, + exporter::setCompression, + exporter::setTrustedCertificates); + otelHooks.metricReaders(ImmutableList.of(PeriodicMetricReader.create(exporter.build()))); + } + + OtlpSettings logs = otlpSettings("LOGS", "/v1/logs", environment); + if (logs != null) { + OtlpHttpLogRecordExporterBuilder exporter = + OtlpHttpLogRecordExporter.builder().setEndpoint(logs.endpoint); + logs.applyTo( + exporter::addHeader, + exporter::setTimeout, + exporter::setCompression, + exporter::setTrustedCertificates); + otelHooks.logRecordProcessors( + ImmutableList.of(BatchLogRecordProcessor.builder(exporter.build()).build())); + } + + return otelHooks.build(); + } + + /** + * What the standard OTLP environment variables configure for one signal. Java's exporter builders + * read no environment of their own, so everything the variables say has to be applied by hand. + */ + private static final class OtlpSettings { + private final String endpoint; + private final ImmutableMap headers; + private final @Nullable Duration timeout; + private final @Nullable String compression; + private final byte @Nullable [] trustedCertificates; + + OtlpSettings( + String endpoint, + ImmutableMap headers, + @Nullable Duration timeout, + @Nullable String compression, + byte @Nullable [] trustedCertificates) { + this.endpoint = endpoint; + this.headers = headers; + this.timeout = timeout; + this.compression = compression; + this.trustedCertificates = trustedCertificates; + } + + /** + * Applies everything but the endpoint to one signal's exporter builder. The three builders have + * the same setters and no common supertype, so the caller passes them in. + */ + void applyTo( + BiConsumer addHeader, + Consumer setTimeout, + Consumer setCompression, + Consumer setTrustedCertificates) { + headers.forEach(addHeader); + if (timeout != null) { + setTimeout.accept(timeout); + } + if (compression != null) { + setCompression.accept(compression); + } + if (trustedCertificates != null) { + setTrustedCertificates.accept(trustedCertificates); + } + } + } + + /** + * The OTLP settings for one signal, or null if no endpoint variable asks for that signal to be + * exported. + * + * @param signal the infix the signal's own variables carry, such as {@code TRACES} + * @param signalPath the path appended to a generic endpoint, such as {@code /v1/traces} + */ + private static @Nullable OtlpSettings otlpSettings( + String signal, String signalPath, Function environment) { + String endpoint = + otlpEndpoint( + environment.apply(OTLP_VARIABLE_PREFIX + signal + "_ENDPOINT"), + environment.apply(OTLP_VARIABLE_PREFIX + "ENDPOINT"), + signalPath); + if (endpoint == null) { + return null; + } + return new OtlpSettings( + endpoint, + parseKeyValueList( + otlpSetting(signal, "HEADERS", environment), "the OTLP exporter headers for " + signal), + parseTimeout(otlpSetting(signal, "TIMEOUT", environment)), + otlpSetting(signal, "COMPRESSION", environment), + readTrustedCertificates(otlpSetting(signal, "CERTIFICATE", environment))); + } + + /** One OTLP setting, taken from the signal's own variable and falling back to the generic one. */ + private static @Nullable String otlpSetting( + String signal, String setting, Function environment) { + String forThisSignal = environment.apply(OTLP_VARIABLE_PREFIX + signal + "_" + setting); + return Strings.isNullOrEmpty(forThisSignal) + ? Strings.emptyToNull(environment.apply(OTLP_VARIABLE_PREFIX + setting)) + : forThisSignal; + } + + /** + * An export timeout in whole milliseconds, which is the unit the OpenTelemetry specification + * gives {@code OTEL_EXPORTER_OTLP_TIMEOUT}. Anything else, a unit suffix or a negative number + * included, is ignored and leaves the exporter on its own default. + */ + @VisibleForTesting + static @Nullable Duration parseTimeout(@Nullable String timeout) { + if (Strings.isNullOrEmpty(timeout)) { + return null; + } + long milliseconds; + try { + milliseconds = Long.parseLong(timeout.trim()); + } catch (NumberFormatException e) { + logger.warn( + "Ignoring OTLP exporter timeout {}, which is not a whole number of milliseconds.", + timeout); + return null; + } + if (milliseconds < 0) { + logger.warn("Ignoring OTLP exporter timeout {}, which is negative.", timeout); + return null; + } + return Duration.ofMillis(milliseconds); + } + + /** The PEM certificate chain in the file the certificate variable names. */ + @VisibleForTesting + static byte @Nullable [] readTrustedCertificates(@Nullable String path) { + if (Strings.isNullOrEmpty(path)) { + return null; + } + try { + return Files.readAllBytes(Paths.get(path)); + } catch (IOException | RuntimeException e) { + logger.warn("Ignoring OTLP exporter certificate file {}, which could not be read.", path, e); + return null; + } + } + + /** + * OpenTelemetry's default resource with whatever {@code OTEL_SERVICE_NAME} and {@code + * OTEL_RESOURCE_ATTRIBUTES} declare laid over it. A service name in its own variable wins over + * one in the attribute list. + */ + @VisibleForTesting + static Resource environmentResource(Function environment) { + AttributesBuilder attributes = Attributes.builder(); + parseKeyValueList(environment.apply(RESOURCE_ATTRIBUTES_VARIABLE), RESOURCE_ATTRIBUTES_VARIABLE) + .forEach(attributes::put); + String serviceName = environment.apply(SERVICE_NAME_VARIABLE); + if (!Strings.isNullOrEmpty(serviceName)) { + attributes.put(SERVICE_NAME_ATTRIBUTE, serviceName); + } + Attributes declared = attributes.build(); + return declared.isEmpty() + ? Resource.getDefault() + : Resource.getDefault().merge(Resource.create(declared)); + } + + /** + * A {@code key1=value1,key2=value2} environment variable, with each value percent-decoded. An + * entry that is not a key-value pair is dropped. + * + *

{@code description} names the variable for the log line an entry is dropped on. The entry + * itself is never logged. One of the variables parsed here is the OTLP exporter headers, where a + * malformed entry is most likely a piece of a credential. + */ + @VisibleForTesting + static ImmutableMap parseKeyValueList(@Nullable String list, String description) { + if (Strings.isNullOrEmpty(list)) { + return ImmutableMap.of(); + } + Map parsed = new LinkedHashMap<>(); + int position = 0; + for (String entry : Splitter.on(',').trimResults().omitEmptyStrings().split(list)) { + position++; + int separator = entry.indexOf('='); + if (separator <= 0) { + logger.warn("Ignoring entry {} of {}, which is not key=value.", position, description); + continue; + } + parsed.put( + entry.substring(0, separator).trim(), + percentDecode(entry.substring(separator + 1).trim())); + } + return ImmutableMap.copyOf(parsed); + } + + /** + * Percent-decoding as the W3C Baggage syntax uses it, which is how the OpenTelemetry + * specification encodes these values. + * + *

This is not {@code URLDecoder}, which decodes for HTML form submission instead: it turns a + * {@code +} into a space, which corrupts any credential or base64 value carrying one, and it + * throws on a percent sign that does not introduce two hex digits, which turns a stray {@code %} + * in a resource attribute into a startup failure. Here a {@code +} stays a plus sign and an + * incomplete escape stays the literal text it was written as. + */ + private static String percentDecode(String value) { + if (value.indexOf('%') < 0) { + return value; + } + // Three bytes per character is the most any character in a Java string contributes to UTF-8: + // a surrogate pair is two characters and four bytes. + byte[] decoded = new byte[value.length() * 3]; + int length = 0; + int i = 0; + while (i < value.length()) { + if (value.charAt(i) == '%' && i + 2 < value.length()) { + int high = Character.digit(value.charAt(i + 1), 16); + int low = Character.digit(value.charAt(i + 2), 16); + if (high >= 0 && low >= 0) { + decoded[length++] = (byte) ((high << 4) + low); + i += 3; + continue; + } + } + // Everything up to the next percent sign is literal, and is encoded whole so that a + // character outside the ASCII range survives. + int literalEnd = value.indexOf('%', i + 1); + if (literalEnd < 0) { + literalEnd = value.length(); + } + for (byte encoded : value.substring(i, literalEnd).getBytes(UTF_8)) { + decoded[length++] = encoded; + } + i = literalEnd; + } + return new String(decoded, 0, length, UTF_8); + } + + /** + * The endpoint one signal should be exported to, or null if neither variable configures one. A + * signal-specific endpoint is a complete URL; the generic one is a base that the signal's own + * path is appended to. + */ + @VisibleForTesting + static @Nullable String otlpEndpoint( + @Nullable String signalEndpoint, @Nullable String genericEndpoint, String signalPath) { + if (!Strings.isNullOrEmpty(signalEndpoint)) { + return signalEndpoint; + } + if (Strings.isNullOrEmpty(genericEndpoint)) { + return null; + } + return CharMatcher.is('/').trimTrailingFrom(genericEndpoint) + signalPath; + } + + private static void shutdown(OtelHooks otelHooks) { + otelHooks.spanProcessors().forEach(SpanProcessor::shutdown); + otelHooks.metricReaders().forEach(MetricReader::shutdown); + otelHooks.logRecordProcessors().forEach(LogRecordProcessor::shutdown); + } + + private TelemetrySetup() {} +} diff --git a/core/src/test/java/com/google/adk/telemetry/OtelHooksTest.java b/core/src/test/java/com/google/adk/telemetry/OtelHooksTest.java new file mode 100644 index 000000000..dfee28ce6 --- /dev/null +++ b/core/src/test/java/com/google/adk/telemetry/OtelHooksTest.java @@ -0,0 +1,89 @@ +/* + * Copyright 2026 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.adk.telemetry; + +import static com.google.common.truth.Truth.assertThat; + +import com.google.common.collect.ImmutableList; +import io.opentelemetry.sdk.logs.LogRecordProcessor; +import io.opentelemetry.sdk.logs.export.SimpleLogRecordProcessor; +import io.opentelemetry.sdk.metrics.export.MetricReader; +import io.opentelemetry.sdk.testing.exporter.InMemoryLogRecordExporter; +import io.opentelemetry.sdk.testing.exporter.InMemoryMetricReader; +import io.opentelemetry.sdk.testing.exporter.InMemorySpanExporter; +import io.opentelemetry.sdk.trace.SpanProcessor; +import io.opentelemetry.sdk.trace.export.SimpleSpanProcessor; +import java.util.ArrayList; +import java.util.List; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +@RunWith(JUnit4.class) +public final class OtelHooksTest { + + @Test + public void builder_leavesEverySignalEmpty() { + OtelHooks hooks = OtelHooks.builder().build(); + + assertThat(hooks.spanProcessors()).isEmpty(); + assertThat(hooks.metricReaders()).isEmpty(); + assertThat(hooks.logRecordProcessors()).isEmpty(); + } + + @Test + public void builder_signalsAreIndependentOfEachOther() { + MetricReader reader = InMemoryMetricReader.create(); + + OtelHooks hooks = OtelHooks.builder().metricReaders(ImmutableList.of(reader)).build(); + + assertThat(hooks.metricReaders()).containsExactly(reader); + assertThat(hooks.spanProcessors()).isEmpty(); + assertThat(hooks.logRecordProcessors()).isEmpty(); + } + + @Test + public void builder_copiesTheGivenIterable() { + SpanProcessor spanProcessor = SimpleSpanProcessor.create(InMemorySpanExporter.create()); + List mutable = new ArrayList<>(); + mutable.add(spanProcessor); + + OtelHooks hooks = OtelHooks.builder().spanProcessors(mutable).build(); + mutable.clear(); + + assertThat(hooks.spanProcessors()).containsExactly(spanProcessor); + } + + @Test + public void builder_carriesAllThreeSignalsTogether() { + SpanProcessor spanProcessor = SimpleSpanProcessor.create(InMemorySpanExporter.create()); + MetricReader reader = InMemoryMetricReader.create(); + LogRecordProcessor logRecordProcessor = + SimpleLogRecordProcessor.create(InMemoryLogRecordExporter.create()); + + OtelHooks hooks = + OtelHooks.builder() + .spanProcessors(ImmutableList.of(spanProcessor)) + .metricReaders(ImmutableList.of(reader)) + .logRecordProcessors(ImmutableList.of(logRecordProcessor)) + .build(); + + assertThat(hooks.spanProcessors()).containsExactly(spanProcessor); + assertThat(hooks.metricReaders()).containsExactly(reader); + assertThat(hooks.logRecordProcessors()).containsExactly(logRecordProcessor); + } +} diff --git a/core/src/test/java/com/google/adk/telemetry/TelemetrySetupTest.java b/core/src/test/java/com/google/adk/telemetry/TelemetrySetupTest.java new file mode 100644 index 000000000..8f5b74195 --- /dev/null +++ b/core/src/test/java/com/google/adk/telemetry/TelemetrySetupTest.java @@ -0,0 +1,408 @@ +/* + * Copyright 2026 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.adk.telemetry; + +import static com.google.common.truth.Truth.assertThat; +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.junit.Assert.assertThrows; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import io.opentelemetry.api.GlobalOpenTelemetry; +import io.opentelemetry.api.common.AttributeKey; +import io.opentelemetry.sdk.OpenTelemetrySdk; +import io.opentelemetry.sdk.logs.LogRecordProcessor; +import io.opentelemetry.sdk.logs.export.SimpleLogRecordProcessor; +import io.opentelemetry.sdk.metrics.export.MetricReader; +import io.opentelemetry.sdk.resources.Resource; +import io.opentelemetry.sdk.testing.exporter.InMemoryLogRecordExporter; +import io.opentelemetry.sdk.testing.exporter.InMemoryMetricReader; +import io.opentelemetry.sdk.testing.exporter.InMemorySpanExporter; +import io.opentelemetry.sdk.trace.SdkTracerProvider; +import io.opentelemetry.sdk.trace.SpanProcessor; +import io.opentelemetry.sdk.trace.data.SpanData; +import io.opentelemetry.sdk.trace.export.SimpleSpanProcessor; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +@RunWith(JUnit4.class) +public final class TelemetrySetupTest { + + private static final AttributeKey SERVICE_NAME = AttributeKey.stringKey("service.name"); + private static final ImmutableMap EMPTY_ENVIRONMENT = ImmutableMap.of(); + private static final String COLLECTOR = "http://localhost:4318"; + + @Before + public void unclaimTheGlobal() { + GlobalOpenTelemetry.resetForTest(); + } + + @After + public void releaseTheGlobal() { + GlobalOpenTelemetry.resetForTest(); + } + + // Installing providers. + + @Test + public void maybeSetOtelProviders_callerSpanProcessorReceivesSpansWithTheResource() { + InMemorySpanExporter exporter = InMemorySpanExporter.create(); + Resource resource = Resource.getDefault().merge(resourceNamed("under-test")); + + TelemetrySetup.maybeSetOtelProviders( + ImmutableList.of(hooksExportingTo(exporter)), resource, EMPTY_ENVIRONMENT::get); + GlobalOpenTelemetry.getTracer("test").spanBuilder("a-span").startSpan().end(); + + assertThat(exporter.getFinishedSpanItems()).hasSize(1); + SpanData span = exporter.getFinishedSpanItems().get(0); + assertThat(span.getName()).isEqualTo("a-span"); + assertThat(span.getResource().getAttribute(SERVICE_NAME)).isEqualTo("under-test"); + } + + @Test + public void maybeSetOtelProviders_everyHooksValueInTheListContributes() { + InMemorySpanExporter first = InMemorySpanExporter.create(); + InMemorySpanExporter second = InMemorySpanExporter.create(); + + TelemetrySetup.maybeSetOtelProviders( + ImmutableList.of(hooksExportingTo(first), hooksExportingTo(second)), + Resource.getDefault(), + EMPTY_ENVIRONMENT::get); + GlobalOpenTelemetry.getTracer("test").spanBuilder("a-span").startSpan().end(); + + assertThat(first.getFinishedSpanItems()).hasSize(1); + assertThat(second.getFinishedSpanItems()).hasSize(1); + } + + @Test + public void maybeSetOtelProviders_callerMetricReaderAndLogProcessorAlsoReceiveTheirSignal() { + InMemoryMetricReader metricReader = InMemoryMetricReader.create(); + InMemoryLogRecordExporter logExporter = InMemoryLogRecordExporter.create(); + OtelHooks hooks = + OtelHooks.builder() + .metricReaders(ImmutableList.of(metricReader)) + .logRecordProcessors(ImmutableList.of(SimpleLogRecordProcessor.create(logExporter))) + .build(); + + TelemetrySetup.maybeSetOtelProviders( + ImmutableList.of(hooks), Resource.getDefault(), EMPTY_ENVIRONMENT::get); + GlobalOpenTelemetry.getMeter("test").counterBuilder("a-counter").build().add(1); + GlobalOpenTelemetry.get() + .getLogsBridge() + .get("test") + .logRecordBuilder() + .setBody("a-log") + .emit(); + + assertThat(metricReader.collectAllMetrics()).hasSize(1); + assertThat(logExporter.getFinishedLogRecordItems()).hasSize(1); + } + + @Test + public void maybeSetOtelProviders_installedGlobalPropagatesTraceContextAndBaggage() { + TelemetrySetup.maybeSetOtelProviders( + ImmutableList.of(hooksExportingTo(InMemorySpanExporter.create())), + Resource.getDefault(), + EMPTY_ENVIRONMENT::get); + + // The SDK builder's own default is a no-op propagator, which would drop every incoming and + // outgoing traceparent for the life of the process. + assertThat(GlobalOpenTelemetry.getPropagators().getTextMapPropagator().fields()) + .containsAtLeast("traceparent", "baggage"); + } + + @Test + public void maybeSetOtelProviders_oneSignalStillClaimsTheWholeGlobal() { + TelemetrySetup.maybeSetOtelProviders( + ImmutableList.of(hooksExportingTo(InMemorySpanExporter.create())), + Resource.getDefault(), + EMPTY_ENVIRONMENT::get); + + // Java has one global for all three signals, so a traces-only setup takes metrics and logs + // with it and the application can no longer install its own. + OpenTelemetrySdk applicationSdk = OpenTelemetrySdk.builder().build(); + assertThrows(IllegalStateException.class, () -> GlobalOpenTelemetry.set(applicationSdk)); + } + + @Test + public void maybeSetOtelProviders_nothingToInstall_leavesTheGlobalUnclaimed() { + TelemetrySetup.maybeSetOtelProviders( + ImmutableList.of(), Resource.getDefault(), EMPTY_ENVIRONMENT::get); + + InMemorySpanExporter applicationExporter = InMemorySpanExporter.create(); + GlobalOpenTelemetry.set(sdkExportingTo(applicationExporter)); + GlobalOpenTelemetry.getTracer("test").spanBuilder("a-span").startSpan().end(); + + assertThat(applicationExporter.getFinishedSpanItems()).hasSize(1); + } + + @Test + public void maybeSetOtelProviders_globalAlreadyRegistered_leavesItInPlace() { + InMemorySpanExporter applicationExporter = InMemorySpanExporter.create(); + GlobalOpenTelemetry.set(sdkExportingTo(applicationExporter)); + InMemorySpanExporter adkExporter = InMemorySpanExporter.create(); + + TelemetrySetup.maybeSetOtelProviders( + ImmutableList.of(hooksExportingTo(adkExporter)), + Resource.getDefault(), + EMPTY_ENVIRONMENT::get); + GlobalOpenTelemetry.getTracer("test").spanBuilder("a-span").startSpan().end(); + + assertThat(applicationExporter.getFinishedSpanItems()).hasSize(1); + assertThat(adkExporter.getFinishedSpanItems()).isEmpty(); + } + + // Which signals the OTLP environment variables ask to export. + + @Test + public void otlpHooksFromEnvironment_tracesEndpoint_exportsTracesOnly() { + assertConfiguredSignals( + ImmutableMap.of("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT", COLLECTOR + "/v1/traces"), + /* traces= */ true, + /* metrics= */ false, + /* logs= */ false); + } + + @Test + public void otlpHooksFromEnvironment_metricsEndpoint_exportsMetricsOnly() { + assertConfiguredSignals( + ImmutableMap.of("OTEL_EXPORTER_OTLP_METRICS_ENDPOINT", COLLECTOR + "/v1/metrics"), + /* traces= */ false, + /* metrics= */ true, + /* logs= */ false); + } + + @Test + public void otlpHooksFromEnvironment_logsEndpoint_exportsLogsOnly() { + assertConfiguredSignals( + ImmutableMap.of("OTEL_EXPORTER_OTLP_LOGS_ENDPOINT", COLLECTOR + "/v1/logs"), + /* traces= */ false, + /* metrics= */ false, + /* logs= */ true); + } + + @Test + public void otlpHooksFromEnvironment_genericEndpoint_exportsEverySignal() { + assertConfiguredSignals( + ImmutableMap.of("OTEL_EXPORTER_OTLP_ENDPOINT", COLLECTOR), + /* traces= */ true, + /* metrics= */ true, + /* logs= */ true); + } + + @Test + public void otlpHooksFromEnvironment_noEndpoint_exportsNothing() { + assertConfiguredSignals( + ImmutableMap.of("OTEL_EXPORTER_OTLP_HEADERS", "authorization=Bearer token"), + /* traces= */ false, + /* metrics= */ false, + /* logs= */ false); + } + + // Endpoints. + + @Test + public void otlpEndpoint_signalEndpointIsUsedWhole() { + assertThat( + TelemetrySetup.otlpEndpoint( + "http://collector:4318/custom/traces", "http://generic:4318", "/v1/traces")) + .isEqualTo("http://collector:4318/custom/traces"); + } + + @Test + public void otlpEndpoint_genericEndpointGainsTheSignalPath() { + assertThat(TelemetrySetup.otlpEndpoint(null, "http://collector:4318", "/v1/metrics")) + .isEqualTo("http://collector:4318/v1/metrics"); + } + + @Test + public void otlpEndpoint_genericEndpointKeepsItsOwnPath() { + assertThat(TelemetrySetup.otlpEndpoint("", "http://collector:4318/otlp/", "/v1/logs")) + .isEqualTo("http://collector:4318/otlp/v1/logs"); + } + + @Test + public void otlpEndpoint_neitherIsSet_isNull() { + assertThat(TelemetrySetup.otlpEndpoint(null, null, "/v1/traces")).isNull(); + assertThat(TelemetrySetup.otlpEndpoint("", "", "/v1/traces")).isNull(); + } + + // Key-value environment variables. + + @Test + public void parseKeyValueList_keepsAPlusSignAndLaterSeparatorsInTheValue() { + // A credential is the usual content of this variable, and base64 is full of plus signs. + assertThat(TelemetrySetup.parseKeyValueList("authorization=Bearer a+b/c==", "headers")) + .containsExactly("authorization", "Bearer a+b/c=="); + } + + @Test + public void parseKeyValueList_percentDecodesTheValue() { + assertThat( + TelemetrySetup.parseKeyValueList( + "greeting=hello%20world%21,accented=caf%C3%A9", "attributes")) + .containsExactly("greeting", "hello world!", "accented", "caf\u00e9"); + } + + @Test + public void parseKeyValueList_incompleteEscapeStaysLiteral() { + assertThat(TelemetrySetup.parseKeyValueList("progress=100%done,trailing=50%", "attributes")) + .containsExactly("progress", "100%done", "trailing", "50%"); + } + + @Test + public void parseKeyValueList_dropsAnEntryThatIsNotAPair() { + assertThat(TelemetrySetup.parseKeyValueList("good=yes,justatoken,=novalue", "attributes")) + .containsExactly("good", "yes"); + } + + @Test + public void parseKeyValueList_trimsAroundEveryPart() { + assertThat( + TelemetrySetup.parseKeyValueList(" region = us-central1 , tier = prod ", "attributes")) + .containsExactly("region", "us-central1", "tier", "prod"); + } + + @Test + public void parseKeyValueList_unset_isEmpty() { + assertThat(TelemetrySetup.parseKeyValueList(null, "attributes")).isEmpty(); + assertThat(TelemetrySetup.parseKeyValueList("", "attributes")).isEmpty(); + assertThat(TelemetrySetup.parseKeyValueList(" , ,, ", "attributes")).isEmpty(); + } + + // Timeouts. + + @Test + public void parseTimeout_readsWholeMilliseconds() { + assertThat(TelemetrySetup.parseTimeout(" 2500 ")).isEqualTo(Duration.ofMillis(2500)); + } + + @Test + public void parseTimeout_withAUnitSuffix_isIgnored() { + assertThat(TelemetrySetup.parseTimeout("10s")).isNull(); + } + + @Test + public void parseTimeout_negative_isIgnored() { + assertThat(TelemetrySetup.parseTimeout("-1")).isNull(); + } + + @Test + public void parseTimeout_unset_isNull() { + assertThat(TelemetrySetup.parseTimeout(null)).isNull(); + assertThat(TelemetrySetup.parseTimeout("")).isNull(); + } + + // Trusted certificates. + + @Test + public void readTrustedCertificates_readsTheNamedFile() throws IOException { + byte[] pem = "-----BEGIN CERTIFICATE-----".getBytes(UTF_8); + Path certificate = Files.createTempFile("trusted", ".pem"); + Files.write(certificate, pem); + + assertThat(TelemetrySetup.readTrustedCertificates(certificate.toString())).isEqualTo(pem); + } + + @Test + public void readTrustedCertificates_unreadableFile_isIgnored() { + assertThat(TelemetrySetup.readTrustedCertificates("/no/such/trusted.pem")).isNull(); + } + + @Test + public void readTrustedCertificates_unset_isNull() { + assertThat(TelemetrySetup.readTrustedCertificates(null)).isNull(); + assertThat(TelemetrySetup.readTrustedCertificates("")).isNull(); + } + + // Resources. + + @Test + public void environmentResource_serviceNameVariableWinsOverTheAttributeList() { + ImmutableMap environment = + ImmutableMap.of( + "OTEL_RESOURCE_ATTRIBUTES", + "service.name=from-the-list,deployment.environment=staging", + "OTEL_SERVICE_NAME", + "from-the-variable"); + + Resource resource = TelemetrySetup.environmentResource(environment::get); + + assertThat(resource.getAttribute(SERVICE_NAME)).isEqualTo("from-the-variable"); + assertThat(resource.getAttribute(AttributeKey.stringKey("deployment.environment"))) + .isEqualTo("staging"); + } + + @Test + public void environmentResource_attributesLieOverTheDefaultResource() { + ImmutableMap environment = + ImmutableMap.of("OTEL_RESOURCE_ATTRIBUTES", "deployment.environment=staging"); + + Resource resource = TelemetrySetup.environmentResource(environment::get); + + assertThat(resource.getAttribute(AttributeKey.stringKey("deployment.environment"))) + .isEqualTo("staging"); + assertThat(resource.getAttributes().asMap()) + .containsAtLeastEntriesIn(Resource.getDefault().getAttributes().asMap()); + } + + @Test + public void environmentResource_noVariables_isTheDefaultResource() { + assertThat(TelemetrySetup.environmentResource(EMPTY_ENVIRONMENT::get)) + .isEqualTo(Resource.getDefault()); + } + + private static void assertConfiguredSignals( + ImmutableMap environment, boolean traces, boolean metrics, boolean logs) { + OtelHooks hooks = TelemetrySetup.otlpHooksFromEnvironment(environment::get); + try { + assertThat(hooks.spanProcessors()).hasSize(traces ? 1 : 0); + assertThat(hooks.metricReaders()).hasSize(metrics ? 1 : 0); + assertThat(hooks.logRecordProcessors()).hasSize(logs ? 1 : 0); + } finally { + hooks.spanProcessors().forEach(SpanProcessor::shutdown); + hooks.metricReaders().forEach(MetricReader::shutdown); + hooks.logRecordProcessors().forEach(LogRecordProcessor::shutdown); + } + } + + private static OtelHooks hooksExportingTo(InMemorySpanExporter exporter) { + return OtelHooks.builder() + .spanProcessors(ImmutableList.of(SimpleSpanProcessor.create(exporter))) + .build(); + } + + private static OpenTelemetrySdk sdkExportingTo(InMemorySpanExporter exporter) { + return OpenTelemetrySdk.builder() + .setTracerProvider( + SdkTracerProvider.builder() + .addSpanProcessor(SimpleSpanProcessor.create(exporter)) + .build()) + .build(); + } + + private static Resource resourceNamed(String serviceName) { + return Resource.builder().put(SERVICE_NAME, serviceName).build(); + } +}