diff --git a/.github/workflows/deploy-manual-snapshot.yml b/.github/workflows/deploy-manual-snapshot.yml index 8c1744a8..45c8fb4e 100644 --- a/.github/workflows/deploy-manual-snapshot.yml +++ b/.github/workflows/deploy-manual-snapshot.yml @@ -13,11 +13,15 @@ on: jobs: deploy-snapshot-gradle: - uses: entur/gha-maven-central/.github/workflows/gradle-publish-snapshot.yml@copilot/gradle-publish-snapshot + uses: entur/gha-maven-central/.github/workflows/gradle-publish.yml@v1 secrets: inherit permissions: - contents: read + contents: write + issues: write with: + java_version: 25 next_version: ${{ inputs.version-increment }} version_strategy: tag + snapshot: true + push_to_repo: false diff --git a/azure/logback-logstash-encoder-azure/src/main/java/no/entur/logging/cloud/azure/logback/logstash/AzureLogstashEncoder.java b/azure/logback-logstash-encoder-azure/src/main/java/no/entur/logging/cloud/azure/logback/logstash/AzureLogstashEncoder.java index 16d4e3ae..6f33f47b 100644 --- a/azure/logback-logstash-encoder-azure/src/main/java/no/entur/logging/cloud/azure/logback/logstash/AzureLogstashEncoder.java +++ b/azure/logback-logstash-encoder-azure/src/main/java/no/entur/logging/cloud/azure/logback/logstash/AzureLogstashEncoder.java @@ -2,8 +2,11 @@ import ch.qos.logback.classic.spi.ILoggingEvent; import ch.qos.logback.core.joran.spi.DefaultClass; +import net.logstash.logback.composite.AbstractNestedJsonProvider; +import net.logstash.logback.composite.JsonProvider; import net.logstash.logback.composite.JsonProviders; import net.logstash.logback.composite.loggingevent.LoggingEventJsonProviders; +import net.logstash.logback.composite.loggingevent.MdcJsonProvider; import net.logstash.logback.encoder.LoggingEventCompositeJsonEncoder; public class AzureLogstashEncoder extends LoggingEventCompositeJsonEncoder { @@ -11,12 +14,37 @@ public class AzureLogstashEncoder extends LoggingEventCompositeJsonEncoder { @Override @DefaultClass(LoggingEventJsonProviders.class) public void setProviders(JsonProviders jsonProviders) { + + if(AzureOpenTelemetryTraceMdcJsonProvider.isOtelAgent()) { + // replace MDC provider with our own which translates agent trace_id and span_id to traceId and spanId + // see https://docs.azure.cn/en-us/spring-apps/basic-standard/structured-app-log + replaceMdcProvider(jsonProviders); + } + AzureServiceContextJsonProvider azureServiceContextJsonProvider = new AzureServiceContextJsonProvider(); azureServiceContextJsonProvider.autodetectService(); jsonProviders.addProvider(azureServiceContextJsonProvider); super.setProviders(jsonProviders); } + @SuppressWarnings("unchecked") + private boolean replaceMdcProvider(JsonProviders providers) { + for (JsonProvider jsonProvider : providers.getProviders()) { + if (jsonProvider instanceof MdcJsonProvider) { + providers.removeProvider(jsonProvider); + providers.addProvider(new AzureOpenTelemetryTraceMdcJsonProvider()); + return true; + } + if (jsonProvider instanceof AbstractNestedJsonProvider) { + JsonProviders nested = ((AbstractNestedJsonProvider) jsonProvider).getProviders(); + if (replaceMdcProvider(nested)) { + return true; + } + } + } + return false; + } + } diff --git a/azure/logback-logstash-encoder-azure/src/main/java/no/entur/logging/cloud/azure/logback/logstash/AzureOpenTelemetryTraceMdcJsonProvider.java b/azure/logback-logstash-encoder-azure/src/main/java/no/entur/logging/cloud/azure/logback/logstash/AzureOpenTelemetryTraceMdcJsonProvider.java new file mode 100644 index 00000000..a602327a --- /dev/null +++ b/azure/logback-logstash-encoder-azure/src/main/java/no/entur/logging/cloud/azure/logback/logstash/AzureOpenTelemetryTraceMdcJsonProvider.java @@ -0,0 +1,69 @@ +package no.entur.logging.cloud.azure.logback.logstash; + +import ch.qos.logback.classic.spi.ILoggingEvent; +import net.logstash.logback.composite.AbstractJsonProvider; +import tools.jackson.core.JsonGenerator; + +import java.lang.management.ManagementFactory; +import java.util.List; +import java.util.Map; + +/** + * An MDC provider that maps OpenTelemetry trace fields to the special JSON fields + * recognized by Azure: https://docs.azure.cn/en-us/spring-apps/basic-standard/structured-app-log + * + */ +public class AzureOpenTelemetryTraceMdcJsonProvider extends AbstractJsonProvider { + + public static final String OPENTELEMETRY_TRACE_ID_KEY = "trace_id"; + public static final String OPENTELEMETRY_SPAN_ID_KEY = "span_id"; + + public static final String AZURE_TRACE_KEY = "traceId"; + public static final String AZURE_SPAN_ID_KEY = "spanId"; + + @Override + public void writeTo(JsonGenerator generator, ILoggingEvent event) { + Map mdcProperties = event.getMDCPropertyMap(); + if (mdcProperties == null || mdcProperties.isEmpty()) { + return; + } + + // map OTel MDC keys to Azure trace fields; write all others as-is. + for (Map.Entry entry : mdcProperties.entrySet()) { + String key = entry.getKey(); + if (key == null) continue; + String value = entry.getValue(); + if (value == null) continue; + + switch (key) { + case OPENTELEMETRY_TRACE_ID_KEY -> generator.writeStringProperty(AZURE_TRACE_KEY, value); + case OPENTELEMETRY_SPAN_ID_KEY -> generator.writeStringProperty(AZURE_SPAN_ID_KEY, value); + default -> generator.writeStringProperty(key, value); + } + } + } + + public static boolean isOtelAgent() { + // 1. Check direct JVM command-line arguments (-javaagent) + List jvmArgs = ManagementFactory.getRuntimeMXBean().getInputArguments(); + for (String arg : jvmArgs) { + if (isOtelArgument(arg)) { + return true; + } + } + + // 2. Backup check for environment variables that inject JVM arguments + String javaToolOptions = System.getenv("JAVA_TOOL_OPTIONS"); + if (javaToolOptions != null && isOtelArgument(javaToolOptions)) { + return true; + } + + return false; + } + + private static boolean isOtelArgument(String argument) { + String lowerArg = argument.toLowerCase(); + return lowerArg.contains("-javaagent:") && lowerArg.contains("opentelemetry"); + } + +} diff --git a/azure/logback-logstash-encoder-azure/src/test/java/no/entur/logging/cloud/azure/logback/logstash/AzureOpenTelemetryTraceMdcJsonProviderTest.java b/azure/logback-logstash-encoder-azure/src/test/java/no/entur/logging/cloud/azure/logback/logstash/AzureOpenTelemetryTraceMdcJsonProviderTest.java new file mode 100644 index 00000000..52facdc6 --- /dev/null +++ b/azure/logback-logstash-encoder-azure/src/test/java/no/entur/logging/cloud/azure/logback/logstash/AzureOpenTelemetryTraceMdcJsonProviderTest.java @@ -0,0 +1,106 @@ +package no.entur.logging.cloud.azure.logback.logstash; + +import ch.qos.logback.classic.spi.ILoggingEvent; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import tools.jackson.core.JsonGenerator; +import tools.jackson.core.json.JsonFactory; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.json.JsonMapper; + +import java.io.StringWriter; +import java.util.LinkedHashMap; +import java.util.Map; + +import static com.google.common.truth.Truth.assertThat; + +public class AzureOpenTelemetryTraceMdcJsonProviderTest { + + private static final JsonMapper MAPPER = JsonMapper.builder().build(); + + @Test + void writeTo_openTelemetryTraceFields_mappedToAzureFields() throws Exception { + Map mdc = new LinkedHashMap<>(); + mdc.put(AzureOpenTelemetryTraceMdcJsonProvider.OPENTELEMETRY_TRACE_ID_KEY, "06796866738c859f2f19b7cfb3214824"); + mdc.put(AzureOpenTelemetryTraceMdcJsonProvider.OPENTELEMETRY_SPAN_ID_KEY, "000000000000004a"); + + JsonNode root = write(mdc); + + assertThat(root.get(AzureOpenTelemetryTraceMdcJsonProvider.AZURE_TRACE_KEY).asText()) + .isEqualTo("06796866738c859f2f19b7cfb3214824"); + assertThat(root.get(AzureOpenTelemetryTraceMdcJsonProvider.AZURE_SPAN_ID_KEY).asText()) + .isEqualTo("000000000000004a"); + assertThat(root.has(AzureOpenTelemetryTraceMdcJsonProvider.OPENTELEMETRY_TRACE_ID_KEY)).isFalse(); + assertThat(root.has(AzureOpenTelemetryTraceMdcJsonProvider.OPENTELEMETRY_SPAN_ID_KEY)).isFalse(); + } + + @Test + void writeTo_unrelatedMdcFields_preserved() throws Exception { + Map mdc = new LinkedHashMap<>(); + mdc.put(AzureOpenTelemetryTraceMdcJsonProvider.OPENTELEMETRY_TRACE_ID_KEY, "abc"); + mdc.put("correlationId", "xyz123"); + mdc.put("userId", "user42"); + + JsonNode root = write(mdc); + + assertThat(root.get("correlationId").asText()).isEqualTo("xyz123"); + assertThat(root.get("userId").asText()).isEqualTo("user42"); + } + + @Test + void writeTo_collisionBetweenOtelAndAzureKey_otelKeyMappedFirst() throws Exception { + // When MDC contains both the OTel key (trace_id) and the Azure target key (traceId), + // the OTel key is translated to traceId and appears in the output. + Map mdc = new LinkedHashMap<>(); + mdc.put(AzureOpenTelemetryTraceMdcJsonProvider.OPENTELEMETRY_TRACE_ID_KEY, "otel-trace-value"); + mdc.put(AzureOpenTelemetryTraceMdcJsonProvider.AZURE_TRACE_KEY, "existing-azure-trace"); + + JsonNode root = write(mdc); + + // The OTel trace_id is mapped to traceId; readTree retains the last value on duplicate keys. + assertThat(root.get(AzureOpenTelemetryTraceMdcJsonProvider.AZURE_TRACE_KEY)).isNotNull(); + assertThat(root.has(AzureOpenTelemetryTraceMdcJsonProvider.OPENTELEMETRY_TRACE_ID_KEY)).isFalse(); + } + + @Test + void writeTo_emptyMdc_writesNothing() throws Exception { + JsonNode root = write(new LinkedHashMap<>()); + assertThat(root.size()).isEqualTo(0); + } + + @Test + void writeTo_nullMdc_writesNothing() throws Exception { + AzureOpenTelemetryTraceMdcJsonProvider provider = new AzureOpenTelemetryTraceMdcJsonProvider(); + ILoggingEvent event = Mockito.mock(ILoggingEvent.class); + Mockito.when(event.getMDCPropertyMap()).thenReturn(null); + + StringWriter stringWriter = new StringWriter(); + JsonFactory factory = new JsonFactory(); + try (JsonGenerator generator = factory.createGenerator(stringWriter)) { + generator.writeStartObject(); + provider.writeTo(generator, event); + generator.writeEndObject(); + } + JsonNode root = MAPPER.readTree(stringWriter.toString()); + assertThat(root.size()).isEqualTo(0); + } + + private static JsonNode write(Map mdcMap) throws Exception { + return MAPPER.readTree(writeRaw(mdcMap)); + } + + private static String writeRaw(Map mdcMap) throws Exception { + AzureOpenTelemetryTraceMdcJsonProvider provider = new AzureOpenTelemetryTraceMdcJsonProvider(); + ILoggingEvent event = Mockito.mock(ILoggingEvent.class); + Mockito.when(event.getMDCPropertyMap()).thenReturn(mdcMap); + + StringWriter stringWriter = new StringWriter(); + JsonFactory factory = new JsonFactory(); + try (JsonGenerator generator = factory.createGenerator(stringWriter)) { + generator.writeStartObject(); + provider.writeTo(generator, event); + generator.writeEndObject(); + } + return stringWriter.toString(); + } +} diff --git a/azure/spring-boot-autoconfigure-azure-test/src/main/resources/logback/spring-defaults-test.xml b/azure/spring-boot-autoconfigure-azure-test/src/main/resources/logback/spring-defaults-test.xml index 6e8ca452..7dea13c0 100644 --- a/azure/spring-boot-autoconfigure-azure-test/src/main/resources/logback/spring-defaults-test.xml +++ b/azure/spring-boot-autoconfigure-azure-test/src/main/resources/logback/spring-defaults-test.xml @@ -12,7 +12,7 @@ Default logback configuration provided for import by spring, modified to give me - + diff --git a/build.gradle b/build.gradle index 99f96edd..3ab0f075 100644 --- a/build.gradle +++ b/build.gradle @@ -87,7 +87,7 @@ configure(libraryProjects()) { } configure(jvmProjects()) { - apply plugin: 'com.github.ben-manes.versions' + apply plugin: 'io.github.ben-manes.versions' test { useJUnitPlatform { diff --git a/examples/gcp-grpc-spring-example/src/main/java/no/entur/grpc/example/GreetingController.java b/examples/gcp-grpc-spring-example/src/main/java/no/entur/grpc/example/GreetingController.java index 6dea440c..7f22b8e8 100644 --- a/examples/gcp-grpc-spring-example/src/main/java/no/entur/grpc/example/GreetingController.java +++ b/examples/gcp-grpc-spring-example/src/main/java/no/entur/grpc/example/GreetingController.java @@ -1,7 +1,6 @@ package no.entur.grpc.example; -import no.entur.logging.cloud.gcp.trace.spring.grpc.interceptor.OrderedTraceIdGrpcMdcContextServerInterceptor; import no.entur.logging.cloud.spring.rr.grpc.OrderedGrpcLoggingServerInterceptor; import no.entur.logging.cloud.spring.rr.grpc.RequestResponseGrpcExceptionHandlerInterceptor; import no.entur.logging.cloud.trace.spring.grpc.interceptor.OrderedCorrelationIdGrpcMdcContextServerInterceptor; @@ -12,7 +11,6 @@ @GrpcService(interceptors = { // Trace OrderedCorrelationIdGrpcMdcContextServerInterceptor.class, // add trace headers (correlation-id and such) - OrderedTraceIdGrpcMdcContextServerInterceptor.class, // add trace headers (correlation-id and such) // logging OrderedGrpcLoggingServerInterceptor.class, diff --git a/examples/gcp-grpc-spring-example/src/main/java/no/entur/grpc/example/GreetingControllerWithOnDemandLogging.java b/examples/gcp-grpc-spring-example/src/main/java/no/entur/grpc/example/GreetingControllerWithOnDemandLogging.java index 4e3404ff..db615e81 100644 --- a/examples/gcp-grpc-spring-example/src/main/java/no/entur/grpc/example/GreetingControllerWithOnDemandLogging.java +++ b/examples/gcp-grpc-spring-example/src/main/java/no/entur/grpc/example/GreetingControllerWithOnDemandLogging.java @@ -1,7 +1,6 @@ package no.entur.grpc.example; -import no.entur.logging.cloud.gcp.trace.spring.grpc.interceptor.OrderedTraceIdGrpcMdcContextServerInterceptor; import no.entur.logging.cloud.spring.ondemand.grpc.scope.GrpcLoggingScopeContextInterceptor; import no.entur.logging.cloud.spring.rr.grpc.OrderedGrpcLoggingServerInterceptor; import no.entur.logging.cloud.spring.rr.grpc.RequestResponseGrpcExceptionHandlerInterceptor; @@ -14,7 +13,6 @@ GrpcLoggingScopeContextInterceptor.class, // Trace OrderedCorrelationIdGrpcMdcContextServerInterceptor.class, // add trace headers (correlation-id and such) - OrderedTraceIdGrpcMdcContextServerInterceptor.class, // add trace headers (correlation-id and such) // logging OrderedGrpcLoggingServerInterceptor.class, diff --git a/examples/gcp-grpc-spring-otel-agent-example/README.md b/examples/gcp-grpc-spring-otel-agent-example/README.md new file mode 100644 index 00000000..5eae9843 --- /dev/null +++ b/examples/gcp-grpc-spring-otel-agent-example/README.md @@ -0,0 +1,4 @@ +# gcp-grpc-spring-otel-agent-example +Simple GRPC service example with OpenTelemetry Java agent. + +This emulates the deployed application (i.e. machine-readable JSON). diff --git a/examples/gcp-grpc-spring-otel-agent-example/build.gradle b/examples/gcp-grpc-spring-otel-agent-example/build.gradle new file mode 100644 index 00000000..4f04ddc1 --- /dev/null +++ b/examples/gcp-grpc-spring-otel-agent-example/build.gradle @@ -0,0 +1,93 @@ +plugins { + id 'org.springframework.boot' version '4.1.0' + id "com.google.protobuf" version "0.10.0" +} + +configurations { + otelAgent +} + +test { + useJUnitPlatform { + includeEngines 'junit-jupiter' + } + + doFirst { + jvmArgs "-javaagent:${configurations.otelAgent.singleFile}" + } + + systemProperty 'otel.traces.exporter', 'logging' + systemProperty 'otel.metrics.exporter', 'none' + systemProperty 'otel.logs.exporter', 'none' + systemProperty 'otel.service.name', 'junit5-tests' + + systemProperty 'otel.instrumentation.http.server.exclude-paths', '/actuator/**' +} + +dependencies { + otelAgent "io.opentelemetry.javaagent:opentelemetry-javaagent:2.30.0" + + implementation project(':on-demand:on-demand-spring-boot-starter-grpc') + implementation project(':gcp:spring-boot-starter-gcp-grpc-spring') + implementation project(':gcp:request-response-spring-boot-starter-gcp-grpc-spring') + implementation project(':trace:server:correlation-id-trace-grpc-netty') + implementation project(':request-response:request-response-spring-boot-autoconfigure-grpc-spring') + + implementation project(':trace:mdc-context-grpc-netty') + + implementation("io.grpc:grpc-api:$grpcVersion") + implementation("io.grpc:grpc-core:$grpcVersion") + implementation("io.grpc:grpc-context:$grpcVersion") + implementation("io.grpc:grpc-stub:$grpcVersion") + //implementation("io.grpc:grpc-inprocess:$grpcVersion") + implementation("io.grpc:grpc-services:$grpcVersion") + implementation("io.grpc:grpc-netty:$grpcVersion") + implementation("io.grpc:grpc-util:$grpcVersion") + implementation("org.springframework.boot:spring-boot-starter") + + testImplementation project(":gcp:spring-boot-starter-gcp-grpc-spring-test") + testImplementation project(':gcp:request-response-spring-boot-starter-gcp-grpc-spring-test') + testImplementation("org.springframework.boot:spring-boot-starter-test") + + // added due to grpc plugin dependency resolution problem + testImplementation "org.junit.platform:junit-platform-launcher" + + testImplementation ("com.google.truth:truth:${googleTruthVersion}") + testImplementation ("com.google.truth.extensions:truth-java8-extension:${googleTruthVersion}") + + testImplementation project(":test:test-logback-junit") +} + +sourceSets { + main { + java { + srcDirs '../gcp-grpc-spring-without-test-artifacts-example/src/main/java' + } + proto { + srcDirs '../gcp-grpc-spring-without-test-artifacts-example/src/main/proto' + } + resources { + srcDirs '../gcp-grpc-spring-without-test-artifacts-example/src/main/resources' + } + } + test.java.srcDirs += "${protobuf.generatedFilesBaseDir}/test/java" + test.java.srcDirs += "${protobuf.generatedFilesBaseDir}/test/grpc" +} + +tasks.compileTestJava { dependsOn("generateTestProto") } + +protobuf { + protoc { + artifact = "com.google.protobuf:protoc:${grpcProtobufVersion}" + } + plugins { + grpc { + artifact = "io.grpc:protoc-gen-grpc-java:$grpcVersion" + } + } + generateProtoTasks { + all()*.plugins { + grpc {} + } + } +} diff --git a/examples/gcp-grpc-spring-otel-agent-example/src/test/java/no/entur/grpc/example/otel/agent/ProviderSelectionTest.java b/examples/gcp-grpc-spring-otel-agent-example/src/test/java/no/entur/grpc/example/otel/agent/ProviderSelectionTest.java new file mode 100644 index 00000000..6fb30413 --- /dev/null +++ b/examples/gcp-grpc-spring-otel-agent-example/src/test/java/no/entur/grpc/example/otel/agent/ProviderSelectionTest.java @@ -0,0 +1,72 @@ +package no.entur.grpc.example.otel.agent; + +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.LoggerContext; +import ch.qos.logback.core.Appender; +import ch.qos.logback.core.ConsoleAppender; +import ch.qos.logback.core.encoder.Encoder; +import ch.qos.logback.core.spi.AppenderAttachable; +import no.entur.logging.cloud.gcp.logback.logstash.StackdriverLogstashEncoder; +import no.entur.logging.cloud.gcp.logback.logstash.StackdriverMicrometerTraceMdcJsonProvider; +import no.entur.logging.cloud.gcp.logback.logstash.StackdriverOpenTelemetryTraceMdcJsonProvider; +import no.entur.logging.cloud.logback.logstash.test.CompositeConsoleAppender; +import org.junit.jupiter.api.Test; +import org.slf4j.LoggerFactory; +import org.springframework.boot.test.context.SpringBootTest; + +import java.util.Iterator; + +import static com.google.common.truth.Truth.assertThat; + +/** + * Verifies that the {@link StackdriverLogstashEncoder} selects + * {@link StackdriverOpenTelemetryTraceMdcJsonProvider} when the OpenTelemetry Java agent is + * attached (as configured in this module's build.gradle via {@code -javaagent}). + */ +@SpringBootTest +public class ProviderSelectionTest { + + @Test + public void encoderUsesOpenTelemetryTraceMdcJsonProvider() { + StackdriverLogstashEncoder encoder = findEncoder(); + assertThat(encoder).isNotNull(); + + boolean hasOtel = encoder.getProviders().getProviders().stream().anyMatch(p -> p instanceof StackdriverOpenTelemetryTraceMdcJsonProvider); + boolean hasMicrometer = encoder.getProviders().getProviders().stream().anyMatch(p -> p instanceof StackdriverMicrometerTraceMdcJsonProvider); + + assertThat(hasOtel).isTrue(); + assertThat(hasMicrometer).isFalse(); + } + + private static StackdriverLogstashEncoder findEncoder() { + LoggerContext ctx = (LoggerContext) LoggerFactory.getILoggerFactory(); + Logger root = ctx.getLogger(org.slf4j.Logger.ROOT_LOGGER_NAME); + return searchForEncoder(root); + } + + @SuppressWarnings("unchecked") + private static StackdriverLogstashEncoder searchForEncoder(AppenderAttachable attachable) { + Iterator> iter = (Iterator) attachable.iteratorForAppenders(); + while (iter.hasNext()) { + Appender appender = iter.next(); + if (appender instanceof CompositeConsoleAppender composite) { + // Test appender: the machine-readable encoder is the StackdriverLogstashEncoder + Encoder enc = composite.getMachineReadableJsonEncoder(); + if (enc instanceof StackdriverLogstashEncoder stackdriverEncoder) { + return stackdriverEncoder; + } + } else if (appender instanceof ConsoleAppender consoleAppender) { + if (consoleAppender.getEncoder() instanceof StackdriverLogstashEncoder enc) { + return enc; + } + } + if (appender instanceof AppenderAttachable nested) { + StackdriverLogstashEncoder result = searchForEncoder(nested); + if (result != null) { + return result; + } + } + } + return null; + } +} diff --git a/examples/gcp-grpc-spring-otel-agent-example/src/test/java/no/entur/grpc/example/otel/agent/SpringAbstractGrpcTest.java b/examples/gcp-grpc-spring-otel-agent-example/src/test/java/no/entur/grpc/example/otel/agent/SpringAbstractGrpcTest.java new file mode 100644 index 00000000..6fcd2ba6 --- /dev/null +++ b/examples/gcp-grpc-spring-otel-agent-example/src/test/java/no/entur/grpc/example/otel/agent/SpringAbstractGrpcTest.java @@ -0,0 +1,93 @@ +package no.entur.grpc.example.otel.agent; + +import io.grpc.ManagedChannel; +import io.grpc.ManagedChannelBuilder; + +import no.entur.logging.cloud.gcp.logback.logstash.StackdriverMicrometerTraceMdcJsonProvider; +import no.entur.logging.cloud.logback.logstash.test.junit.LogStatement; +import no.entur.logging.cloud.logback.logstash.test.junit.LogStatements; +import org.entur.grpc.example.GreetingRequest; +import org.entur.grpc.example.GreetingServiceGrpc; +import org.junit.jupiter.api.Assertions; +import org.springframework.beans.factory.annotation.Value; + +import java.util.concurrent.TimeUnit; + +import static com.google.common.truth.Truth.assertThat; + +public class SpringAbstractGrpcTest { + + // https://github.com/olivere/grpc-demo/blob/master/java-client/src/main/java/com/altf4/grpc/client/ExampleClient.java + public static final int MAX_INBOUND_MESSAGE_SIZE = 1 << 20; + public static final int MAX_OUTBOUND_MESSAGE_SIZE = 1 << 20; + + protected GreetingRequest greetingRequest = GreetingRequest.newBuilder().build(); + + @Value("${grpc.server.port:9090}") + protected int port; + + protected final int maxOutboundMessageSize; + protected final int maxInboundMessageSize; + + public SpringAbstractGrpcTest() { + this(MAX_INBOUND_MESSAGE_SIZE, MAX_OUTBOUND_MESSAGE_SIZE); + } + + public SpringAbstractGrpcTest(int maxInboundMessageSize, int maxOutboundMessageSize) { + this.maxInboundMessageSize = maxInboundMessageSize; + this.maxOutboundMessageSize = maxOutboundMessageSize; + } + + protected GreetingServiceGrpc.GreetingServiceBlockingStub stub() { + ManagedChannel managedChannel = ManagedChannelBuilder.forAddress("localhost", port).usePlaintext().build(); + GreetingServiceGrpc.GreetingServiceBlockingStub greetingService = GreetingServiceGrpc.newBlockingStub(managedChannel); + return greetingService; + } + + protected void shutdown(GreetingServiceGrpc.GreetingServiceBlockingStub stub) throws InterruptedException { + ManagedChannel m = (ManagedChannel)stub.getChannel(); + m.shutdown(); + m.awaitTermination(15, TimeUnit.SECONDS); + } + + protected GreetingServiceGrpc.GreetingServiceFutureStub futureStub() { + ManagedChannel managedChannel = ManagedChannelBuilder.forAddress("localhost", port).usePlaintext().build(); + return GreetingServiceGrpc.newFutureStub(managedChannel); + } + + protected static void shutdown(GreetingServiceGrpc.GreetingServiceFutureStub stub) throws InterruptedException { + ManagedChannel m = (ManagedChannel)stub.getChannel(); + m.shutdown(); + m.awaitTermination(15, TimeUnit.SECONDS); + } + + protected void shutdown(GreetingServiceGrpc.GreetingServiceStub stub) throws InterruptedException { + ManagedChannel m = (ManagedChannel)stub.getChannel(); + m.shutdown(); + m.awaitTermination(15, TimeUnit.SECONDS); + } + + + protected GreetingServiceGrpc.GreetingServiceStub async() { + ManagedChannel managedChannel = ManagedChannelBuilder.forAddress("localhost", port).usePlaintext().build(); + GreetingServiceGrpc.GreetingServiceStub greetingService = GreetingServiceGrpc.newStub(managedChannel) + .withMaxInboundMessageSize(maxInboundMessageSize) + .withMaxOutboundMessageSize(maxOutboundMessageSize); + return greetingService; + } + + + public static void assertGcpTrace(LogStatements statements) { + // Wait a bit to ensure that the logs have been flushed and captured + try { + Thread.sleep(100); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + Assertions.assertFalse(statements.isEmpty(), "Expected log statements to be captured, but none were found."); + for (LogStatement statement : statements) { + assertThat(statement.getJsonPropertyString(StackdriverMicrometerTraceMdcJsonProvider.GCP_TRACE_KEY)).hasLength(32); + assertThat(statement.getJsonPropertyString(StackdriverMicrometerTraceMdcJsonProvider.GCP_SPAN_ID_KEY)).hasLength(16); + } + } +} diff --git a/examples/gcp-grpc-spring-otel-agent-example/src/test/java/no/entur/grpc/example/otel/agent/SpringGrpcLoggingFormatTest.java b/examples/gcp-grpc-spring-otel-agent-example/src/test/java/no/entur/grpc/example/otel/agent/SpringGrpcLoggingFormatTest.java new file mode 100644 index 00000000..8c4fe670 --- /dev/null +++ b/examples/gcp-grpc-spring-otel-agent-example/src/test/java/no/entur/grpc/example/otel/agent/SpringGrpcLoggingFormatTest.java @@ -0,0 +1,31 @@ +package no.entur.grpc.example.otel.agent; + +import no.entur.logging.cloud.logback.logstash.test.junit.CaptureLogStatements; +import no.entur.logging.cloud.logback.logstash.test.junit.LogStatements; +import org.entur.grpc.example.GreetingResponse; +import org.entur.grpc.example.GreetingServiceGrpc; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.annotation.DirtiesContext; + +import static com.google.common.truth.Truth.assertThat; + +@SpringBootTest +@DirtiesContext +@CaptureLogStatements({"no.entur", "org.entur"}) +public class SpringGrpcLoggingFormatTest extends SpringAbstractGrpcTest { + + @Test + public void useMachineReadableJsonEncoder(LogStatements logStatements) throws InterruptedException { + GreetingServiceGrpc.GreetingServiceBlockingStub stub = stub(); + try { + GreetingResponse response = stub.greeting1(greetingRequest); + assertThat(response.getMessage()).isEqualTo("Hello"); + + assertGcpTrace(logStatements); + } finally { + shutdown(stub); + } + } + +} \ No newline at end of file diff --git a/examples/gcp-grpc-spring-otel-agent-example/src/test/java/no/entur/grpc/example/otel/agent/SpringOndemandGrpcLoggingHighLogLevelTest.java b/examples/gcp-grpc-spring-otel-agent-example/src/test/java/no/entur/grpc/example/otel/agent/SpringOndemandGrpcLoggingHighLogLevelTest.java new file mode 100644 index 00000000..62368756 --- /dev/null +++ b/examples/gcp-grpc-spring-otel-agent-example/src/test/java/no/entur/grpc/example/otel/agent/SpringOndemandGrpcLoggingHighLogLevelTest.java @@ -0,0 +1,44 @@ +package no.entur.grpc.example.otel.agent; + +import no.entur.logging.cloud.logback.logstash.test.junit.CaptureLogStatements; +import no.entur.logging.cloud.logback.logstash.test.junit.LogStatements; +import org.entur.grpc.example.GreetingResponse; +import org.entur.grpc.example.GreetingServiceGrpc; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.TestPropertySource; + +import static com.google.common.truth.Truth.assertThat; + +/** + * + * Test additional logging due to a log statement with high log level. + * + */ + +@SpringBootTest +@ActiveProfiles("ondemand") +@TestPropertySource(properties = { + "entur.logging.grpc.ondemand.enabled=true", + "entur.logging.http.ondemand.failure.logger.level=error", +}) +@DirtiesContext +@CaptureLogStatements({"no.entur", "org.entur"}) +public class SpringOndemandGrpcLoggingHighLogLevelTest extends SpringAbstractGrpcTest { + + @Test + public void useMachineReadableJsonEncoderExpectFullLogging(LogStatements logStatements) throws InterruptedException { + GreetingServiceGrpc.GreetingServiceBlockingStub stub = stub(); + try { + GreetingResponse response = stub.greeting1(greetingRequest); + assertThat(response.getMessage()).isEqualTo("Hello"); + + assertGcpTrace(logStatements); + } finally { + shutdown(stub); + } + } + +} \ No newline at end of file diff --git a/examples/gcp-grpc-spring-otel-starter-example/README.md b/examples/gcp-grpc-spring-otel-starter-example/README.md new file mode 100644 index 00000000..7bb80475 --- /dev/null +++ b/examples/gcp-grpc-spring-otel-starter-example/README.md @@ -0,0 +1,4 @@ +# gcp-grpc-spring-otel-starter-example +Simple GRPC service example with OpenTelemetry Spring Boot starter. + +This emulates the deployed application (i.e. machine-readable JSON). diff --git a/examples/gcp-grpc-spring-otel-starter-example/build.gradle b/examples/gcp-grpc-spring-otel-starter-example/build.gradle new file mode 100644 index 00000000..da839070 --- /dev/null +++ b/examples/gcp-grpc-spring-otel-starter-example/build.gradle @@ -0,0 +1,77 @@ +plugins { + id 'org.springframework.boot' version '4.1.0' + id "com.google.protobuf" version "0.10.0" +} + +test { + useJUnitPlatform { + includeEngines 'junit-jupiter' + } +} + +dependencies { + implementation project(':on-demand:on-demand-spring-boot-starter-grpc') + implementation project(':gcp:spring-boot-starter-gcp-grpc-spring') + implementation project(':gcp:request-response-spring-boot-starter-gcp-grpc-spring') + implementation project(':trace:server:correlation-id-trace-grpc-netty') + implementation project(':request-response:request-response-spring-boot-autoconfigure-grpc-spring') + + implementation project(':trace:mdc-context-grpc-netty') + implementation "org.springframework.boot:spring-boot-starter-opentelemetry" + + implementation("io.grpc:grpc-api:$grpcVersion") + implementation("io.grpc:grpc-core:$grpcVersion") + implementation("io.grpc:grpc-context:$grpcVersion") + implementation("io.grpc:grpc-stub:$grpcVersion") + //implementation("io.grpc:grpc-inprocess:$grpcVersion") + implementation("io.grpc:grpc-services:$grpcVersion") + implementation("io.grpc:grpc-netty:$grpcVersion") + implementation("io.grpc:grpc-util:$grpcVersion") + implementation("org.springframework.boot:spring-boot-starter") + + testImplementation project(":gcp:spring-boot-starter-gcp-grpc-spring-test") + testImplementation project(':gcp:request-response-spring-boot-starter-gcp-grpc-spring-test') + testImplementation("org.springframework.boot:spring-boot-starter-test") + + // added due to grpc plugin dependency resolution problem + testImplementation "org.junit.platform:junit-platform-launcher" + + testImplementation ("com.google.truth:truth:${googleTruthVersion}") + testImplementation ("com.google.truth.extensions:truth-java8-extension:${googleTruthVersion}") + + testImplementation project(":test:test-logback-junit") +} + +sourceSets { + main { + java { + srcDirs '../gcp-grpc-spring-without-test-artifacts-example/src/main/java' + } + proto { + srcDirs '../gcp-grpc-spring-without-test-artifacts-example/src/main/proto' + } + resources { + srcDirs '../gcp-grpc-spring-without-test-artifacts-example/src/main/resources' + } + } + test.java.srcDirs += "${protobuf.generatedFilesBaseDir}/test/java" + test.java.srcDirs += "${protobuf.generatedFilesBaseDir}/test/grpc" +} + +tasks.compileTestJava { dependsOn("generateTestProto") } + +protobuf { + protoc { + artifact = "com.google.protobuf:protoc:${grpcProtobufVersion}" + } + plugins { + grpc { + artifact = "io.grpc:protoc-gen-grpc-java:$grpcVersion" + } + } + generateProtoTasks { + all()*.plugins { + grpc {} + } + } +} diff --git a/examples/gcp-grpc-spring-otel-starter-example/src/test/java/no/entur/grpc/example/otel/starter/ProviderSelectionTest.java b/examples/gcp-grpc-spring-otel-starter-example/src/test/java/no/entur/grpc/example/otel/starter/ProviderSelectionTest.java new file mode 100644 index 00000000..e3afddc6 --- /dev/null +++ b/examples/gcp-grpc-spring-otel-starter-example/src/test/java/no/entur/grpc/example/otel/starter/ProviderSelectionTest.java @@ -0,0 +1,72 @@ +package no.entur.grpc.example.otel.starter; + +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.LoggerContext; +import ch.qos.logback.core.Appender; +import ch.qos.logback.core.ConsoleAppender; +import ch.qos.logback.core.encoder.Encoder; +import ch.qos.logback.core.spi.AppenderAttachable; +import no.entur.logging.cloud.gcp.logback.logstash.StackdriverLogstashEncoder; +import no.entur.logging.cloud.gcp.logback.logstash.StackdriverMicrometerTraceMdcJsonProvider; +import no.entur.logging.cloud.gcp.logback.logstash.StackdriverOpenTelemetryTraceMdcJsonProvider; +import no.entur.logging.cloud.logback.logstash.test.CompositeConsoleAppender; +import org.junit.jupiter.api.Test; +import org.slf4j.LoggerFactory; +import org.springframework.boot.test.context.SpringBootTest; + +import java.util.Iterator; + +import static com.google.common.truth.Truth.assertThat; + +/** + * Verifies that the {@link StackdriverLogstashEncoder} selects + * {@link StackdriverMicrometerTraceMdcJsonProvider} when using the Spring Boot OpenTelemetry + * starter (no Java agent attached). This is the default when no OTel agent is present. + */ +@SpringBootTest +public class ProviderSelectionTest { + + @Test + public void encoderUsesMicrometerTraceMdcJsonProvider() { + StackdriverLogstashEncoder encoder = findEncoder(); + assertThat(encoder).isNotNull(); + + boolean hasOtel = encoder.getProviders().getProviders().stream().anyMatch(p -> p instanceof StackdriverOpenTelemetryTraceMdcJsonProvider); + boolean hasMicrometer = encoder.getProviders().getProviders().stream().anyMatch(p -> p instanceof StackdriverMicrometerTraceMdcJsonProvider); + + assertThat(hasMicrometer).isTrue(); + assertThat(hasOtel).isFalse(); + } + + private static StackdriverLogstashEncoder findEncoder() { + LoggerContext ctx = (LoggerContext) LoggerFactory.getILoggerFactory(); + Logger root = ctx.getLogger(org.slf4j.Logger.ROOT_LOGGER_NAME); + return searchForEncoder(root); + } + + @SuppressWarnings("unchecked") + private static StackdriverLogstashEncoder searchForEncoder(AppenderAttachable attachable) { + Iterator> iter = (Iterator) attachable.iteratorForAppenders(); + while (iter.hasNext()) { + Appender appender = iter.next(); + if (appender instanceof CompositeConsoleAppender composite) { + // Test appender: the machine-readable encoder is the StackdriverLogstashEncoder + Encoder enc = composite.getMachineReadableJsonEncoder(); + if (enc instanceof StackdriverLogstashEncoder stackdriverEncoder) { + return stackdriverEncoder; + } + } else if (appender instanceof ConsoleAppender consoleAppender) { + if (consoleAppender.getEncoder() instanceof StackdriverLogstashEncoder enc) { + return enc; + } + } + if (appender instanceof AppenderAttachable nested) { + StackdriverLogstashEncoder result = searchForEncoder(nested); + if (result != null) { + return result; + } + } + } + return null; + } +} diff --git a/examples/gcp-grpc-spring-otel-starter-example/src/test/java/no/entur/grpc/example/otel/starter/SpringAbstractGrpcTest.java b/examples/gcp-grpc-spring-otel-starter-example/src/test/java/no/entur/grpc/example/otel/starter/SpringAbstractGrpcTest.java new file mode 100644 index 00000000..f9c60b5f --- /dev/null +++ b/examples/gcp-grpc-spring-otel-starter-example/src/test/java/no/entur/grpc/example/otel/starter/SpringAbstractGrpcTest.java @@ -0,0 +1,91 @@ +package no.entur.grpc.example.otel.starter; + +import io.grpc.ManagedChannel; +import io.grpc.ManagedChannelBuilder; +import no.entur.logging.cloud.gcp.logback.logstash.StackdriverMicrometerTraceMdcJsonProvider; +import no.entur.logging.cloud.logback.logstash.test.junit.LogStatement; +import no.entur.logging.cloud.logback.logstash.test.junit.LogStatements; +import org.entur.grpc.example.GreetingRequest; +import org.entur.grpc.example.GreetingServiceGrpc; +import org.junit.jupiter.api.Assertions; +import org.springframework.beans.factory.annotation.Value; +import static com.google.common.truth.Truth.assertThat; + +import java.util.concurrent.TimeUnit; + +public class SpringAbstractGrpcTest { + + // https://github.com/olivere/grpc-demo/blob/master/java-client/src/main/java/com/altf4/grpc/client/ExampleClient.java + public static final int MAX_INBOUND_MESSAGE_SIZE = 1 << 20; + public static final int MAX_OUTBOUND_MESSAGE_SIZE = 1 << 20; + + protected GreetingRequest greetingRequest = GreetingRequest.newBuilder().build(); + + @Value("${grpc.server.port:9090}") + protected int port; + + protected final int maxOutboundMessageSize; + protected final int maxInboundMessageSize; + + public SpringAbstractGrpcTest() { + this(MAX_INBOUND_MESSAGE_SIZE, MAX_OUTBOUND_MESSAGE_SIZE); + } + + public SpringAbstractGrpcTest(int maxInboundMessageSize, int maxOutboundMessageSize) { + this.maxInboundMessageSize = maxInboundMessageSize; + this.maxOutboundMessageSize = maxOutboundMessageSize; + } + + protected GreetingServiceGrpc.GreetingServiceBlockingStub stub() { + ManagedChannel managedChannel = ManagedChannelBuilder.forAddress("localhost", port).usePlaintext().build(); + GreetingServiceGrpc.GreetingServiceBlockingStub greetingService = GreetingServiceGrpc.newBlockingStub(managedChannel); + return greetingService; + } + + protected void shutdown(GreetingServiceGrpc.GreetingServiceBlockingStub stub) throws InterruptedException { + ManagedChannel m = (ManagedChannel)stub.getChannel(); + m.shutdown(); + m.awaitTermination(15, TimeUnit.SECONDS); + } + + protected GreetingServiceGrpc.GreetingServiceFutureStub futureStub() { + ManagedChannel managedChannel = ManagedChannelBuilder.forAddress("localhost", port).usePlaintext().build(); + return GreetingServiceGrpc.newFutureStub(managedChannel); + } + + protected static void shutdown(GreetingServiceGrpc.GreetingServiceFutureStub stub) throws InterruptedException { + ManagedChannel m = (ManagedChannel)stub.getChannel(); + m.shutdown(); + m.awaitTermination(15, TimeUnit.SECONDS); + } + + protected void shutdown(GreetingServiceGrpc.GreetingServiceStub stub) throws InterruptedException { + ManagedChannel m = (ManagedChannel)stub.getChannel(); + m.shutdown(); + m.awaitTermination(15, TimeUnit.SECONDS); + } + + + protected GreetingServiceGrpc.GreetingServiceStub async() { + ManagedChannel managedChannel = ManagedChannelBuilder.forAddress("localhost", port).usePlaintext().build(); + GreetingServiceGrpc.GreetingServiceStub greetingService = GreetingServiceGrpc.newStub(managedChannel) + .withMaxInboundMessageSize(maxInboundMessageSize) + .withMaxOutboundMessageSize(maxOutboundMessageSize); + return greetingService; + } + + + public static void assertGcpTrace(LogStatements statements) { + // Wait a bit to ensure that the logs have been flushed and captured + try { + Thread.sleep(100); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + Assertions.assertFalse(statements.isEmpty(), "Expected log statements to be captured, but none were found."); + for (LogStatement statement : statements) { + assertThat(statement.getJsonPropertyString(StackdriverMicrometerTraceMdcJsonProvider.GCP_TRACE_KEY)).hasLength(32); + assertThat(statement.getJsonPropertyString(StackdriverMicrometerTraceMdcJsonProvider.GCP_SPAN_ID_KEY)).hasLength(16); + } + } +} diff --git a/examples/gcp-grpc-spring-otel-starter-example/src/test/java/no/entur/grpc/example/otel/starter/SpringGrpcLoggingFormatTest.java b/examples/gcp-grpc-spring-otel-starter-example/src/test/java/no/entur/grpc/example/otel/starter/SpringGrpcLoggingFormatTest.java new file mode 100644 index 00000000..d969880f --- /dev/null +++ b/examples/gcp-grpc-spring-otel-starter-example/src/test/java/no/entur/grpc/example/otel/starter/SpringGrpcLoggingFormatTest.java @@ -0,0 +1,31 @@ +package no.entur.grpc.example.otel.starter; + +import no.entur.logging.cloud.logback.logstash.test.junit.CaptureLogStatements; +import no.entur.logging.cloud.logback.logstash.test.junit.LogStatements; +import org.entur.grpc.example.GreetingResponse; +import org.entur.grpc.example.GreetingServiceGrpc; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.annotation.DirtiesContext; + +import static com.google.common.truth.Truth.assertThat; + +@SpringBootTest +@DirtiesContext +@CaptureLogStatements({"no.entur", "org.entur"}) +public class SpringGrpcLoggingFormatTest extends SpringAbstractGrpcTest { + + @Test + public void useMachineReadableJsonEncoder(LogStatements logStatements) throws InterruptedException { + GreetingServiceGrpc.GreetingServiceBlockingStub stub = stub(); + try { + GreetingResponse response = stub.greeting1(greetingRequest); + assertThat(response.getMessage()).isEqualTo("Hello"); + + assertGcpTrace(logStatements); + } finally { + shutdown(stub); + } + } + +} \ No newline at end of file diff --git a/examples/gcp-grpc-spring-otel-starter-example/src/test/java/no/entur/grpc/example/otel/starter/SpringOndemandGrpcLoggingHighLogLevelTest.java b/examples/gcp-grpc-spring-otel-starter-example/src/test/java/no/entur/grpc/example/otel/starter/SpringOndemandGrpcLoggingHighLogLevelTest.java new file mode 100644 index 00000000..d4613271 --- /dev/null +++ b/examples/gcp-grpc-spring-otel-starter-example/src/test/java/no/entur/grpc/example/otel/starter/SpringOndemandGrpcLoggingHighLogLevelTest.java @@ -0,0 +1,44 @@ +package no.entur.grpc.example.otel.starter; + +import no.entur.logging.cloud.logback.logstash.test.junit.CaptureLogStatements; +import no.entur.logging.cloud.logback.logstash.test.junit.LogStatements; +import org.entur.grpc.example.GreetingResponse; +import org.entur.grpc.example.GreetingServiceGrpc; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.TestPropertySource; + +import static com.google.common.truth.Truth.assertThat; + +/** + * + * Test additional logging due to a log statement with high log level. + * + */ + +@SpringBootTest +@ActiveProfiles("ondemand") +@TestPropertySource(properties = { + "entur.logging.grpc.ondemand.enabled=true", + "entur.logging.http.ondemand.failure.logger.level=error", +}) +@DirtiesContext +@CaptureLogStatements({"no.entur", "org.entur"}) +public class SpringOndemandGrpcLoggingHighLogLevelTest extends SpringAbstractGrpcTest { + + @Test + public void useMachineReadableJsonEncoderExpectFullLogging(LogStatements logStatements) throws InterruptedException { + GreetingServiceGrpc.GreetingServiceBlockingStub stub = stub(); + try { + GreetingResponse response = stub.greeting1(greetingRequest); + assertThat(response.getMessage()).isEqualTo("Hello"); + + assertGcpTrace(logStatements); + } finally { + shutdown(stub); + } + } + +} \ No newline at end of file diff --git a/examples/gcp-grpc-spring-without-test-artifacts-example/build.gradle b/examples/gcp-grpc-spring-without-test-artifacts-example/build.gradle index c0ee5d55..9590d1f6 100644 --- a/examples/gcp-grpc-spring-without-test-artifacts-example/build.gradle +++ b/examples/gcp-grpc-spring-without-test-artifacts-example/build.gradle @@ -35,6 +35,10 @@ dependencies { testImplementation ("com.google.truth:truth:${googleTruthVersion}") testImplementation ("com.google.truth.extensions:truth-java8-extension:${googleTruthVersion}") + + implementation "org.springframework.boot:spring-boot-starter-opentelemetry" + testImplementation("io.opentelemetry:opentelemetry-sdk-testing") + } sourceSets { diff --git a/examples/gcp-grpc-spring-without-test-artifacts-example/src/main/java/no/entur/grpc/example/GreetingController.java b/examples/gcp-grpc-spring-without-test-artifacts-example/src/main/java/no/entur/grpc/example/GreetingController.java index 6dea440c..7f22b8e8 100644 --- a/examples/gcp-grpc-spring-without-test-artifacts-example/src/main/java/no/entur/grpc/example/GreetingController.java +++ b/examples/gcp-grpc-spring-without-test-artifacts-example/src/main/java/no/entur/grpc/example/GreetingController.java @@ -1,7 +1,6 @@ package no.entur.grpc.example; -import no.entur.logging.cloud.gcp.trace.spring.grpc.interceptor.OrderedTraceIdGrpcMdcContextServerInterceptor; import no.entur.logging.cloud.spring.rr.grpc.OrderedGrpcLoggingServerInterceptor; import no.entur.logging.cloud.spring.rr.grpc.RequestResponseGrpcExceptionHandlerInterceptor; import no.entur.logging.cloud.trace.spring.grpc.interceptor.OrderedCorrelationIdGrpcMdcContextServerInterceptor; @@ -12,7 +11,6 @@ @GrpcService(interceptors = { // Trace OrderedCorrelationIdGrpcMdcContextServerInterceptor.class, // add trace headers (correlation-id and such) - OrderedTraceIdGrpcMdcContextServerInterceptor.class, // add trace headers (correlation-id and such) // logging OrderedGrpcLoggingServerInterceptor.class, diff --git a/examples/gcp-grpc-spring-without-test-artifacts-example/src/main/java/no/entur/grpc/example/GreetingControllerWithOnDemandLogging.java b/examples/gcp-grpc-spring-without-test-artifacts-example/src/main/java/no/entur/grpc/example/GreetingControllerWithOnDemandLogging.java index 4e3404ff..db615e81 100644 --- a/examples/gcp-grpc-spring-without-test-artifacts-example/src/main/java/no/entur/grpc/example/GreetingControllerWithOnDemandLogging.java +++ b/examples/gcp-grpc-spring-without-test-artifacts-example/src/main/java/no/entur/grpc/example/GreetingControllerWithOnDemandLogging.java @@ -1,7 +1,6 @@ package no.entur.grpc.example; -import no.entur.logging.cloud.gcp.trace.spring.grpc.interceptor.OrderedTraceIdGrpcMdcContextServerInterceptor; import no.entur.logging.cloud.spring.ondemand.grpc.scope.GrpcLoggingScopeContextInterceptor; import no.entur.logging.cloud.spring.rr.grpc.OrderedGrpcLoggingServerInterceptor; import no.entur.logging.cloud.spring.rr.grpc.RequestResponseGrpcExceptionHandlerInterceptor; @@ -14,7 +13,6 @@ GrpcLoggingScopeContextInterceptor.class, // Trace OrderedCorrelationIdGrpcMdcContextServerInterceptor.class, // add trace headers (correlation-id and such) - OrderedTraceIdGrpcMdcContextServerInterceptor.class, // add trace headers (correlation-id and such) // logging OrderedGrpcLoggingServerInterceptor.class, diff --git a/examples/gcp-grpc-spring-without-test-artifacts-otel-agent-example/README.md b/examples/gcp-grpc-spring-without-test-artifacts-otel-agent-example/README.md new file mode 100644 index 00000000..30dd9da4 --- /dev/null +++ b/examples/gcp-grpc-spring-without-test-artifacts-otel-agent-example/README.md @@ -0,0 +1,4 @@ +# gcp-grpc-spring-without-test-artifacts-otel-agent-example +Simple GRPC service example without test dependencies from this project. + +This emulates the deployed application (i.e. machine-readable JSON). diff --git a/examples/gcp-grpc-spring-without-test-artifacts-otel-agent-example/build.gradle b/examples/gcp-grpc-spring-without-test-artifacts-otel-agent-example/build.gradle new file mode 100644 index 00000000..2ff42a1e --- /dev/null +++ b/examples/gcp-grpc-spring-without-test-artifacts-otel-agent-example/build.gradle @@ -0,0 +1,91 @@ +plugins { + id 'org.springframework.boot' version '4.1.0' + id "com.google.protobuf" version "0.10.0" +} + +configurations { + otelAgent +} + +test { + useJUnitPlatform { + includeEngines 'junit-jupiter' + } + + doFirst { + jvmArgs "-javaagent:${configurations.otelAgent.singleFile}" + } + + systemProperty 'otel.traces.exporter', 'logging' + systemProperty 'otel.metrics.exporter', 'none' + systemProperty 'otel.logs.exporter', 'none' + systemProperty 'otel.service.name', 'junit5-tests' + + systemProperty 'otel.instrumentation.http.server.exclude-paths', '/actuator/**' +} + +dependencies { + otelAgent "io.opentelemetry.javaagent:opentelemetry-javaagent:2.30.0" + + implementation project(':on-demand:on-demand-spring-boot-starter-grpc') + implementation project(':gcp:spring-boot-starter-gcp-grpc-spring') + implementation project(':gcp:request-response-spring-boot-starter-gcp-grpc-spring') + implementation project(':trace:server:correlation-id-trace-grpc-netty') + implementation project(':request-response:request-response-spring-boot-autoconfigure-grpc-spring') + + implementation project(':trace:mdc-context-grpc-netty') + + implementation("io.grpc:grpc-api:$grpcVersion") + implementation("io.grpc:grpc-core:$grpcVersion") + implementation("io.grpc:grpc-context:$grpcVersion") + implementation("io.grpc:grpc-stub:$grpcVersion") + //implementation("io.grpc:grpc-inprocess:$grpcVersion") + implementation("io.grpc:grpc-services:$grpcVersion") + implementation("io.grpc:grpc-netty:$grpcVersion") + implementation("io.grpc:grpc-util:$grpcVersion") + implementation("org.springframework.boot:spring-boot-starter") + + testImplementation("org.springframework.boot:spring-boot-starter-test") + + // added due to grpc plugin dependency resolution problem + testImplementation "org.junit.platform:junit-platform-launcher" + + testImplementation ("com.google.truth:truth:${googleTruthVersion}") + testImplementation ("com.google.truth.extensions:truth-java8-extension:${googleTruthVersion}") + + testImplementation project(":test:test-logback-junit") +} + +sourceSets { + main { + java { + srcDirs '../gcp-grpc-spring-without-test-artifacts-example/src/main/java' + } + proto { + srcDirs '../gcp-grpc-spring-without-test-artifacts-example/src/main/proto' + } + resources { + srcDirs '../gcp-grpc-spring-without-test-artifacts-example/src/main/resources' + } + } + test.java.srcDirs += "${protobuf.generatedFilesBaseDir}/test/java" + test.java.srcDirs += "${protobuf.generatedFilesBaseDir}/test/grpc" +} + +tasks.compileTestJava { dependsOn("generateTestProto") } + +protobuf { + protoc { + artifact = "com.google.protobuf:protoc:${grpcProtobufVersion}" + } + plugins { + grpc { + artifact = "io.grpc:protoc-gen-grpc-java:$grpcVersion" + } + } + generateProtoTasks { + all()*.plugins { + grpc {} + } + } +} diff --git a/examples/gcp-grpc-spring-without-test-artifacts-otel-agent-example/src/test/java/no/entur/grpc/example/without/test/artifacts/otel/agent/ProviderSelectionTest.java b/examples/gcp-grpc-spring-without-test-artifacts-otel-agent-example/src/test/java/no/entur/grpc/example/without/test/artifacts/otel/agent/ProviderSelectionTest.java new file mode 100644 index 00000000..805ecb19 --- /dev/null +++ b/examples/gcp-grpc-spring-without-test-artifacts-otel-agent-example/src/test/java/no/entur/grpc/example/without/test/artifacts/otel/agent/ProviderSelectionTest.java @@ -0,0 +1,72 @@ +package no.entur.grpc.example.without.test.artifacts.otel.agent; + +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.LoggerContext; +import ch.qos.logback.core.Appender; +import ch.qos.logback.core.ConsoleAppender; +import ch.qos.logback.core.encoder.Encoder; +import ch.qos.logback.core.spi.AppenderAttachable; +import no.entur.logging.cloud.gcp.logback.logstash.StackdriverLogstashEncoder; +import no.entur.logging.cloud.gcp.logback.logstash.StackdriverMicrometerTraceMdcJsonProvider; +import no.entur.logging.cloud.gcp.logback.logstash.StackdriverOpenTelemetryTraceMdcJsonProvider; +import no.entur.logging.cloud.logback.logstash.test.CompositeConsoleAppender; +import org.junit.jupiter.api.Test; +import org.slf4j.LoggerFactory; +import org.springframework.boot.test.context.SpringBootTest; + +import java.util.Iterator; + +import static com.google.common.truth.Truth.assertThat; + +/** + * Verifies that the {@link StackdriverLogstashEncoder} selects + * {@link StackdriverOpenTelemetryTraceMdcJsonProvider} when the OpenTelemetry Java agent is + * attached (as configured in this module's build.gradle via {@code -javaagent}). + */ +@SpringBootTest +public class ProviderSelectionTest { + + @Test + public void encoderUsesOpenTelemetryTraceMdcJsonProvider() { + StackdriverLogstashEncoder encoder = findEncoder(); + assertThat(encoder).isNotNull(); + + boolean hasOtel = encoder.getProviders().getProviders().stream().anyMatch(p -> p instanceof StackdriverOpenTelemetryTraceMdcJsonProvider); + boolean hasMicrometer = encoder.getProviders().getProviders().stream().anyMatch(p -> p instanceof StackdriverMicrometerTraceMdcJsonProvider); + + assertThat(hasOtel).isTrue(); + assertThat(hasMicrometer).isFalse(); + } + + private static StackdriverLogstashEncoder findEncoder() { + LoggerContext ctx = (LoggerContext) LoggerFactory.getILoggerFactory(); + Logger root = ctx.getLogger(org.slf4j.Logger.ROOT_LOGGER_NAME); + return searchForEncoder(root); + } + + @SuppressWarnings("unchecked") + private static StackdriverLogstashEncoder searchForEncoder(AppenderAttachable attachable) { + Iterator> iter = (Iterator) attachable.iteratorForAppenders(); + while (iter.hasNext()) { + Appender appender = iter.next(); + if (appender instanceof CompositeConsoleAppender composite) { + // Test appender: the machine-readable encoder is the StackdriverLogstashEncoder + Encoder enc = composite.getMachineReadableJsonEncoder(); + if (enc instanceof StackdriverLogstashEncoder stackdriverEncoder) { + return stackdriverEncoder; + } + } else if (appender instanceof ConsoleAppender consoleAppender) { + if (consoleAppender.getEncoder() instanceof StackdriverLogstashEncoder enc) { + return enc; + } + } + if (appender instanceof AppenderAttachable nested) { + StackdriverLogstashEncoder result = searchForEncoder(nested); + if (result != null) { + return result; + } + } + } + return null; + } +} diff --git a/examples/gcp-grpc-spring-without-test-artifacts-otel-agent-example/src/test/java/no/entur/grpc/example/without/test/artifacts/otel/agent/SpringAbstractGrpcTest.java b/examples/gcp-grpc-spring-without-test-artifacts-otel-agent-example/src/test/java/no/entur/grpc/example/without/test/artifacts/otel/agent/SpringAbstractGrpcTest.java new file mode 100644 index 00000000..34c3c93b --- /dev/null +++ b/examples/gcp-grpc-spring-without-test-artifacts-otel-agent-example/src/test/java/no/entur/grpc/example/without/test/artifacts/otel/agent/SpringAbstractGrpcTest.java @@ -0,0 +1,93 @@ +package no.entur.grpc.example.without.test.artifacts.otel.agent; + +import io.grpc.ManagedChannel; +import io.grpc.ManagedChannelBuilder; + +import no.entur.logging.cloud.gcp.logback.logstash.StackdriverMicrometerTraceMdcJsonProvider; +import no.entur.logging.cloud.logback.logstash.test.junit.LogStatement; +import no.entur.logging.cloud.logback.logstash.test.junit.LogStatements; +import org.entur.grpc.example.GreetingRequest; +import org.entur.grpc.example.GreetingServiceGrpc; +import org.junit.jupiter.api.Assertions; +import org.springframework.beans.factory.annotation.Value; + +import java.util.concurrent.TimeUnit; + +import static com.google.common.truth.Truth.assertThat; + +public class SpringAbstractGrpcTest { + + // https://github.com/olivere/grpc-demo/blob/master/java-client/src/main/java/com/altf4/grpc/client/ExampleClient.java + public static final int MAX_INBOUND_MESSAGE_SIZE = 1 << 20; + public static final int MAX_OUTBOUND_MESSAGE_SIZE = 1 << 20; + + protected GreetingRequest greetingRequest = GreetingRequest.newBuilder().build(); + + @Value("${grpc.server.port:9090}") + protected int port; + + protected final int maxOutboundMessageSize; + protected final int maxInboundMessageSize; + + public SpringAbstractGrpcTest() { + this(MAX_INBOUND_MESSAGE_SIZE, MAX_OUTBOUND_MESSAGE_SIZE); + } + + public SpringAbstractGrpcTest(int maxInboundMessageSize, int maxOutboundMessageSize) { + this.maxInboundMessageSize = maxInboundMessageSize; + this.maxOutboundMessageSize = maxOutboundMessageSize; + } + + protected GreetingServiceGrpc.GreetingServiceBlockingStub stub() { + ManagedChannel managedChannel = ManagedChannelBuilder.forAddress("localhost", port).usePlaintext().build(); + GreetingServiceGrpc.GreetingServiceBlockingStub greetingService = GreetingServiceGrpc.newBlockingStub(managedChannel); + return greetingService; + } + + protected void shutdown(GreetingServiceGrpc.GreetingServiceBlockingStub stub) throws InterruptedException { + ManagedChannel m = (ManagedChannel)stub.getChannel(); + m.shutdown(); + m.awaitTermination(15, TimeUnit.SECONDS); + } + + protected GreetingServiceGrpc.GreetingServiceFutureStub futureStub() { + ManagedChannel managedChannel = ManagedChannelBuilder.forAddress("localhost", port).usePlaintext().build(); + return GreetingServiceGrpc.newFutureStub(managedChannel); + } + + protected static void shutdown(GreetingServiceGrpc.GreetingServiceFutureStub stub) throws InterruptedException { + ManagedChannel m = (ManagedChannel)stub.getChannel(); + m.shutdown(); + m.awaitTermination(15, TimeUnit.SECONDS); + } + + protected void shutdown(GreetingServiceGrpc.GreetingServiceStub stub) throws InterruptedException { + ManagedChannel m = (ManagedChannel)stub.getChannel(); + m.shutdown(); + m.awaitTermination(15, TimeUnit.SECONDS); + } + + + protected GreetingServiceGrpc.GreetingServiceStub async() { + ManagedChannel managedChannel = ManagedChannelBuilder.forAddress("localhost", port).usePlaintext().build(); + GreetingServiceGrpc.GreetingServiceStub greetingService = GreetingServiceGrpc.newStub(managedChannel) + .withMaxInboundMessageSize(maxInboundMessageSize) + .withMaxOutboundMessageSize(maxOutboundMessageSize); + return greetingService; + } + + + public static void assertGcpTrace(LogStatements statements) { + // Wait a bit to ensure that the logs have been flushed and captured + try { + Thread.sleep(100); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + Assertions.assertFalse(statements.isEmpty(), "Expected log statements to be captured, but none were found."); + for (LogStatement statement : statements) { + assertThat(statement.getJsonPropertyString(StackdriverMicrometerTraceMdcJsonProvider.GCP_TRACE_KEY)).hasLength(32); + assertThat(statement.getJsonPropertyString(StackdriverMicrometerTraceMdcJsonProvider.GCP_SPAN_ID_KEY)).hasLength(16); + } + } +} diff --git a/examples/gcp-grpc-spring-without-test-artifacts-otel-agent-example/src/test/java/no/entur/grpc/example/without/test/artifacts/otel/agent/SpringGrpcLoggingFormatTest.java b/examples/gcp-grpc-spring-without-test-artifacts-otel-agent-example/src/test/java/no/entur/grpc/example/without/test/artifacts/otel/agent/SpringGrpcLoggingFormatTest.java new file mode 100644 index 00000000..85c05d0e --- /dev/null +++ b/examples/gcp-grpc-spring-without-test-artifacts-otel-agent-example/src/test/java/no/entur/grpc/example/without/test/artifacts/otel/agent/SpringGrpcLoggingFormatTest.java @@ -0,0 +1,31 @@ +package no.entur.grpc.example.without.test.artifacts.otel.agent; + +import no.entur.logging.cloud.logback.logstash.test.junit.CaptureLogStatements; +import no.entur.logging.cloud.logback.logstash.test.junit.LogStatements; +import org.entur.grpc.example.GreetingResponse; +import org.entur.grpc.example.GreetingServiceGrpc; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.annotation.DirtiesContext; + +import static com.google.common.truth.Truth.assertThat; + +@SpringBootTest +@DirtiesContext +@CaptureLogStatements({"no.entur", "org.entur"}) +public class SpringGrpcLoggingFormatTest extends SpringAbstractGrpcTest { + + @Test + public void useMachineReadableJsonEncoder(LogStatements logStatements) throws InterruptedException { + GreetingServiceGrpc.GreetingServiceBlockingStub stub = stub(); + try { + GreetingResponse response = stub.greeting1(greetingRequest); + assertThat(response.getMessage()).isEqualTo("Hello"); + + assertGcpTrace(logStatements); + } finally { + shutdown(stub); + } + } + +} \ No newline at end of file diff --git a/examples/gcp-grpc-spring-without-test-artifacts-otel-agent-example/src/test/java/no/entur/grpc/example/without/test/artifacts/otel/agent/SpringOndemandGrpcLoggingHighLogLevelTest.java b/examples/gcp-grpc-spring-without-test-artifacts-otel-agent-example/src/test/java/no/entur/grpc/example/without/test/artifacts/otel/agent/SpringOndemandGrpcLoggingHighLogLevelTest.java new file mode 100644 index 00000000..11cd8b4e --- /dev/null +++ b/examples/gcp-grpc-spring-without-test-artifacts-otel-agent-example/src/test/java/no/entur/grpc/example/without/test/artifacts/otel/agent/SpringOndemandGrpcLoggingHighLogLevelTest.java @@ -0,0 +1,44 @@ +package no.entur.grpc.example.without.test.artifacts.otel.agent; + +import no.entur.logging.cloud.logback.logstash.test.junit.CaptureLogStatements; +import no.entur.logging.cloud.logback.logstash.test.junit.LogStatements; +import org.entur.grpc.example.GreetingResponse; +import org.entur.grpc.example.GreetingServiceGrpc; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.TestPropertySource; + +import static com.google.common.truth.Truth.assertThat; + +/** + * + * Test additional logging due to a log statement with high log level. + * + */ + +@SpringBootTest +@ActiveProfiles("ondemand") +@TestPropertySource(properties = { + "entur.logging.grpc.ondemand.enabled=true", + "entur.logging.http.ondemand.failure.logger.level=error", +}) +@DirtiesContext +@CaptureLogStatements({"no.entur", "org.entur"}) +public class SpringOndemandGrpcLoggingHighLogLevelTest extends SpringAbstractGrpcTest { + + @Test + public void useMachineReadableJsonEncoderExpectFullLogging(LogStatements logStatements) throws InterruptedException { + GreetingServiceGrpc.GreetingServiceBlockingStub stub = stub(); + try { + GreetingResponse response = stub.greeting1(greetingRequest); + assertThat(response.getMessage()).isEqualTo("Hello"); + + assertGcpTrace(logStatements); + } finally { + shutdown(stub); + } + } + +} \ No newline at end of file diff --git a/examples/gcp-grpc-spring-without-test-artifacts-otel-starter-example/README.md b/examples/gcp-grpc-spring-without-test-artifacts-otel-starter-example/README.md new file mode 100644 index 00000000..f332ba05 --- /dev/null +++ b/examples/gcp-grpc-spring-without-test-artifacts-otel-starter-example/README.md @@ -0,0 +1,4 @@ +# gcp-grpc-spring-without-test-artifacts-otel-starter-example +Simple GRPC service example without test dependencies from this project. + +This emulates the deployed application (i.e. machine-readable JSON). diff --git a/examples/gcp-grpc-spring-without-test-artifacts-otel-starter-example/build.gradle b/examples/gcp-grpc-spring-without-test-artifacts-otel-starter-example/build.gradle new file mode 100644 index 00000000..1b2c9546 --- /dev/null +++ b/examples/gcp-grpc-spring-without-test-artifacts-otel-starter-example/build.gradle @@ -0,0 +1,75 @@ +plugins { + id 'org.springframework.boot' version '4.1.0' + id "com.google.protobuf" version "0.10.0" +} + +test { + useJUnitPlatform { + includeEngines 'junit-jupiter' + } +} + +dependencies { + implementation project(':on-demand:on-demand-spring-boot-starter-grpc') + implementation project(':gcp:spring-boot-starter-gcp-grpc-spring') + implementation project(':gcp:request-response-spring-boot-starter-gcp-grpc-spring') + implementation project(':trace:server:correlation-id-trace-grpc-netty') + implementation project(':request-response:request-response-spring-boot-autoconfigure-grpc-spring') + + implementation project(':trace:mdc-context-grpc-netty') + implementation "org.springframework.boot:spring-boot-starter-opentelemetry" + + implementation("io.grpc:grpc-api:$grpcVersion") + implementation("io.grpc:grpc-core:$grpcVersion") + implementation("io.grpc:grpc-context:$grpcVersion") + implementation("io.grpc:grpc-stub:$grpcVersion") + //implementation("io.grpc:grpc-inprocess:$grpcVersion") + implementation("io.grpc:grpc-services:$grpcVersion") + implementation("io.grpc:grpc-netty:$grpcVersion") + implementation("io.grpc:grpc-util:$grpcVersion") + implementation("org.springframework.boot:spring-boot-starter") + + testImplementation("org.springframework.boot:spring-boot-starter-test") + + // added due to grpc plugin dependency resolution problem + testImplementation "org.junit.platform:junit-platform-launcher" + + testImplementation ("com.google.truth:truth:${googleTruthVersion}") + testImplementation ("com.google.truth.extensions:truth-java8-extension:${googleTruthVersion}") + + testImplementation project(":test:test-logback-junit") +} + +sourceSets { + main { + java { + srcDirs '../gcp-grpc-spring-without-test-artifacts-example/src/main/java' + } + proto { + srcDirs '../gcp-grpc-spring-without-test-artifacts-example/src/main/proto' + } + resources { + srcDirs '../gcp-grpc-spring-without-test-artifacts-example/src/main/resources' + } + } + test.java.srcDirs += "${protobuf.generatedFilesBaseDir}/test/java" + test.java.srcDirs += "${protobuf.generatedFilesBaseDir}/test/grpc" +} + +tasks.compileTestJava { dependsOn("generateTestProto") } + +protobuf { + protoc { + artifact = "com.google.protobuf:protoc:${grpcProtobufVersion}" + } + plugins { + grpc { + artifact = "io.grpc:protoc-gen-grpc-java:$grpcVersion" + } + } + generateProtoTasks { + all()*.plugins { + grpc {} + } + } +} diff --git a/examples/gcp-grpc-spring-without-test-artifacts-otel-starter-example/src/test/java/no/entur/grpc/example/without/test/artifacts/otel/starter/ProviderSelectionTest.java b/examples/gcp-grpc-spring-without-test-artifacts-otel-starter-example/src/test/java/no/entur/grpc/example/without/test/artifacts/otel/starter/ProviderSelectionTest.java new file mode 100644 index 00000000..40f537e1 --- /dev/null +++ b/examples/gcp-grpc-spring-without-test-artifacts-otel-starter-example/src/test/java/no/entur/grpc/example/without/test/artifacts/otel/starter/ProviderSelectionTest.java @@ -0,0 +1,72 @@ +package no.entur.grpc.example.without.test.artifacts.otel.starter; + +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.LoggerContext; +import ch.qos.logback.core.Appender; +import ch.qos.logback.core.ConsoleAppender; +import ch.qos.logback.core.encoder.Encoder; +import ch.qos.logback.core.spi.AppenderAttachable; +import no.entur.logging.cloud.gcp.logback.logstash.StackdriverLogstashEncoder; +import no.entur.logging.cloud.gcp.logback.logstash.StackdriverMicrometerTraceMdcJsonProvider; +import no.entur.logging.cloud.gcp.logback.logstash.StackdriverOpenTelemetryTraceMdcJsonProvider; +import no.entur.logging.cloud.logback.logstash.test.CompositeConsoleAppender; +import org.junit.jupiter.api.Test; +import org.slf4j.LoggerFactory; +import org.springframework.boot.test.context.SpringBootTest; + +import java.util.Iterator; + +import static com.google.common.truth.Truth.assertThat; + +/** + * Verifies that the {@link StackdriverLogstashEncoder} selects + * {@link StackdriverMicrometerTraceMdcJsonProvider} when using the Spring Boot OpenTelemetry + * starter (no Java agent attached). This is the default when no OTel agent is present. + */ +@SpringBootTest +public class ProviderSelectionTest { + + @Test + public void encoderUsesMicrometerTraceMdcJsonProvider() { + StackdriverLogstashEncoder encoder = findEncoder(); + assertThat(encoder).isNotNull(); + + boolean hasOtel = encoder.getProviders().getProviders().stream().anyMatch(p -> p instanceof StackdriverOpenTelemetryTraceMdcJsonProvider); + boolean hasMicrometer = encoder.getProviders().getProviders().stream().anyMatch(p -> p instanceof StackdriverMicrometerTraceMdcJsonProvider); + + assertThat(hasMicrometer).isTrue(); + assertThat(hasOtel).isFalse(); + } + + private static StackdriverLogstashEncoder findEncoder() { + LoggerContext ctx = (LoggerContext) LoggerFactory.getILoggerFactory(); + Logger root = ctx.getLogger(org.slf4j.Logger.ROOT_LOGGER_NAME); + return searchForEncoder(root); + } + + @SuppressWarnings("unchecked") + private static StackdriverLogstashEncoder searchForEncoder(AppenderAttachable attachable) { + Iterator> iter = (Iterator) attachable.iteratorForAppenders(); + while (iter.hasNext()) { + Appender appender = iter.next(); + if (appender instanceof CompositeConsoleAppender composite) { + // Test appender: the machine-readable encoder is the StackdriverLogstashEncoder + Encoder enc = composite.getMachineReadableJsonEncoder(); + if (enc instanceof StackdriverLogstashEncoder stackdriverEncoder) { + return stackdriverEncoder; + } + } else if (appender instanceof ConsoleAppender consoleAppender) { + if (consoleAppender.getEncoder() instanceof StackdriverLogstashEncoder enc) { + return enc; + } + } + if (appender instanceof AppenderAttachable nested) { + StackdriverLogstashEncoder result = searchForEncoder(nested); + if (result != null) { + return result; + } + } + } + return null; + } +} diff --git a/examples/gcp-grpc-spring-without-test-artifacts-otel-starter-example/src/test/java/no/entur/grpc/example/without/test/artifacts/otel/starter/SpringAbstractGrpcTest.java b/examples/gcp-grpc-spring-without-test-artifacts-otel-starter-example/src/test/java/no/entur/grpc/example/without/test/artifacts/otel/starter/SpringAbstractGrpcTest.java new file mode 100644 index 00000000..81f48eef --- /dev/null +++ b/examples/gcp-grpc-spring-without-test-artifacts-otel-starter-example/src/test/java/no/entur/grpc/example/without/test/artifacts/otel/starter/SpringAbstractGrpcTest.java @@ -0,0 +1,91 @@ +package no.entur.grpc.example.without.test.artifacts.otel.starter; + +import io.grpc.ManagedChannel; +import io.grpc.ManagedChannelBuilder; +import no.entur.logging.cloud.gcp.logback.logstash.StackdriverMicrometerTraceMdcJsonProvider; +import no.entur.logging.cloud.logback.logstash.test.junit.LogStatement; +import no.entur.logging.cloud.logback.logstash.test.junit.LogStatements; +import org.entur.grpc.example.GreetingRequest; +import org.entur.grpc.example.GreetingServiceGrpc; +import org.junit.jupiter.api.Assertions; +import org.springframework.beans.factory.annotation.Value; +import static com.google.common.truth.Truth.assertThat; + +import java.util.concurrent.TimeUnit; + +public class SpringAbstractGrpcTest { + + // https://github.com/olivere/grpc-demo/blob/master/java-client/src/main/java/com/altf4/grpc/client/ExampleClient.java + public static final int MAX_INBOUND_MESSAGE_SIZE = 1 << 20; + public static final int MAX_OUTBOUND_MESSAGE_SIZE = 1 << 20; + + protected GreetingRequest greetingRequest = GreetingRequest.newBuilder().build(); + + @Value("${grpc.server.port:9090}") + protected int port; + + protected final int maxOutboundMessageSize; + protected final int maxInboundMessageSize; + + public SpringAbstractGrpcTest() { + this(MAX_INBOUND_MESSAGE_SIZE, MAX_OUTBOUND_MESSAGE_SIZE); + } + + public SpringAbstractGrpcTest(int maxInboundMessageSize, int maxOutboundMessageSize) { + this.maxInboundMessageSize = maxInboundMessageSize; + this.maxOutboundMessageSize = maxOutboundMessageSize; + } + + protected GreetingServiceGrpc.GreetingServiceBlockingStub stub() { + ManagedChannel managedChannel = ManagedChannelBuilder.forAddress("localhost", port).usePlaintext().build(); + GreetingServiceGrpc.GreetingServiceBlockingStub greetingService = GreetingServiceGrpc.newBlockingStub(managedChannel); + return greetingService; + } + + protected void shutdown(GreetingServiceGrpc.GreetingServiceBlockingStub stub) throws InterruptedException { + ManagedChannel m = (ManagedChannel)stub.getChannel(); + m.shutdown(); + m.awaitTermination(15, TimeUnit.SECONDS); + } + + protected GreetingServiceGrpc.GreetingServiceFutureStub futureStub() { + ManagedChannel managedChannel = ManagedChannelBuilder.forAddress("localhost", port).usePlaintext().build(); + return GreetingServiceGrpc.newFutureStub(managedChannel); + } + + protected static void shutdown(GreetingServiceGrpc.GreetingServiceFutureStub stub) throws InterruptedException { + ManagedChannel m = (ManagedChannel)stub.getChannel(); + m.shutdown(); + m.awaitTermination(15, TimeUnit.SECONDS); + } + + protected void shutdown(GreetingServiceGrpc.GreetingServiceStub stub) throws InterruptedException { + ManagedChannel m = (ManagedChannel)stub.getChannel(); + m.shutdown(); + m.awaitTermination(15, TimeUnit.SECONDS); + } + + + protected GreetingServiceGrpc.GreetingServiceStub async() { + ManagedChannel managedChannel = ManagedChannelBuilder.forAddress("localhost", port).usePlaintext().build(); + GreetingServiceGrpc.GreetingServiceStub greetingService = GreetingServiceGrpc.newStub(managedChannel) + .withMaxInboundMessageSize(maxInboundMessageSize) + .withMaxOutboundMessageSize(maxOutboundMessageSize); + return greetingService; + } + + + public static void assertGcpTrace(LogStatements statements) { + // Wait a bit to ensure that the logs have been flushed and captured + try { + Thread.sleep(100); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + Assertions.assertFalse(statements.isEmpty(), "Expected log statements to be captured, but none were found."); + for (LogStatement statement : statements) { + assertThat(statement.getJsonPropertyString(StackdriverMicrometerTraceMdcJsonProvider.GCP_TRACE_KEY)).hasLength(32); + assertThat(statement.getJsonPropertyString(StackdriverMicrometerTraceMdcJsonProvider.GCP_SPAN_ID_KEY)).hasLength(16); + } + } +} diff --git a/examples/gcp-grpc-spring-without-test-artifacts-otel-starter-example/src/test/java/no/entur/grpc/example/without/test/artifacts/otel/starter/SpringGrpcLoggingFormatTest.java b/examples/gcp-grpc-spring-without-test-artifacts-otel-starter-example/src/test/java/no/entur/grpc/example/without/test/artifacts/otel/starter/SpringGrpcLoggingFormatTest.java new file mode 100644 index 00000000..0a61cfa9 --- /dev/null +++ b/examples/gcp-grpc-spring-without-test-artifacts-otel-starter-example/src/test/java/no/entur/grpc/example/without/test/artifacts/otel/starter/SpringGrpcLoggingFormatTest.java @@ -0,0 +1,31 @@ +package no.entur.grpc.example.without.test.artifacts.otel.starter; + +import no.entur.logging.cloud.logback.logstash.test.junit.CaptureLogStatements; +import no.entur.logging.cloud.logback.logstash.test.junit.LogStatements; +import org.entur.grpc.example.GreetingResponse; +import org.entur.grpc.example.GreetingServiceGrpc; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.annotation.DirtiesContext; + +import static com.google.common.truth.Truth.assertThat; + +@SpringBootTest +@DirtiesContext +@CaptureLogStatements({"no.entur", "org.entur"}) +public class SpringGrpcLoggingFormatTest extends SpringAbstractGrpcTest { + + @Test + public void useMachineReadableJsonEncoder(LogStatements logStatements) throws InterruptedException { + GreetingServiceGrpc.GreetingServiceBlockingStub stub = stub(); + try { + GreetingResponse response = stub.greeting1(greetingRequest); + assertThat(response.getMessage()).isEqualTo("Hello"); + + assertGcpTrace(logStatements); + } finally { + shutdown(stub); + } + } + +} \ No newline at end of file diff --git a/examples/gcp-grpc-spring-without-test-artifacts-otel-starter-example/src/test/java/no/entur/grpc/example/without/test/artifacts/otel/starter/SpringOndemandGrpcLoggingHighLogLevelTest.java b/examples/gcp-grpc-spring-without-test-artifacts-otel-starter-example/src/test/java/no/entur/grpc/example/without/test/artifacts/otel/starter/SpringOndemandGrpcLoggingHighLogLevelTest.java new file mode 100644 index 00000000..afdc019b --- /dev/null +++ b/examples/gcp-grpc-spring-without-test-artifacts-otel-starter-example/src/test/java/no/entur/grpc/example/without/test/artifacts/otel/starter/SpringOndemandGrpcLoggingHighLogLevelTest.java @@ -0,0 +1,44 @@ +package no.entur.grpc.example.without.test.artifacts.otel.starter; + +import no.entur.logging.cloud.logback.logstash.test.junit.CaptureLogStatements; +import no.entur.logging.cloud.logback.logstash.test.junit.LogStatements; +import org.entur.grpc.example.GreetingResponse; +import org.entur.grpc.example.GreetingServiceGrpc; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.TestPropertySource; + +import static com.google.common.truth.Truth.assertThat; + +/** + * + * Test additional logging due to a log statement with high log level. + * + */ + +@SpringBootTest +@ActiveProfiles("ondemand") +@TestPropertySource(properties = { + "entur.logging.grpc.ondemand.enabled=true", + "entur.logging.http.ondemand.failure.logger.level=error", +}) +@DirtiesContext +@CaptureLogStatements({"no.entur", "org.entur"}) +public class SpringOndemandGrpcLoggingHighLogLevelTest extends SpringAbstractGrpcTest { + + @Test + public void useMachineReadableJsonEncoderExpectFullLogging(LogStatements logStatements) throws InterruptedException { + GreetingServiceGrpc.GreetingServiceBlockingStub stub = stub(); + try { + GreetingResponse response = stub.greeting1(greetingRequest); + assertThat(response.getMessage()).isEqualTo("Hello"); + + assertGcpTrace(logStatements); + } finally { + shutdown(stub); + } + } + +} \ No newline at end of file diff --git a/examples/gcp-web-otel-agent-example/README.md b/examples/gcp-web-otel-agent-example/README.md new file mode 100644 index 00000000..d5aa317c --- /dev/null +++ b/examples/gcp-web-otel-agent-example/README.md @@ -0,0 +1,2 @@ +# gcp-web-otel-agent-example +Simple Spring REST service example with a few unit tests. diff --git a/examples/gcp-web-otel-agent-example/build.gradle b/examples/gcp-web-otel-agent-example/build.gradle new file mode 100644 index 00000000..0d8e8c72 --- /dev/null +++ b/examples/gcp-web-otel-agent-example/build.gradle @@ -0,0 +1,60 @@ +plugins { + id 'org.springframework.boot' version '4.1.0' +} + +configurations { + otelAgent +} + +test { + useJUnitPlatform { + includeEngines 'junit-jupiter' + } + + doFirst { + jvmArgs "-javaagent:${configurations.otelAgent.singleFile}" + } + + systemProperty 'otel.traces.exporter', 'logging' + systemProperty 'otel.metrics.exporter', 'none' + systemProperty 'otel.logs.exporter', 'none' + systemProperty 'otel.service.name', 'junit5-tests' + systemProperty 'otel.instrumentation.http.server.exclude-paths', '/actuator/**' +} + +dependencies { + otelAgent "io.opentelemetry.javaagent:opentelemetry-javaagent:2.30.0" + + implementation project(':on-demand:on-demand-spring-boot-starter-web') + implementation project(":gcp:spring-boot-starter-gcp-web"); + implementation project(":gcp:request-response-spring-boot-starter-gcp-web"); + + implementation("org.springframework.boot:spring-boot-starter-security") + implementation("org.springframework.boot:spring-boot-starter-web") + implementation("org.springframework.boot:spring-boot-starter-actuator") + + testImplementation project(":gcp:spring-boot-starter-gcp-web-test"); + testImplementation project(":gcp:request-response-spring-boot-starter-gcp-web-test"); + + testImplementation("org.springframework.boot:spring-boot-starter-test") + testImplementation("org.springframework.boot:spring-boot-restclient") + testImplementation("org.springframework.boot:spring-boot-resttestclient") + + // JUnit Jupiter API and TestEngine implementation + testImplementation("org.junit.jupiter:junit-jupiter-api") + testImplementation("org.junit.jupiter:junit-jupiter-engine") + + testImplementation ("com.google.truth:truth:${googleTruthVersion}") + testImplementation ("com.google.truth.extensions:truth-java8-extension:${googleTruthVersion}") + + testImplementation("io.opentelemetry:opentelemetry-sdk-testing") + testImplementation project(":test:test-logback-junit") +} + +bootRun { + // example for running locally with one-line logging + dependencies { + implementation project(":gcp:spring-boot-starter-gcp-web-test"); + implementation project(":gcp:request-response-spring-boot-starter-gcp-web-test"); + } +} \ No newline at end of file diff --git a/examples/gcp-web-otel-agent-example/src/main/java/org/entur/example/web/DemoApplication.java b/examples/gcp-web-otel-agent-example/src/main/java/org/entur/example/web/DemoApplication.java new file mode 100644 index 00000000..b9dbb799 --- /dev/null +++ b/examples/gcp-web-otel-agent-example/src/main/java/org/entur/example/web/DemoApplication.java @@ -0,0 +1,13 @@ +package org.entur.example.web; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class DemoApplication { + public static void main(String[] args) { + SpringApplication.run(DemoApplication.class, args); + } +} + + diff --git a/examples/gcp-web-otel-agent-example/src/main/java/org/entur/example/web/config/LogConfiguration.java b/examples/gcp-web-otel-agent-example/src/main/java/org/entur/example/web/config/LogConfiguration.java new file mode 100644 index 00000000..4aa4e68a --- /dev/null +++ b/examples/gcp-web-otel-agent-example/src/main/java/org/entur/example/web/config/LogConfiguration.java @@ -0,0 +1,21 @@ +package org.entur.example.web.config; + +import java.util.HashSet; +import java.util.Set; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.zalando.logbook.BodyFilter; +import org.zalando.logbook.json.JsonBodyFilters; + +@Configuration +public class LogConfiguration { + + @Bean + public BodyFilter filterBody() { + final Set properties = new HashSet<>(); + properties.add("secret"); + return JsonBodyFilters.replaceJsonStringProperty(properties, "hidden"); + } + +} diff --git a/examples/gcp-web-otel-agent-example/src/main/java/org/entur/example/web/config/RestClientConfig.java b/examples/gcp-web-otel-agent-example/src/main/java/org/entur/example/web/config/RestClientConfig.java new file mode 100644 index 00000000..3d06678c --- /dev/null +++ b/examples/gcp-web-otel-agent-example/src/main/java/org/entur/example/web/config/RestClientConfig.java @@ -0,0 +1,15 @@ +package org.entur.example.web.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.client.RestClient; + +@Configuration +public class RestClientConfig { + + @Bean + public RestClient restClient() { + return RestClient.builder() + .build(); + } +} diff --git a/examples/gcp-web-otel-agent-example/src/main/java/org/entur/example/web/config/ReturnHttp401AuthenticationHeaderFilter.java b/examples/gcp-web-otel-agent-example/src/main/java/org/entur/example/web/config/ReturnHttp401AuthenticationHeaderFilter.java new file mode 100644 index 00000000..5d95f972 --- /dev/null +++ b/examples/gcp-web-otel-agent-example/src/main/java/org/entur/example/web/config/ReturnHttp401AuthenticationHeaderFilter.java @@ -0,0 +1,41 @@ +package org.entur.example.web.config; + +import jakarta.servlet.Filter; +import jakarta.servlet.FilterChain; +import jakarta.servlet.FilterConfig; +import jakarta.servlet.ServletException; +import jakarta.servlet.ServletRequest; +import jakarta.servlet.ServletResponse; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.slf4j.MDC; + +import java.io.IOException; + +// for testing +public class ReturnHttp401AuthenticationHeaderFilter implements Filter { + + @Override + public void init(FilterConfig filterConfig) throws ServletException { + } + + @Override + public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) + throws IOException, ServletException { + HttpServletRequest httpRequest = (HttpServletRequest) request; + HttpServletResponse httpResponse = (HttpServletResponse) response; + + String customHeader = httpRequest.getHeader("Authorization"); + if (customHeader != null && customHeader.equals("Bearer x.y.z")) { + httpResponse.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + return; + } + + chain.doFilter(request, response); + } + + @Override + public void destroy() { + // Cleanup logic if needed + } +} \ No newline at end of file diff --git a/examples/gcp-web-otel-agent-example/src/main/java/org/entur/example/web/config/UserIdEnricherFilter.java b/examples/gcp-web-otel-agent-example/src/main/java/org/entur/example/web/config/UserIdEnricherFilter.java new file mode 100644 index 00000000..9d2c34d5 --- /dev/null +++ b/examples/gcp-web-otel-agent-example/src/main/java/org/entur/example/web/config/UserIdEnricherFilter.java @@ -0,0 +1,37 @@ +package org.entur.example.web.config; + +import jakarta.servlet.Filter; +import jakarta.servlet.FilterChain; +import jakarta.servlet.FilterConfig; +import jakarta.servlet.ServletException; +import jakarta.servlet.ServletRequest; +import jakarta.servlet.ServletResponse; +import org.slf4j.MDC; + +import java.io.IOException; + +// for testing +public class UserIdEnricherFilter implements Filter { + + @Override + public void init(FilterConfig filterConfig) throws ServletException { + } + + @Override + public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) + throws IOException, ServletException { + + // emulate a filter enriching the request + MDC.put("subject", "my-subject-id"); + try { + chain.doFilter(request, response); + } finally { + MDC.remove("subject"); + } + } + + @Override + public void destroy() { + // Cleanup logic if needed + } +} \ No newline at end of file diff --git a/examples/gcp-web-otel-agent-example/src/main/java/org/entur/example/web/config/WebSecurityConfig.java b/examples/gcp-web-otel-agent-example/src/main/java/org/entur/example/web/config/WebSecurityConfig.java new file mode 100644 index 00000000..a5423a02 --- /dev/null +++ b/examples/gcp-web-otel-agent-example/src/main/java/org/entur/example/web/config/WebSecurityConfig.java @@ -0,0 +1,26 @@ +package org.entur.example.web.config; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +@Configuration +@EnableWebSecurity +public class WebSecurityConfig { + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http + .csrf( c -> c.disable() ) + .authorizeHttpRequests((authorize) -> { + authorize.requestMatchers("/api/secured/endpoint").fullyAuthenticated(); + authorize.anyRequest().permitAll(); + } + ); + http.addFilterBefore(new ReturnHttp401AuthenticationHeaderFilter(), UsernamePasswordAuthenticationFilter.class); + http.addFilterBefore(new UserIdEnricherFilter(), UsernamePasswordAuthenticationFilter.class); + return http.build(); + } +} \ No newline at end of file diff --git a/examples/gcp-web-otel-agent-example/src/main/java/org/entur/example/web/rest/DocumentEndpoint.java b/examples/gcp-web-otel-agent-example/src/main/java/org/entur/example/web/rest/DocumentEndpoint.java new file mode 100644 index 00000000..31bdb5d8 --- /dev/null +++ b/examples/gcp-web-otel-agent-example/src/main/java/org/entur/example/web/rest/DocumentEndpoint.java @@ -0,0 +1,156 @@ +package org.entur.example.web.rest; + +import jakarta.servlet.http.HttpServletRequest; +import org.slf4j.MDC; +import tools.jackson.core.JsonGenerator; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.authorization.AuthorizationDeniedException; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.client.RestClient; +import tools.jackson.core.json.JsonFactory; + +import java.io.CharArrayWriter; +import java.io.IOException; +import java.util.concurrent.CompletableFuture; + +@RestController +@RequestMapping("/api/document") +public class DocumentEndpoint { + + private final static Logger logger = LoggerFactory.getLogger(DocumentEndpoint.class); + + @Autowired + private RestClient restClient; + + @PostMapping("/some/method") + public MyEntity someMessage(@RequestBody MyEntity entity) { + logger.trace("Hello entity with secret / trace"); + logger.debug("Hello entity with secret / debug"); + logger.info("Hello entity with secret / info"); + logger.warn("Hello entity with secret / warn"); + logger.error("Hello entity with secret / error"); + + logger.info("My MDC map is {}", MDC.getCopyOfContextMap()); + + entity.setName("Entur response"); + return entity; + } + + @PostMapping("/some/error") + public ResponseEntity errorMethod(@RequestBody MyEntity entity) throws InterruptedException { + System.out.flush(); + System.out.println("System out before endpoint logging"); + + logger.trace("This message should be ignored / trace"); + logger.debug("This message should be ignored / debug"); + logger.info("This message should be delayed / info"); + logger.warn("This message should be logged / warn"); + logger.error("This message should be logged / error"); + + Thread.sleep(1000); + System.out.println("System out after endpoint logging + 1000ms"); + + + return new ResponseEntity(HttpStatus.NOT_FOUND); + } + + @GetMapping(value = "/some/newlines", produces = "application/json") + ResponseEntity age() { + String json = "{\n}\n"; + + return new ResponseEntity<>(json, HttpStatus.OK); + } + + + @GetMapping(value = "/some/slow/method", produces = "application/json") + public ResponseEntity age(@RequestParam("wait") Long wait) throws InterruptedException { + logger.info("This message should be delayed; printed for slow requests / info"); + + String json = "{\n}\n"; + try { + Thread.sleep(wait); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + return new ResponseEntity<>(json, HttpStatus.OK); + } + + + @GetMapping(value = "/some/bigResponse", produces = "application/json") + ResponseEntity bigResponse() throws IOException { + JsonFactory factory = new JsonFactory(); + + CharArrayWriter writer = new CharArrayWriter(); + + JsonGenerator generator = factory.createGenerator(writer); + + generator.writeStartObject(); + generator.writeStringProperty("start", "here"); + for(int i = 0; i < 10; i++) { + generator.writeStringProperty("longValue" + i, generateLongString(25*1024)); + } + generator.writeStringProperty("end", "here"); + generator.writeEndObject(); + + generator.flush(); + + return new ResponseEntity<>(writer.toString(), HttpStatus.OK); + } + + private String generateLongString(int length) { + StringBuilder builder = new StringBuilder(length); + + int mod = 'z' - 'a'; + + for(int i = 0; i < length; i++) { + char c = (char) ('a' + i % mod); + builder.append(c); + } + return builder.toString(); + } + + @PostMapping("/some/method/infoLoggingOnly") + public MyEntity infoLoggingOnly(@RequestBody MyEntity entity) { + logger.info("Hello entity with secret / info"); + + entity.setName("Entur response"); + return entity; + } + + @PostMapping(value = "/some/authorizationDenied", produces = "application/json") + public ResponseEntity authorizationDeniedException(@RequestBody MyEntity entity) { + logger.info("Hello entity with secret / info"); + throw new AuthorizationDeniedException("Access Denied", () -> false); + } + + + @PostMapping(value = "/some/nullpointer", produces = "application/json") + public ResponseEntity nullPointerException(@RequestBody MyEntity entity) { + logger.info("Hello entity with secret / info"); + throw new NullPointerException(); + } + + @PostMapping("/some/downstream") + public MyEntity callDownstream(@RequestBody MyEntity entity, HttpServletRequest request) { + logger.info("Calling downstream service"); + String url = "http://127.0.0.1:" + request.getServerPort() + "/api/dummy-service/some/method"; + MyEntity result = restClient.post() + .uri(url) + .contentType(org.springframework.http.MediaType.APPLICATION_JSON) + .body(entity) + .retrieve() + .body(MyEntity.class); + logger.info("Downstream service responded"); + return result; + } + +} \ No newline at end of file diff --git a/examples/gcp-web-otel-agent-example/src/main/java/org/entur/example/web/rest/DummyEndpoint.java b/examples/gcp-web-otel-agent-example/src/main/java/org/entur/example/web/rest/DummyEndpoint.java new file mode 100644 index 00000000..47ba9bf9 --- /dev/null +++ b/examples/gcp-web-otel-agent-example/src/main/java/org/entur/example/web/rest/DummyEndpoint.java @@ -0,0 +1,27 @@ +package org.entur.example.web.rest; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * Simulation of a downstream service, used to verify that trace IDs are propagated + * in outgoing HTTP requests and appear in the logs. + */ +@RestController +@RequestMapping("/api/dummy-service") +public class DummyEndpoint { + + private static final Logger logger = LoggerFactory.getLogger(DummyEndpoint.class); + + @PostMapping("/some/method") + public MyEntity someMessage(@RequestBody MyEntity entity) { + logger.debug("Downstream service received request / debug"); + logger.info("Downstream service received request / info"); + entity.setName("Entur dummy response"); + return entity; + } +} diff --git a/examples/gcp-web-otel-agent-example/src/main/java/org/entur/example/web/rest/MyEntity.java b/examples/gcp-web-otel-agent-example/src/main/java/org/entur/example/web/rest/MyEntity.java new file mode 100644 index 00000000..015b1909 --- /dev/null +++ b/examples/gcp-web-otel-agent-example/src/main/java/org/entur/example/web/rest/MyEntity.java @@ -0,0 +1,28 @@ +package org.entur.example.web.rest; + +import org.codehaus.commons.nullanalysis.NotNull; + +public class MyEntity { + + @NotNull + private String secret; + + @NotNull + private String name; + + public String getSecret() { + return secret; + } + public void setSecret(String secret) { + this.secret = secret; + } + public String getName() { + return name; + } + public void setName(String key) { + this.name = key; + } + + + +} diff --git a/examples/gcp-web-otel-agent-example/src/main/resources/application.properties b/examples/gcp-web-otel-agent-example/src/main/resources/application.properties new file mode 100644 index 00000000..dd37f1a3 --- /dev/null +++ b/examples/gcp-web-otel-agent-example/src/main/resources/application.properties @@ -0,0 +1,17 @@ +entur.logging.request-response.logger.level=INFO +entur.logging.request-response.logger.name=no.entur.logging.cloud + +# override log level +logging.level.org.entur=debug + +# override log level +logging.level.root=debug + + +management.endpoint.health.probes.enabled: true + +logbook.filter.enabled: true + +#logbook.secure-filter.enabled=false + +logbook.exclude[0]=/api/too/much/data \ No newline at end of file diff --git a/examples/gcp-web-otel-agent-example/src/test/java/org/entur/example/web/ActuatorTest.java b/examples/gcp-web-otel-agent-example/src/test/java/org/entur/example/web/ActuatorTest.java new file mode 100644 index 00000000..2c550793 --- /dev/null +++ b/examples/gcp-web-otel-agent-example/src/test/java/org/entur/example/web/ActuatorTest.java @@ -0,0 +1,51 @@ +package org.entur.example.web; + +import no.entur.logging.cloud.logback.logstash.test.CompositeConsoleOutputControl; +import no.entur.logging.cloud.logback.logstash.test.CompositeConsoleOutputControlClosable; +import org.entur.example.web.rest.MyEntity; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.resttestclient.autoconfigure.AutoConfigureTestRestTemplate; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; +import org.springframework.boot.resttestclient.TestRestTemplate; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import static com.google.common.truth.Truth.assertThat; + +@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) +@AutoConfigureTestRestTemplate +public class ActuatorTest { + + @LocalServerPort + private int randomServerPort; + + @Autowired + private TestRestTemplate restTemplate; + + @Test + public void useHumanReadablePlainEncoderTest() { + ResponseEntity response = restTemplate.getForEntity("/actuator/health/readiness", String.class); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + } + + @Test + public void useHumanReadableJsonEncoderTest() throws InterruptedException { + try (CompositeConsoleOutputControlClosable c = CompositeConsoleOutputControl.useHumanReadableJsonEncoder()) { + ResponseEntity response = restTemplate.getForEntity("/actuator/health/readiness", String.class); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + } + } + + @Test + public void useMachineReadableJsonEncoder() throws InterruptedException { + try (CompositeConsoleOutputControlClosable c = CompositeConsoleOutputControl.useMachineReadableJsonEncoder()) { + ResponseEntity response = restTemplate.getForEntity("/actuator/health/readiness", String.class); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + } + } + +} \ No newline at end of file diff --git a/examples/gcp-web-otel-agent-example/src/test/java/org/entur/example/web/OndemandWebLoggingHttpOkHighLogLevelTest.java b/examples/gcp-web-otel-agent-example/src/test/java/org/entur/example/web/OndemandWebLoggingHttpOkHighLogLevelTest.java new file mode 100644 index 00000000..ccef416a --- /dev/null +++ b/examples/gcp-web-otel-agent-example/src/test/java/org/entur/example/web/OndemandWebLoggingHttpOkHighLogLevelTest.java @@ -0,0 +1,82 @@ +package org.entur.example.web; + +import no.entur.logging.cloud.logback.logstash.test.CompositeConsoleOutputControl; +import no.entur.logging.cloud.logback.logstash.test.CompositeConsoleOutputControlClosable; +import no.entur.logging.cloud.logback.logstash.test.junit.CaptureLogStatements; +import no.entur.logging.cloud.logback.logstash.test.junit.LogStatements; +import org.entur.example.web.rest.MyEntity; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.resttestclient.TestRestTemplate; +import org.springframework.boot.resttestclient.autoconfigure.AutoConfigureTestRestTemplate; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.test.context.TestPropertySource; + +import static com.google.common.truth.Truth.assertThat; + +/** + * + * Test additional logging due to a log statement with high log level. + * + */ + +@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) +@TestPropertySource(properties = { + "entur.logging.http.ondemand.enabled=true", + "entur.logging.http.ondemand.failure.http.enabled=false", + "entur.logging.http.ondemand.failure.logger.level=error", +}) +@AutoConfigureTestRestTemplate +@CaptureLogStatements({"no.entur", "org.entur"}) +public class OndemandWebLoggingHttpOkHighLogLevelTest { + + @LocalServerPort + private int randomServerPort; + + @Autowired + private TestRestTemplate restTemplate; + + @Test + public void useHumanReadablePlainEncoderExpectFullLogging(LogStatements statements) { + MyEntity entity = new MyEntity(); + entity.setName("Entur"); + entity.setSecret("mySecret"); + + ResponseEntity response = restTemplate.postForEntity("/api/document/some/method", entity, MyEntity.class); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + + WebLoggingFormatTest.assertGcpTrace(statements); + } + + @Test + public void useHumanReadableJsonEncoderExpectFullLogging(LogStatements statements) { + MyEntity entity = new MyEntity(); + entity.setName("Entur"); + entity.setSecret("mySecret"); + + try (CompositeConsoleOutputControlClosable c = CompositeConsoleOutputControl.useHumanReadableJsonEncoder()) { + ResponseEntity response = restTemplate.postForEntity("/api/document/some/method", entity, MyEntity.class); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + } + + WebLoggingFormatTest.assertGcpTrace(statements); + } + + @Test + public void useMachineReadableJsonEncoderExpectFullLogging(LogStatements statements) { + MyEntity entity = new MyEntity(); + entity.setName("Entur"); + entity.setSecret("mySecret"); + + try (CompositeConsoleOutputControlClosable c = CompositeConsoleOutputControl.useMachineReadableJsonEncoder()) { + ResponseEntity response = restTemplate.postForEntity("/api/document/some/method", entity, MyEntity.class); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + } + WebLoggingFormatTest.assertGcpTrace(statements); + } + +} \ No newline at end of file diff --git a/examples/gcp-web-otel-agent-example/src/test/java/org/entur/example/web/ProviderSelectionTest.java b/examples/gcp-web-otel-agent-example/src/test/java/org/entur/example/web/ProviderSelectionTest.java new file mode 100644 index 00000000..02f4c8d5 --- /dev/null +++ b/examples/gcp-web-otel-agent-example/src/test/java/org/entur/example/web/ProviderSelectionTest.java @@ -0,0 +1,72 @@ +package org.entur.example.web; + +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.LoggerContext; +import ch.qos.logback.core.Appender; +import ch.qos.logback.core.ConsoleAppender; +import ch.qos.logback.core.encoder.Encoder; +import ch.qos.logback.core.spi.AppenderAttachable; +import no.entur.logging.cloud.gcp.logback.logstash.StackdriverLogstashEncoder; +import no.entur.logging.cloud.gcp.logback.logstash.StackdriverMicrometerTraceMdcJsonProvider; +import no.entur.logging.cloud.gcp.logback.logstash.StackdriverOpenTelemetryTraceMdcJsonProvider; +import no.entur.logging.cloud.logback.logstash.test.CompositeConsoleAppender; +import org.junit.jupiter.api.Test; +import org.slf4j.LoggerFactory; +import org.springframework.boot.test.context.SpringBootTest; + +import java.util.Iterator; + +import static com.google.common.truth.Truth.assertThat; + +/** + * Verifies that the {@link StackdriverLogstashEncoder} selects + * {@link StackdriverOpenTelemetryTraceMdcJsonProvider} when the OpenTelemetry Java agent is + * attached (as configured in this module's build.gradle via {@code -javaagent}). + */ +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +public class ProviderSelectionTest { + + @Test + public void encoderUsesOpenTelemetryTraceMdcJsonProvider() { + StackdriverLogstashEncoder encoder = findEncoder(); + assertThat(encoder).isNotNull(); + + boolean hasOtel = encoder.getProviders().getProviders().stream().anyMatch(p -> p instanceof StackdriverOpenTelemetryTraceMdcJsonProvider); + boolean hasMicrometer = encoder.getProviders().getProviders().stream().anyMatch(p -> p instanceof StackdriverMicrometerTraceMdcJsonProvider); + + assertThat(hasOtel).isTrue(); + assertThat(hasMicrometer).isFalse(); + } + + private static StackdriverLogstashEncoder findEncoder() { + LoggerContext ctx = (LoggerContext) LoggerFactory.getILoggerFactory(); + Logger root = ctx.getLogger(org.slf4j.Logger.ROOT_LOGGER_NAME); + return searchForEncoder(root); + } + + @SuppressWarnings("unchecked") + private static StackdriverLogstashEncoder searchForEncoder(AppenderAttachable attachable) { + Iterator> iter = (Iterator) attachable.iteratorForAppenders(); + while (iter.hasNext()) { + Appender appender = iter.next(); + if (appender instanceof CompositeConsoleAppender composite) { + // Test appender: the machine-readable encoder is the StackdriverLogstashEncoder + Encoder enc = composite.getMachineReadableJsonEncoder(); + if (enc instanceof StackdriverLogstashEncoder stackdriverEncoder) { + return stackdriverEncoder; + } + } else if (appender instanceof ConsoleAppender consoleAppender) { + if (consoleAppender.getEncoder() instanceof StackdriverLogstashEncoder enc) { + return enc; + } + } + if (appender instanceof AppenderAttachable nested) { + StackdriverLogstashEncoder result = searchForEncoder(nested); + if (result != null) { + return result; + } + } + } + return null; + } +} diff --git a/examples/gcp-web-otel-agent-example/src/test/java/org/entur/example/web/TraceIdPropagationTest.java b/examples/gcp-web-otel-agent-example/src/test/java/org/entur/example/web/TraceIdPropagationTest.java new file mode 100644 index 00000000..1ccd0d2e --- /dev/null +++ b/examples/gcp-web-otel-agent-example/src/test/java/org/entur/example/web/TraceIdPropagationTest.java @@ -0,0 +1,83 @@ +package org.entur.example.web; + +import no.entur.logging.cloud.gcp.logback.logstash.StackdriverMicrometerTraceMdcJsonProvider; +import no.entur.logging.cloud.logback.logstash.test.junit.CaptureLogStatements; +import no.entur.logging.cloud.logback.logstash.test.junit.LogStatement; +import no.entur.logging.cloud.logback.logstash.test.junit.LogStatements; +import org.entur.example.web.rest.MyEntity; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.resttestclient.TestRestTemplate; +import org.springframework.boot.resttestclient.autoconfigure.AutoConfigureTestRestTemplate; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +import static com.google.common.truth.Truth.assertThat; + +/** + * Verifies that the trace ID is propagated from the caller (DocumentEndpoint) to the downstream + * service (DummyEndpoint) when using a RestTemplate instrumented by the OpenTelemetry agent. + */ +@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) +@AutoConfigureTestRestTemplate +@CaptureLogStatements({"org.entur"}) +public class TraceIdPropagationTest { + + private static final String DOCUMENT_ENDPOINT_LOGGER = "org.entur.example.web.rest.DocumentEndpoint"; + private static final String DUMMY_ENDPOINT_LOGGER = "org.entur.example.web.rest.DummyEndpoint"; + + @Autowired + private TestRestTemplate restTemplate; + + @Test + public void traceIdIsPropagatedToDownstreamService(LogStatements statements) { + MyEntity entity = new MyEntity(); + entity.setName("Entur"); + entity.setSecret("mySecret"); + + ResponseEntity response = restTemplate.postForEntity("/api/document/some/downstream", entity, MyEntity.class); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + + // Allow log events to flush + try { + Thread.sleep(100); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + + List documentLogs = statements.stream() + .filter(s -> DOCUMENT_ENDPOINT_LOGGER.equals(s.getLoggerName())) + .collect(Collectors.toList()); + + List dummyLogs = statements.stream() + .filter(s -> DUMMY_ENDPOINT_LOGGER.equals(s.getLoggerName())) + .collect(Collectors.toList()); + + Assertions.assertFalse(documentLogs.isEmpty(), "Expected log statements from DocumentEndpoint"); + Assertions.assertFalse(dummyLogs.isEmpty(), "Expected log statements from DummyEndpoint"); + + // Collect all distinct trace IDs from each endpoint + Set documentTraceIds = documentLogs.stream() + .map(s -> s.getJsonPropertyString(StackdriverMicrometerTraceMdcJsonProvider.GCP_TRACE_KEY)) + .filter(id -> id != null && !id.isEmpty()) + .collect(Collectors.toSet()); + + Set dummyTraceIds = dummyLogs.stream() + .map(s -> s.getJsonPropertyString(StackdriverMicrometerTraceMdcJsonProvider.GCP_TRACE_KEY)) + .filter(id -> id != null && !id.isEmpty()) + .collect(Collectors.toSet()); + + Assertions.assertFalse(documentTraceIds.isEmpty(), "DocumentEndpoint logs must contain a trace ID"); + Assertions.assertFalse(dummyTraceIds.isEmpty(), "DummyEndpoint logs must contain a trace ID"); + + // The downstream call must use the same trace ID as the caller + assertThat(dummyTraceIds).containsAtLeastElementsIn(documentTraceIds); + } +} diff --git a/examples/gcp-web-otel-agent-example/src/test/java/org/entur/example/web/WebLoggingFormatTest.java b/examples/gcp-web-otel-agent-example/src/test/java/org/entur/example/web/WebLoggingFormatTest.java new file mode 100644 index 00000000..5261b92b --- /dev/null +++ b/examples/gcp-web-otel-agent-example/src/test/java/org/entur/example/web/WebLoggingFormatTest.java @@ -0,0 +1,88 @@ +package org.entur.example.web; + +import no.entur.logging.cloud.gcp.logback.logstash.StackdriverMicrometerTraceMdcJsonProvider; +import no.entur.logging.cloud.logback.logstash.test.CompositeConsoleOutputControl; +import no.entur.logging.cloud.logback.logstash.test.CompositeConsoleOutputControlClosable; +import no.entur.logging.cloud.logback.logstash.test.junit.CaptureLogStatements; +import no.entur.logging.cloud.logback.logstash.test.junit.LogStatement; +import no.entur.logging.cloud.logback.logstash.test.junit.LogStatements; +import org.entur.example.web.rest.MyEntity; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.resttestclient.TestRestTemplate; +import org.springframework.boot.resttestclient.autoconfigure.AutoConfigureTestRestTemplate; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import static com.google.common.truth.Truth.assertThat; + +@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) +@AutoConfigureTestRestTemplate +@CaptureLogStatements({"no.entur", "org.entur"}) +public class WebLoggingFormatTest { + + @LocalServerPort + private int randomServerPort; + + @Autowired + private TestRestTemplate restTemplate; + + @Test + public void useHumanReadablePlainEncoderTest(LogStatements statements) throws Exception { + MyEntity entity = new MyEntity(); + entity.setName("Entur"); + entity.setSecret("mySecret"); + + ResponseEntity response = restTemplate.postForEntity("/api/document/some/method", entity, MyEntity.class); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + + assertGcpTrace(statements); + } + + @Test + public void useHumanReadableJsonEncoderTest(LogStatements statements) throws Exception { + MyEntity entity = new MyEntity(); + entity.setName("Entur"); + entity.setSecret("mySecret"); + + try (CompositeConsoleOutputControlClosable c = CompositeConsoleOutputControl.useHumanReadableJsonEncoder()) { + ResponseEntity response = restTemplate.postForEntity("/api/document/some/method", entity, MyEntity.class); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + } + + assertGcpTrace(statements); + } + + @Test + public void useMachineReadableJsonEncoder(LogStatements statements) throws Exception { + MyEntity entity = new MyEntity(); + entity.setName("Entur"); + entity.setSecret("mySecret"); + + try (CompositeConsoleOutputControlClosable c = CompositeConsoleOutputControl.useMachineReadableJsonEncoder()) { + ResponseEntity response = restTemplate.postForEntity("/api/document/some/method", entity, MyEntity.class); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + } + + assertGcpTrace(statements); + } + + public static void assertGcpTrace(LogStatements statements) { + // Wait a bit to ensure that the logs have been flushed and captured + try { + Thread.sleep(100); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + Assertions.assertFalse(statements.isEmpty(), "Expected log statements to be captured, but none were found."); + for (LogStatement statement : statements) { + assertThat(statement.getJsonPropertyString(StackdriverMicrometerTraceMdcJsonProvider.GCP_TRACE_KEY)).hasLength(32); + assertThat(statement.getJsonPropertyString(StackdriverMicrometerTraceMdcJsonProvider.GCP_SPAN_ID_KEY)).hasLength(16); + } + } + +} \ No newline at end of file diff --git a/examples/gcp-web-otel-starter-example/README.md b/examples/gcp-web-otel-starter-example/README.md new file mode 100644 index 00000000..bbd77a2b --- /dev/null +++ b/examples/gcp-web-otel-starter-example/README.md @@ -0,0 +1,2 @@ +# gcp-web-otel-starter-example +Simple Spring REST service example with OTEL and a few unit tests. diff --git a/examples/gcp-web-otel-starter-example/build.gradle b/examples/gcp-web-otel-starter-example/build.gradle new file mode 100644 index 00000000..4b749a16 --- /dev/null +++ b/examples/gcp-web-otel-starter-example/build.gradle @@ -0,0 +1,47 @@ +plugins { + id 'org.springframework.boot' version '4.1.0' +} + +test { + useJUnitPlatform { + includeEngines 'junit-jupiter' + } +} + +dependencies { + implementation project(':on-demand:on-demand-spring-boot-starter-web') + implementation project(":gcp:spring-boot-starter-gcp-web"); + implementation project(":gcp:request-response-spring-boot-starter-gcp-web"); + + implementation("org.springframework.boot:spring-boot-starter-security") + implementation("org.springframework.boot:spring-boot-starter-web") + implementation("org.springframework.boot:spring-boot-starter-actuator") + + implementation 'org.springframework.boot:spring-boot-starter-opentelemetry' + + testImplementation project(":gcp:spring-boot-starter-gcp-web-test"); + testImplementation project(":gcp:request-response-spring-boot-starter-gcp-web-test"); + + testImplementation("org.springframework.boot:spring-boot-starter-test") + testImplementation("org.springframework.boot:spring-boot-restclient") + testImplementation("org.springframework.boot:spring-boot-resttestclient") + + // JUnit Jupiter API and TestEngine implementation + testImplementation("org.junit.jupiter:junit-jupiter-api") + testImplementation("org.junit.jupiter:junit-jupiter-engine") + + testImplementation ("com.google.truth:truth:${googleTruthVersion}") + testImplementation ("com.google.truth.extensions:truth-java8-extension:${googleTruthVersion}") + + testImplementation("io.opentelemetry:opentelemetry-sdk-testing") + testImplementation project(":test:test-logback-junit") + +} + +bootRun { + // example for running locally with one-line logging + dependencies { + implementation project(":gcp:spring-boot-starter-gcp-web-test"); + implementation project(":gcp:request-response-spring-boot-starter-gcp-web-test"); + } +} \ No newline at end of file diff --git a/examples/gcp-web-otel-starter-example/src/main/java/org/entur/example/web/DemoApplication.java b/examples/gcp-web-otel-starter-example/src/main/java/org/entur/example/web/DemoApplication.java new file mode 100644 index 00000000..b9dbb799 --- /dev/null +++ b/examples/gcp-web-otel-starter-example/src/main/java/org/entur/example/web/DemoApplication.java @@ -0,0 +1,13 @@ +package org.entur.example.web; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class DemoApplication { + public static void main(String[] args) { + SpringApplication.run(DemoApplication.class, args); + } +} + + diff --git a/examples/gcp-web-otel-starter-example/src/main/java/org/entur/example/web/config/LogConfiguration.java b/examples/gcp-web-otel-starter-example/src/main/java/org/entur/example/web/config/LogConfiguration.java new file mode 100644 index 00000000..4aa4e68a --- /dev/null +++ b/examples/gcp-web-otel-starter-example/src/main/java/org/entur/example/web/config/LogConfiguration.java @@ -0,0 +1,21 @@ +package org.entur.example.web.config; + +import java.util.HashSet; +import java.util.Set; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.zalando.logbook.BodyFilter; +import org.zalando.logbook.json.JsonBodyFilters; + +@Configuration +public class LogConfiguration { + + @Bean + public BodyFilter filterBody() { + final Set properties = new HashSet<>(); + properties.add("secret"); + return JsonBodyFilters.replaceJsonStringProperty(properties, "hidden"); + } + +} diff --git a/examples/gcp-web-otel-starter-example/src/main/java/org/entur/example/web/config/RestClientConfig.java b/examples/gcp-web-otel-starter-example/src/main/java/org/entur/example/web/config/RestClientConfig.java new file mode 100644 index 00000000..a9d49024 --- /dev/null +++ b/examples/gcp-web-otel-starter-example/src/main/java/org/entur/example/web/config/RestClientConfig.java @@ -0,0 +1,17 @@ +package org.entur.example.web.config; + +import io.micrometer.observation.ObservationRegistry; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.client.RestClient; + +@Configuration +public class RestClientConfig { + + @Bean + public RestClient restClient(ObservationRegistry observationRegistry) { + return RestClient.builder() + .observationRegistry(observationRegistry) + .build(); + } +} diff --git a/examples/gcp-web-otel-starter-example/src/main/java/org/entur/example/web/config/ReturnHttp401AuthenticationHeaderFilter.java b/examples/gcp-web-otel-starter-example/src/main/java/org/entur/example/web/config/ReturnHttp401AuthenticationHeaderFilter.java new file mode 100644 index 00000000..5d95f972 --- /dev/null +++ b/examples/gcp-web-otel-starter-example/src/main/java/org/entur/example/web/config/ReturnHttp401AuthenticationHeaderFilter.java @@ -0,0 +1,41 @@ +package org.entur.example.web.config; + +import jakarta.servlet.Filter; +import jakarta.servlet.FilterChain; +import jakarta.servlet.FilterConfig; +import jakarta.servlet.ServletException; +import jakarta.servlet.ServletRequest; +import jakarta.servlet.ServletResponse; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.slf4j.MDC; + +import java.io.IOException; + +// for testing +public class ReturnHttp401AuthenticationHeaderFilter implements Filter { + + @Override + public void init(FilterConfig filterConfig) throws ServletException { + } + + @Override + public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) + throws IOException, ServletException { + HttpServletRequest httpRequest = (HttpServletRequest) request; + HttpServletResponse httpResponse = (HttpServletResponse) response; + + String customHeader = httpRequest.getHeader("Authorization"); + if (customHeader != null && customHeader.equals("Bearer x.y.z")) { + httpResponse.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + return; + } + + chain.doFilter(request, response); + } + + @Override + public void destroy() { + // Cleanup logic if needed + } +} \ No newline at end of file diff --git a/examples/gcp-web-otel-starter-example/src/main/java/org/entur/example/web/config/UserIdEnricherFilter.java b/examples/gcp-web-otel-starter-example/src/main/java/org/entur/example/web/config/UserIdEnricherFilter.java new file mode 100644 index 00000000..9d2c34d5 --- /dev/null +++ b/examples/gcp-web-otel-starter-example/src/main/java/org/entur/example/web/config/UserIdEnricherFilter.java @@ -0,0 +1,37 @@ +package org.entur.example.web.config; + +import jakarta.servlet.Filter; +import jakarta.servlet.FilterChain; +import jakarta.servlet.FilterConfig; +import jakarta.servlet.ServletException; +import jakarta.servlet.ServletRequest; +import jakarta.servlet.ServletResponse; +import org.slf4j.MDC; + +import java.io.IOException; + +// for testing +public class UserIdEnricherFilter implements Filter { + + @Override + public void init(FilterConfig filterConfig) throws ServletException { + } + + @Override + public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) + throws IOException, ServletException { + + // emulate a filter enriching the request + MDC.put("subject", "my-subject-id"); + try { + chain.doFilter(request, response); + } finally { + MDC.remove("subject"); + } + } + + @Override + public void destroy() { + // Cleanup logic if needed + } +} \ No newline at end of file diff --git a/examples/gcp-web-otel-starter-example/src/main/java/org/entur/example/web/config/WebSecurityConfig.java b/examples/gcp-web-otel-starter-example/src/main/java/org/entur/example/web/config/WebSecurityConfig.java new file mode 100644 index 00000000..a5423a02 --- /dev/null +++ b/examples/gcp-web-otel-starter-example/src/main/java/org/entur/example/web/config/WebSecurityConfig.java @@ -0,0 +1,26 @@ +package org.entur.example.web.config; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +@Configuration +@EnableWebSecurity +public class WebSecurityConfig { + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http + .csrf( c -> c.disable() ) + .authorizeHttpRequests((authorize) -> { + authorize.requestMatchers("/api/secured/endpoint").fullyAuthenticated(); + authorize.anyRequest().permitAll(); + } + ); + http.addFilterBefore(new ReturnHttp401AuthenticationHeaderFilter(), UsernamePasswordAuthenticationFilter.class); + http.addFilterBefore(new UserIdEnricherFilter(), UsernamePasswordAuthenticationFilter.class); + return http.build(); + } +} \ No newline at end of file diff --git a/examples/gcp-web-otel-starter-example/src/main/java/org/entur/example/web/rest/DocumentEndpoint.java b/examples/gcp-web-otel-starter-example/src/main/java/org/entur/example/web/rest/DocumentEndpoint.java new file mode 100644 index 00000000..31bdb5d8 --- /dev/null +++ b/examples/gcp-web-otel-starter-example/src/main/java/org/entur/example/web/rest/DocumentEndpoint.java @@ -0,0 +1,156 @@ +package org.entur.example.web.rest; + +import jakarta.servlet.http.HttpServletRequest; +import org.slf4j.MDC; +import tools.jackson.core.JsonGenerator; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.authorization.AuthorizationDeniedException; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.client.RestClient; +import tools.jackson.core.json.JsonFactory; + +import java.io.CharArrayWriter; +import java.io.IOException; +import java.util.concurrent.CompletableFuture; + +@RestController +@RequestMapping("/api/document") +public class DocumentEndpoint { + + private final static Logger logger = LoggerFactory.getLogger(DocumentEndpoint.class); + + @Autowired + private RestClient restClient; + + @PostMapping("/some/method") + public MyEntity someMessage(@RequestBody MyEntity entity) { + logger.trace("Hello entity with secret / trace"); + logger.debug("Hello entity with secret / debug"); + logger.info("Hello entity with secret / info"); + logger.warn("Hello entity with secret / warn"); + logger.error("Hello entity with secret / error"); + + logger.info("My MDC map is {}", MDC.getCopyOfContextMap()); + + entity.setName("Entur response"); + return entity; + } + + @PostMapping("/some/error") + public ResponseEntity errorMethod(@RequestBody MyEntity entity) throws InterruptedException { + System.out.flush(); + System.out.println("System out before endpoint logging"); + + logger.trace("This message should be ignored / trace"); + logger.debug("This message should be ignored / debug"); + logger.info("This message should be delayed / info"); + logger.warn("This message should be logged / warn"); + logger.error("This message should be logged / error"); + + Thread.sleep(1000); + System.out.println("System out after endpoint logging + 1000ms"); + + + return new ResponseEntity(HttpStatus.NOT_FOUND); + } + + @GetMapping(value = "/some/newlines", produces = "application/json") + ResponseEntity age() { + String json = "{\n}\n"; + + return new ResponseEntity<>(json, HttpStatus.OK); + } + + + @GetMapping(value = "/some/slow/method", produces = "application/json") + public ResponseEntity age(@RequestParam("wait") Long wait) throws InterruptedException { + logger.info("This message should be delayed; printed for slow requests / info"); + + String json = "{\n}\n"; + try { + Thread.sleep(wait); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + return new ResponseEntity<>(json, HttpStatus.OK); + } + + + @GetMapping(value = "/some/bigResponse", produces = "application/json") + ResponseEntity bigResponse() throws IOException { + JsonFactory factory = new JsonFactory(); + + CharArrayWriter writer = new CharArrayWriter(); + + JsonGenerator generator = factory.createGenerator(writer); + + generator.writeStartObject(); + generator.writeStringProperty("start", "here"); + for(int i = 0; i < 10; i++) { + generator.writeStringProperty("longValue" + i, generateLongString(25*1024)); + } + generator.writeStringProperty("end", "here"); + generator.writeEndObject(); + + generator.flush(); + + return new ResponseEntity<>(writer.toString(), HttpStatus.OK); + } + + private String generateLongString(int length) { + StringBuilder builder = new StringBuilder(length); + + int mod = 'z' - 'a'; + + for(int i = 0; i < length; i++) { + char c = (char) ('a' + i % mod); + builder.append(c); + } + return builder.toString(); + } + + @PostMapping("/some/method/infoLoggingOnly") + public MyEntity infoLoggingOnly(@RequestBody MyEntity entity) { + logger.info("Hello entity with secret / info"); + + entity.setName("Entur response"); + return entity; + } + + @PostMapping(value = "/some/authorizationDenied", produces = "application/json") + public ResponseEntity authorizationDeniedException(@RequestBody MyEntity entity) { + logger.info("Hello entity with secret / info"); + throw new AuthorizationDeniedException("Access Denied", () -> false); + } + + + @PostMapping(value = "/some/nullpointer", produces = "application/json") + public ResponseEntity nullPointerException(@RequestBody MyEntity entity) { + logger.info("Hello entity with secret / info"); + throw new NullPointerException(); + } + + @PostMapping("/some/downstream") + public MyEntity callDownstream(@RequestBody MyEntity entity, HttpServletRequest request) { + logger.info("Calling downstream service"); + String url = "http://127.0.0.1:" + request.getServerPort() + "/api/dummy-service/some/method"; + MyEntity result = restClient.post() + .uri(url) + .contentType(org.springframework.http.MediaType.APPLICATION_JSON) + .body(entity) + .retrieve() + .body(MyEntity.class); + logger.info("Downstream service responded"); + return result; + } + +} \ No newline at end of file diff --git a/examples/gcp-web-otel-starter-example/src/main/java/org/entur/example/web/rest/DummyEndpoint.java b/examples/gcp-web-otel-starter-example/src/main/java/org/entur/example/web/rest/DummyEndpoint.java new file mode 100644 index 00000000..47ba9bf9 --- /dev/null +++ b/examples/gcp-web-otel-starter-example/src/main/java/org/entur/example/web/rest/DummyEndpoint.java @@ -0,0 +1,27 @@ +package org.entur.example.web.rest; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * Simulation of a downstream service, used to verify that trace IDs are propagated + * in outgoing HTTP requests and appear in the logs. + */ +@RestController +@RequestMapping("/api/dummy-service") +public class DummyEndpoint { + + private static final Logger logger = LoggerFactory.getLogger(DummyEndpoint.class); + + @PostMapping("/some/method") + public MyEntity someMessage(@RequestBody MyEntity entity) { + logger.debug("Downstream service received request / debug"); + logger.info("Downstream service received request / info"); + entity.setName("Entur dummy response"); + return entity; + } +} diff --git a/examples/gcp-web-otel-starter-example/src/main/java/org/entur/example/web/rest/MyEntity.java b/examples/gcp-web-otel-starter-example/src/main/java/org/entur/example/web/rest/MyEntity.java new file mode 100644 index 00000000..015b1909 --- /dev/null +++ b/examples/gcp-web-otel-starter-example/src/main/java/org/entur/example/web/rest/MyEntity.java @@ -0,0 +1,28 @@ +package org.entur.example.web.rest; + +import org.codehaus.commons.nullanalysis.NotNull; + +public class MyEntity { + + @NotNull + private String secret; + + @NotNull + private String name; + + public String getSecret() { + return secret; + } + public void setSecret(String secret) { + this.secret = secret; + } + public String getName() { + return name; + } + public void setName(String key) { + this.name = key; + } + + + +} diff --git a/examples/gcp-web-otel-starter-example/src/main/resources/application.properties b/examples/gcp-web-otel-starter-example/src/main/resources/application.properties new file mode 100644 index 00000000..dd37f1a3 --- /dev/null +++ b/examples/gcp-web-otel-starter-example/src/main/resources/application.properties @@ -0,0 +1,17 @@ +entur.logging.request-response.logger.level=INFO +entur.logging.request-response.logger.name=no.entur.logging.cloud + +# override log level +logging.level.org.entur=debug + +# override log level +logging.level.root=debug + + +management.endpoint.health.probes.enabled: true + +logbook.filter.enabled: true + +#logbook.secure-filter.enabled=false + +logbook.exclude[0]=/api/too/much/data \ No newline at end of file diff --git a/examples/gcp-web-otel-starter-example/src/test/java/org/entur/example/web/otel/starter/OndemandWebLoggingHttpOkHighLogLevelTest.java b/examples/gcp-web-otel-starter-example/src/test/java/org/entur/example/web/otel/starter/OndemandWebLoggingHttpOkHighLogLevelTest.java new file mode 100644 index 00000000..821630e8 --- /dev/null +++ b/examples/gcp-web-otel-starter-example/src/test/java/org/entur/example/web/otel/starter/OndemandWebLoggingHttpOkHighLogLevelTest.java @@ -0,0 +1,82 @@ +package org.entur.example.web.otel.starter; + +import no.entur.logging.cloud.logback.logstash.test.CompositeConsoleOutputControl; +import no.entur.logging.cloud.logback.logstash.test.CompositeConsoleOutputControlClosable; +import no.entur.logging.cloud.logback.logstash.test.junit.CaptureLogStatements; +import no.entur.logging.cloud.logback.logstash.test.junit.LogStatements; +import org.entur.example.web.rest.MyEntity; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.resttestclient.TestRestTemplate; +import org.springframework.boot.resttestclient.autoconfigure.AutoConfigureTestRestTemplate; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.test.context.TestPropertySource; + +import static com.google.common.truth.Truth.assertThat; + +/** + * + * Test additional logging due to a log statement with high log level. + * + */ + +@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) +@TestPropertySource(properties = { + "entur.logging.http.ondemand.enabled=true", + "entur.logging.http.ondemand.failure.http.enabled=false", + "entur.logging.http.ondemand.failure.logger.level=error", +}) +@AutoConfigureTestRestTemplate +@CaptureLogStatements({"no.entur", "org.entur"}) +public class OndemandWebLoggingHttpOkHighLogLevelTest { + + @LocalServerPort + private int randomServerPort; + + @Autowired + private TestRestTemplate restTemplate; + + @Test + public void useHumanReadablePlainEncoderExpectFullLogging(LogStatements statements) { + MyEntity entity = new MyEntity(); + entity.setName("Entur"); + entity.setSecret("mySecret"); + + ResponseEntity response = restTemplate.postForEntity("/api/document/some/method", entity, MyEntity.class); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + + WebLoggingFormatTest.assertGcpTrace(statements); + } + + @Test + public void useHumanReadableJsonEncoderExpectFullLogging(LogStatements statements) { + MyEntity entity = new MyEntity(); + entity.setName("Entur"); + entity.setSecret("mySecret"); + + try (CompositeConsoleOutputControlClosable c = CompositeConsoleOutputControl.useHumanReadableJsonEncoder()) { + ResponseEntity response = restTemplate.postForEntity("/api/document/some/method", entity, MyEntity.class); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + } + + WebLoggingFormatTest.assertGcpTrace(statements); + } + + @Test + public void useMachineReadableJsonEncoderExpectFullLogging(LogStatements statements) { + MyEntity entity = new MyEntity(); + entity.setName("Entur"); + entity.setSecret("mySecret"); + + try (CompositeConsoleOutputControlClosable c = CompositeConsoleOutputControl.useMachineReadableJsonEncoder()) { + ResponseEntity response = restTemplate.postForEntity("/api/document/some/method", entity, MyEntity.class); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + } + WebLoggingFormatTest.assertGcpTrace(statements); + } + +} \ No newline at end of file diff --git a/examples/gcp-web-otel-starter-example/src/test/java/org/entur/example/web/otel/starter/ProviderSelectionTest.java b/examples/gcp-web-otel-starter-example/src/test/java/org/entur/example/web/otel/starter/ProviderSelectionTest.java new file mode 100644 index 00000000..67e927ea --- /dev/null +++ b/examples/gcp-web-otel-starter-example/src/test/java/org/entur/example/web/otel/starter/ProviderSelectionTest.java @@ -0,0 +1,72 @@ +package org.entur.example.web.otel.starter; + +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.LoggerContext; +import ch.qos.logback.core.Appender; +import ch.qos.logback.core.ConsoleAppender; +import ch.qos.logback.core.encoder.Encoder; +import ch.qos.logback.core.spi.AppenderAttachable; +import no.entur.logging.cloud.gcp.logback.logstash.StackdriverLogstashEncoder; +import no.entur.logging.cloud.gcp.logback.logstash.StackdriverMicrometerTraceMdcJsonProvider; +import no.entur.logging.cloud.gcp.logback.logstash.StackdriverOpenTelemetryTraceMdcJsonProvider; +import no.entur.logging.cloud.logback.logstash.test.CompositeConsoleAppender; +import org.junit.jupiter.api.Test; +import org.slf4j.LoggerFactory; +import org.springframework.boot.test.context.SpringBootTest; + +import java.util.Iterator; + +import static com.google.common.truth.Truth.assertThat; + +/** + * Verifies that the {@link StackdriverLogstashEncoder} selects + * {@link StackdriverMicrometerTraceMdcJsonProvider} when using the Spring Boot OpenTelemetry + * starter (no Java agent attached). This is the default when no OTel agent is present. + */ +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +public class ProviderSelectionTest { + + @Test + public void encoderUsesMicrometerTraceMdcJsonProvider() { + StackdriverLogstashEncoder encoder = findEncoder(); + assertThat(encoder).isNotNull(); + + boolean hasOtel = encoder.getProviders().getProviders().stream().anyMatch(p -> p instanceof StackdriverOpenTelemetryTraceMdcJsonProvider); + boolean hasMicrometer = encoder.getProviders().getProviders().stream().anyMatch(p -> p instanceof StackdriverMicrometerTraceMdcJsonProvider); + + assertThat(hasMicrometer).isTrue(); + assertThat(hasOtel).isFalse(); + } + + private static StackdriverLogstashEncoder findEncoder() { + LoggerContext ctx = (LoggerContext) LoggerFactory.getILoggerFactory(); + Logger root = ctx.getLogger(org.slf4j.Logger.ROOT_LOGGER_NAME); + return searchForEncoder(root); + } + + @SuppressWarnings("unchecked") + private static StackdriverLogstashEncoder searchForEncoder(AppenderAttachable attachable) { + Iterator> iter = (Iterator) attachable.iteratorForAppenders(); + while (iter.hasNext()) { + Appender appender = iter.next(); + if (appender instanceof CompositeConsoleAppender composite) { + // Test appender: the machine-readable encoder is the StackdriverLogstashEncoder + Encoder enc = composite.getMachineReadableJsonEncoder(); + if (enc instanceof StackdriverLogstashEncoder stackdriverEncoder) { + return stackdriverEncoder; + } + } else if (appender instanceof ConsoleAppender consoleAppender) { + if (consoleAppender.getEncoder() instanceof StackdriverLogstashEncoder enc) { + return enc; + } + } + if (appender instanceof AppenderAttachable nested) { + StackdriverLogstashEncoder result = searchForEncoder(nested); + if (result != null) { + return result; + } + } + } + return null; + } +} diff --git a/examples/gcp-web-otel-starter-example/src/test/java/org/entur/example/web/otel/starter/TraceIdPropagationTest.java b/examples/gcp-web-otel-starter-example/src/test/java/org/entur/example/web/otel/starter/TraceIdPropagationTest.java new file mode 100644 index 00000000..ee277cd6 --- /dev/null +++ b/examples/gcp-web-otel-starter-example/src/test/java/org/entur/example/web/otel/starter/TraceIdPropagationTest.java @@ -0,0 +1,83 @@ +package org.entur.example.web.otel.starter; + +import no.entur.logging.cloud.gcp.logback.logstash.StackdriverMicrometerTraceMdcJsonProvider; +import no.entur.logging.cloud.logback.logstash.test.junit.CaptureLogStatements; +import no.entur.logging.cloud.logback.logstash.test.junit.LogStatement; +import no.entur.logging.cloud.logback.logstash.test.junit.LogStatements; +import org.entur.example.web.rest.MyEntity; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.resttestclient.TestRestTemplate; +import org.springframework.boot.resttestclient.autoconfigure.AutoConfigureTestRestTemplate; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +import static com.google.common.truth.Truth.assertThat; + +/** + * Verifies that the trace ID is propagated from the caller (DocumentEndpoint) to the downstream + * service (DummyEndpoint) when using a RestTemplate instrumented by the OpenTelemetry Spring Boot starter. + */ +@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) +@AutoConfigureTestRestTemplate +@CaptureLogStatements({"org.entur"}) +public class TraceIdPropagationTest { + + private static final String DOCUMENT_ENDPOINT_LOGGER = "org.entur.example.web.rest.DocumentEndpoint"; + private static final String DUMMY_ENDPOINT_LOGGER = "org.entur.example.web.rest.DummyEndpoint"; + + @Autowired + private TestRestTemplate restTemplate; + + @Test + public void traceIdIsPropagatedToDownstreamService(LogStatements statements) { + MyEntity entity = new MyEntity(); + entity.setName("Entur"); + entity.setSecret("mySecret"); + + ResponseEntity response = restTemplate.postForEntity("/api/document/some/downstream", entity, MyEntity.class); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + + // Allow log events to flush + try { + Thread.sleep(100); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + + List documentLogs = statements.stream() + .filter(s -> DOCUMENT_ENDPOINT_LOGGER.equals(s.getLoggerName())) + .collect(Collectors.toList()); + + List dummyLogs = statements.stream() + .filter(s -> DUMMY_ENDPOINT_LOGGER.equals(s.getLoggerName())) + .collect(Collectors.toList()); + + Assertions.assertFalse(documentLogs.isEmpty(), "Expected log statements from DocumentEndpoint"); + Assertions.assertFalse(dummyLogs.isEmpty(), "Expected log statements from DummyEndpoint"); + + // Collect all distinct trace IDs from each endpoint + Set documentTraceIds = documentLogs.stream() + .map(s -> s.getJsonPropertyString(StackdriverMicrometerTraceMdcJsonProvider.GCP_TRACE_KEY)) + .filter(id -> id != null && !id.isEmpty()) + .collect(Collectors.toSet()); + + Set dummyTraceIds = dummyLogs.stream() + .map(s -> s.getJsonPropertyString(StackdriverMicrometerTraceMdcJsonProvider.GCP_TRACE_KEY)) + .filter(id -> id != null && !id.isEmpty()) + .collect(Collectors.toSet()); + + Assertions.assertFalse(documentTraceIds.isEmpty(), "DocumentEndpoint logs must contain a trace ID"); + Assertions.assertFalse(dummyTraceIds.isEmpty(), "DummyEndpoint logs must contain a trace ID"); + + // The downstream call must use the same trace ID as the caller + assertThat(dummyTraceIds).containsAtLeastElementsIn(documentTraceIds); + } +} diff --git a/examples/gcp-web-otel-starter-example/src/test/java/org/entur/example/web/otel/starter/WebLoggingFormatTest.java b/examples/gcp-web-otel-starter-example/src/test/java/org/entur/example/web/otel/starter/WebLoggingFormatTest.java new file mode 100644 index 00000000..f8df767f --- /dev/null +++ b/examples/gcp-web-otel-starter-example/src/test/java/org/entur/example/web/otel/starter/WebLoggingFormatTest.java @@ -0,0 +1,88 @@ +package org.entur.example.web.otel.starter; + +import static com.google.common.truth.Truth.assertThat; + +import no.entur.logging.cloud.gcp.logback.logstash.StackdriverMicrometerTraceMdcJsonProvider; +import no.entur.logging.cloud.logback.logstash.test.CompositeConsoleOutputControl; +import no.entur.logging.cloud.logback.logstash.test.CompositeConsoleOutputControlClosable; +import no.entur.logging.cloud.logback.logstash.test.junit.CaptureLogStatements; +import no.entur.logging.cloud.logback.logstash.test.junit.LogStatement; +import no.entur.logging.cloud.logback.logstash.test.junit.LogStatements; +import org.entur.example.web.rest.MyEntity; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.resttestclient.autoconfigure.AutoConfigureTestRestTemplate; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; +import org.springframework.boot.resttestclient.TestRestTemplate; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) +@AutoConfigureTestRestTemplate +@CaptureLogStatements({"no.entur", "org.entur"}) +public class WebLoggingFormatTest { + + @LocalServerPort + private int randomServerPort; + + @Autowired + private TestRestTemplate restTemplate; + + @Test + public void useHumanReadablePlainEncoderTest(LogStatements statements) throws Exception { + MyEntity entity = new MyEntity(); + entity.setName("Entur"); + entity.setSecret("mySecret"); + + ResponseEntity response = restTemplate.postForEntity("/api/document/some/method", entity, MyEntity.class); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + + assertGcpTrace(statements); + } + + @Test + public void useHumanReadableJsonEncoderTest(LogStatements statements) throws Exception { + MyEntity entity = new MyEntity(); + entity.setName("Entur"); + entity.setSecret("mySecret"); + + try (CompositeConsoleOutputControlClosable c = CompositeConsoleOutputControl.useHumanReadableJsonEncoder()) { + ResponseEntity response = restTemplate.postForEntity("/api/document/some/method", entity, MyEntity.class); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + } + + assertGcpTrace(statements); + } + + @Test + public void useMachineReadableJsonEncoder(LogStatements statements) throws Exception { + MyEntity entity = new MyEntity(); + entity.setName("Entur"); + entity.setSecret("mySecret"); + + try (CompositeConsoleOutputControlClosable c = CompositeConsoleOutputControl.useMachineReadableJsonEncoder()) { + ResponseEntity response = restTemplate.postForEntity("/api/document/some/method", entity, MyEntity.class); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + } + + assertGcpTrace(statements); + } + + public static void assertGcpTrace(LogStatements statements) { + // Wait a bit to ensure that the logs have been flushed and captured + try { + Thread.sleep(100); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + Assertions.assertFalse(statements.isEmpty(), "Expected log statements to be captured, but none were found."); + for (LogStatement statement : statements) { + assertThat(statement.getJsonPropertyString(StackdriverMicrometerTraceMdcJsonProvider.GCP_TRACE_KEY)).hasLength(32); + assertThat(statement.getJsonPropertyString(StackdriverMicrometerTraceMdcJsonProvider.GCP_SPAN_ID_KEY)).hasLength(16); + } + } + +} \ No newline at end of file diff --git a/examples/gcp-web-without-test-artifacts-example/build.gradle b/examples/gcp-web-without-test-artifacts-example/build.gradle index d4e7880a..2b52ce23 100644 --- a/examples/gcp-web-without-test-artifacts-example/build.gradle +++ b/examples/gcp-web-without-test-artifacts-example/build.gradle @@ -26,5 +26,5 @@ dependencies { testImplementation ("com.google.truth:truth:${googleTruthVersion}") testImplementation ("com.google.truth.extensions:truth-java8-extension:${googleTruthVersion}") -} +} \ No newline at end of file diff --git a/examples/gcp-web-without-test-artifacts-example/src/main/java/org/entur/example/web/config/LogConfiguration.java b/examples/gcp-web-without-test-artifacts-example/src/main/java/org/entur/example/web/config/LogConfiguration.java index 4aa4e68a..6d5c3996 100644 --- a/examples/gcp-web-without-test-artifacts-example/src/main/java/org/entur/example/web/config/LogConfiguration.java +++ b/examples/gcp-web-without-test-artifacts-example/src/main/java/org/entur/example/web/config/LogConfiguration.java @@ -19,3 +19,4 @@ public BodyFilter filterBody() { } } + diff --git a/examples/gcp-web-without-test-artifacts-example/src/main/java/org/entur/example/web/rest/DocumentEndpoint.java b/examples/gcp-web-without-test-artifacts-example/src/main/java/org/entur/example/web/rest/DocumentEndpoint.java index 82888fac..6c0c1a15 100644 --- a/examples/gcp-web-without-test-artifacts-example/src/main/java/org/entur/example/web/rest/DocumentEndpoint.java +++ b/examples/gcp-web-without-test-artifacts-example/src/main/java/org/entur/example/web/rest/DocumentEndpoint.java @@ -2,6 +2,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.slf4j.MDC; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; @@ -23,6 +24,8 @@ public MyEntity someMessage(@RequestBody MyEntity entity) { logger.warn("Hello entity with secret / warn"); logger.error("Hello entity with secret / error"); + logger.info("My MDC map is {}", MDC.getCopyOfContextMap()); + entity.setName("Entur response"); return entity; } diff --git a/examples/gcp-web-without-test-artifacts-otel-agent-example/README.md b/examples/gcp-web-without-test-artifacts-otel-agent-example/README.md new file mode 100644 index 00000000..9c77b6ed --- /dev/null +++ b/examples/gcp-web-without-test-artifacts-otel-agent-example/README.md @@ -0,0 +1,4 @@ +# gcp-web-without-test-artifacts-otel-agent-example +Simple REST service example without test dependencies from this project, but with instrumentation for OpenTelemetry. + +This emulates the deployed application (i.e. machine readable JSON). diff --git a/examples/gcp-web-without-test-artifacts-otel-agent-example/build.gradle b/examples/gcp-web-without-test-artifacts-otel-agent-example/build.gradle new file mode 100644 index 00000000..bf02d5f9 --- /dev/null +++ b/examples/gcp-web-without-test-artifacts-otel-agent-example/build.gradle @@ -0,0 +1,60 @@ +plugins { + id 'org.springframework.boot' version '4.1.0' +} + +configurations { + otelAgent +} + +test { + useJUnitPlatform { + includeEngines 'junit-jupiter' + } + + doFirst { + jvmArgs "-javaagent:${configurations.otelAgent.singleFile}" + } + + systemProperty 'otel.traces.exporter', 'logging' + systemProperty 'otel.metrics.exporter', 'none' + systemProperty 'otel.logs.exporter', 'none' + systemProperty 'otel.service.name', 'junit5-tests' + + systemProperty 'otel.instrumentation.http.server.exclude-paths', '/actuator/**' +} + +dependencies { + otelAgent "io.opentelemetry.javaagent:opentelemetry-javaagent:2.30.0" + + implementation project(':on-demand:on-demand-spring-boot-starter-web') + implementation project(":gcp:spring-boot-starter-gcp-web"); + implementation project(":gcp:request-response-spring-boot-starter-gcp-web"); + + implementation("org.springframework.boot:spring-boot-starter-security") + implementation("org.springframework.boot:spring-boot-starter-web") + + testImplementation("org.springframework.boot:spring-boot-starter-test") + testImplementation("org.springframework.boot:spring-boot-restclient") + testImplementation("org.springframework.boot:spring-boot-resttestclient") + + // JUnit Jupiter API and TestEngine implementation + testImplementation("org.junit.jupiter:junit-jupiter-api") + testImplementation("org.junit.jupiter:junit-jupiter-engine") + + testImplementation ("com.google.truth:truth:${googleTruthVersion}") + testImplementation ("com.google.truth.extensions:truth-java8-extension:${googleTruthVersion}") + + testImplementation project(":test:test-logback-junit") + +} + +sourceSets { + main { + java { + srcDirs '../gcp-web-otel-agent-example/src/main/java' + } + resources { + srcDirs '../gcp-web-otel-agent-example/src/main/resources' + } + } +} diff --git a/examples/gcp-web-without-test-artifacts-otel-agent-example/src/test/java/org/entur/example/web/otel/agent/OndemandWebLoggingHttpOkHighLogLevelTest.java b/examples/gcp-web-without-test-artifacts-otel-agent-example/src/test/java/org/entur/example/web/otel/agent/OndemandWebLoggingHttpOkHighLogLevelTest.java new file mode 100644 index 00000000..ec81262c --- /dev/null +++ b/examples/gcp-web-without-test-artifacts-otel-agent-example/src/test/java/org/entur/example/web/otel/agent/OndemandWebLoggingHttpOkHighLogLevelTest.java @@ -0,0 +1,82 @@ +package org.entur.example.web.otel.agent; + +import no.entur.logging.cloud.logback.logstash.test.CompositeConsoleOutputControl; +import no.entur.logging.cloud.logback.logstash.test.CompositeConsoleOutputControlClosable; +import no.entur.logging.cloud.logback.logstash.test.junit.CaptureLogStatements; +import no.entur.logging.cloud.logback.logstash.test.junit.LogStatements; +import org.entur.example.web.rest.MyEntity; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.resttestclient.TestRestTemplate; +import org.springframework.boot.resttestclient.autoconfigure.AutoConfigureTestRestTemplate; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.test.context.TestPropertySource; + +import static com.google.common.truth.Truth.assertThat; + +/** + * + * Test additional logging due to a log statement with high log level. + * + */ + +@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) +@TestPropertySource(properties = { + "entur.logging.http.ondemand.enabled=true", + "entur.logging.http.ondemand.failure.http.enabled=false", + "entur.logging.http.ondemand.failure.logger.level=error", +}) +@AutoConfigureTestRestTemplate +@CaptureLogStatements({"no.entur", "org.entur"}) +public class OndemandWebLoggingHttpOkHighLogLevelTest { + + @LocalServerPort + private int randomServerPort; + + @Autowired + private TestRestTemplate restTemplate; + + @Test + public void useHumanReadablePlainEncoderExpectFullLogging(LogStatements statements) { + MyEntity entity = new MyEntity(); + entity.setName("Entur"); + entity.setSecret("mySecret"); + + ResponseEntity response = restTemplate.postForEntity("/api/document/some/method", entity, MyEntity.class); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + + WebLoggingFormatTest.assertGcpTrace(statements); + } + + @Test + public void useHumanReadableJsonEncoderExpectFullLogging(LogStatements statements) { + MyEntity entity = new MyEntity(); + entity.setName("Entur"); + entity.setSecret("mySecret"); + + try (CompositeConsoleOutputControlClosable c = CompositeConsoleOutputControl.useHumanReadableJsonEncoder()) { + ResponseEntity response = restTemplate.postForEntity("/api/document/some/method", entity, MyEntity.class); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + } + + WebLoggingFormatTest.assertGcpTrace(statements); + } + + @Test + public void useMachineReadableJsonEncoderExpectFullLogging(LogStatements statements) { + MyEntity entity = new MyEntity(); + entity.setName("Entur"); + entity.setSecret("mySecret"); + + try (CompositeConsoleOutputControlClosable c = CompositeConsoleOutputControl.useMachineReadableJsonEncoder()) { + ResponseEntity response = restTemplate.postForEntity("/api/document/some/method", entity, MyEntity.class); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + } + WebLoggingFormatTest.assertGcpTrace(statements); + } + +} \ No newline at end of file diff --git a/examples/gcp-web-without-test-artifacts-otel-agent-example/src/test/java/org/entur/example/web/otel/agent/ProviderSelectionTest.java b/examples/gcp-web-without-test-artifacts-otel-agent-example/src/test/java/org/entur/example/web/otel/agent/ProviderSelectionTest.java new file mode 100644 index 00000000..8cacc4ba --- /dev/null +++ b/examples/gcp-web-without-test-artifacts-otel-agent-example/src/test/java/org/entur/example/web/otel/agent/ProviderSelectionTest.java @@ -0,0 +1,72 @@ +package org.entur.example.web.otel.agent; + +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.LoggerContext; +import ch.qos.logback.core.Appender; +import ch.qos.logback.core.ConsoleAppender; +import ch.qos.logback.core.encoder.Encoder; +import ch.qos.logback.core.spi.AppenderAttachable; +import no.entur.logging.cloud.gcp.logback.logstash.StackdriverLogstashEncoder; +import no.entur.logging.cloud.gcp.logback.logstash.StackdriverMicrometerTraceMdcJsonProvider; +import no.entur.logging.cloud.gcp.logback.logstash.StackdriverOpenTelemetryTraceMdcJsonProvider; +import no.entur.logging.cloud.logback.logstash.test.CompositeConsoleAppender; +import org.junit.jupiter.api.Test; +import org.slf4j.LoggerFactory; +import org.springframework.boot.test.context.SpringBootTest; + +import java.util.Iterator; + +import static com.google.common.truth.Truth.assertThat; + +/** + * Verifies that the {@link StackdriverLogstashEncoder} selects + * {@link StackdriverOpenTelemetryTraceMdcJsonProvider} when the OpenTelemetry Java agent is + * attached (as configured in this module's build.gradle via {@code -javaagent}). + */ +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +public class ProviderSelectionTest { + + @Test + public void encoderUsesOpenTelemetryTraceMdcJsonProvider() { + StackdriverLogstashEncoder encoder = findEncoder(); + assertThat(encoder).isNotNull(); + + boolean hasOtel = encoder.getProviders().getProviders().stream().anyMatch(p -> p instanceof StackdriverOpenTelemetryTraceMdcJsonProvider); + boolean hasMicrometer = encoder.getProviders().getProviders().stream().anyMatch(p -> p instanceof StackdriverMicrometerTraceMdcJsonProvider); + + assertThat(hasOtel).isTrue(); + assertThat(hasMicrometer).isFalse(); + } + + private static StackdriverLogstashEncoder findEncoder() { + LoggerContext ctx = (LoggerContext) LoggerFactory.getILoggerFactory(); + Logger root = ctx.getLogger(org.slf4j.Logger.ROOT_LOGGER_NAME); + return searchForEncoder(root); + } + + @SuppressWarnings("unchecked") + private static StackdriverLogstashEncoder searchForEncoder(AppenderAttachable attachable) { + Iterator> iter = (Iterator) attachable.iteratorForAppenders(); + while (iter.hasNext()) { + Appender appender = iter.next(); + if (appender instanceof CompositeConsoleAppender composite) { + // Test appender: the machine-readable encoder is the StackdriverLogstashEncoder + Encoder enc = composite.getMachineReadableJsonEncoder(); + if (enc instanceof StackdriverLogstashEncoder stackdriverEncoder) { + return stackdriverEncoder; + } + } else if (appender instanceof ConsoleAppender consoleAppender) { + if (consoleAppender.getEncoder() instanceof StackdriverLogstashEncoder enc) { + return enc; + } + } + if (appender instanceof AppenderAttachable nested) { + StackdriverLogstashEncoder result = searchForEncoder(nested); + if (result != null) { + return result; + } + } + } + return null; + } +} diff --git a/examples/gcp-web-without-test-artifacts-otel-agent-example/src/test/java/org/entur/example/web/otel/agent/TraceSampledMdcHandlerAbsenceTest.java b/examples/gcp-web-without-test-artifacts-otel-agent-example/src/test/java/org/entur/example/web/otel/agent/TraceSampledMdcHandlerAbsenceTest.java new file mode 100644 index 00000000..3e500cb9 --- /dev/null +++ b/examples/gcp-web-without-test-artifacts-otel-agent-example/src/test/java/org/entur/example/web/otel/agent/TraceSampledMdcHandlerAbsenceTest.java @@ -0,0 +1,26 @@ +package org.entur.example.web.otel.agent; + +import org.junit.jupiter.api.Test; +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.context.ApplicationContext; + +import static com.google.common.truth.Truth.assertThat; + +/** + * Verifies that {@code TraceSampledMdcHandler} is NOT registered as a bean when the + * OpenTelemetry Java agent is active. The agent provides its own MDC keys and the + * autoconfiguration must not interfere. + */ +@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) +public class TraceSampledMdcHandlerAbsenceTest { + + @Autowired + private ApplicationContext applicationContext; + + @Test + public void traceSampledMdcHandlerBeanIsNotRegisteredWhenOtelAgentIsPresent() { + assertThat(applicationContext.containsBean("traceSampledMdcHandler")).isFalse(); + } +} diff --git a/examples/gcp-web-without-test-artifacts-otel-agent-example/src/test/java/org/entur/example/web/otel/agent/WebLoggingFormatTest.java b/examples/gcp-web-without-test-artifacts-otel-agent-example/src/test/java/org/entur/example/web/otel/agent/WebLoggingFormatTest.java new file mode 100644 index 00000000..5f8999c9 --- /dev/null +++ b/examples/gcp-web-without-test-artifacts-otel-agent-example/src/test/java/org/entur/example/web/otel/agent/WebLoggingFormatTest.java @@ -0,0 +1,58 @@ +package org.entur.example.web.otel.agent; + +import static com.google.common.truth.Truth.assertThat; + +import no.entur.logging.cloud.gcp.logback.logstash.StackdriverMicrometerTraceMdcJsonProvider; +import no.entur.logging.cloud.logback.logstash.test.junit.CaptureLogStatements; +import no.entur.logging.cloud.logback.logstash.test.junit.LogStatement; +import no.entur.logging.cloud.logback.logstash.test.junit.LogStatements; +import org.entur.example.web.rest.MyEntity; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.resttestclient.autoconfigure.AutoConfigureTestRestTemplate; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; +import org.springframework.boot.resttestclient.TestRestTemplate; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) +@AutoConfigureTestRestTemplate +@CaptureLogStatements({"no.entur", "org.entur"}) +public class WebLoggingFormatTest { + + @LocalServerPort + private int randomServerPort; + + @Autowired + private TestRestTemplate restTemplate; + + @Test + public void useMachineReadableJsonEncoder(LogStatements logStatements) { + MyEntity entity = new MyEntity(); + entity.setName("Entur"); + entity.setSecret("mySecret"); + + ResponseEntity response = restTemplate.postForEntity("/api/document/some/method", entity, MyEntity.class); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + + assertGcpTrace(logStatements); + } + + public static void assertGcpTrace(LogStatements statements) { + // Wait a bit to ensure that the logs have been flushed and captured + try { + Thread.sleep(100); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + Assertions.assertFalse(statements.isEmpty(), "Expected log statements to be captured, but none were found."); + for (LogStatement statement : statements) { + assertThat(statement.getJsonPropertyString(StackdriverMicrometerTraceMdcJsonProvider.GCP_TRACE_KEY)).hasLength(32); + assertThat(statement.getJsonPropertyString(StackdriverMicrometerTraceMdcJsonProvider.GCP_SPAN_ID_KEY)).hasLength(16); + } + } + +} \ No newline at end of file diff --git a/examples/gcp-web-without-test-artifacts-otel-starter-example/README.md b/examples/gcp-web-without-test-artifacts-otel-starter-example/README.md new file mode 100644 index 00000000..92611bd5 --- /dev/null +++ b/examples/gcp-web-without-test-artifacts-otel-starter-example/README.md @@ -0,0 +1,4 @@ +# gcp-web-without-test-artifacts-otel-starter-example +Simple REST service example without test dependencies from this project, but with spring boot starter for OpenTelemetry. + +This emulates the deployed application (i.e. machine readable JSON). diff --git a/examples/gcp-web-without-test-artifacts-otel-starter-example/build.gradle b/examples/gcp-web-without-test-artifacts-otel-starter-example/build.gradle new file mode 100644 index 00000000..5b115c56 --- /dev/null +++ b/examples/gcp-web-without-test-artifacts-otel-starter-example/build.gradle @@ -0,0 +1,42 @@ +plugins { + id 'org.springframework.boot' version '4.1.0' +} + +test { + useJUnitPlatform { + includeEngines 'junit-jupiter' + } +} + +dependencies { + implementation project(':on-demand:on-demand-spring-boot-starter-web') + implementation project(":gcp:spring-boot-starter-gcp-web"); + implementation project(":gcp:request-response-spring-boot-starter-gcp-web"); + + implementation("org.springframework.boot:spring-boot-starter-security") + implementation("org.springframework.boot:spring-boot-starter-web") + + implementation 'org.springframework.boot:spring-boot-starter-opentelemetry' + + testImplementation("org.springframework.boot:spring-boot-starter-test") + testImplementation("org.springframework.boot:spring-boot-restclient") + testImplementation("org.springframework.boot:spring-boot-resttestclient") + + // JUnit Jupiter API and TestEngine implementation + testImplementation("org.junit.jupiter:junit-jupiter-api") + testImplementation("org.junit.jupiter:junit-jupiter-engine") + + testImplementation ("com.google.truth:truth:${googleTruthVersion}") + testImplementation ("com.google.truth.extensions:truth-java8-extension:${googleTruthVersion}") + + testImplementation project(":test:test-logback-junit") + +} + +sourceSets { + main { + java { + srcDirs '../gcp-web-otel-starter-example/src/main/java' + } + } +} diff --git a/examples/gcp-web-without-test-artifacts-otel-starter-example/src/main/resources/application.properties b/examples/gcp-web-without-test-artifacts-otel-starter-example/src/main/resources/application.properties new file mode 100644 index 00000000..5708c83b --- /dev/null +++ b/examples/gcp-web-without-test-artifacts-otel-starter-example/src/main/resources/application.properties @@ -0,0 +1,11 @@ +entur.logging.request-response.logger.level=INFO +entur.logging.request-response.logger.name=no.entur.logging.cloud + +# override log level +logging.level.org.entur=debug + +# override log level +logging.level.root=debug + +management.tracing.enabled=true +management.tracing.sampling.probability=1.0 diff --git a/examples/gcp-web-without-test-artifacts-otel-starter-example/src/test/java/org/entur/example/web/otel/agent/OndemandWebLoggingHttpOkHighLogLevelTest.java b/examples/gcp-web-without-test-artifacts-otel-starter-example/src/test/java/org/entur/example/web/otel/agent/OndemandWebLoggingHttpOkHighLogLevelTest.java new file mode 100644 index 00000000..ec81262c --- /dev/null +++ b/examples/gcp-web-without-test-artifacts-otel-starter-example/src/test/java/org/entur/example/web/otel/agent/OndemandWebLoggingHttpOkHighLogLevelTest.java @@ -0,0 +1,82 @@ +package org.entur.example.web.otel.agent; + +import no.entur.logging.cloud.logback.logstash.test.CompositeConsoleOutputControl; +import no.entur.logging.cloud.logback.logstash.test.CompositeConsoleOutputControlClosable; +import no.entur.logging.cloud.logback.logstash.test.junit.CaptureLogStatements; +import no.entur.logging.cloud.logback.logstash.test.junit.LogStatements; +import org.entur.example.web.rest.MyEntity; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.resttestclient.TestRestTemplate; +import org.springframework.boot.resttestclient.autoconfigure.AutoConfigureTestRestTemplate; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.test.context.TestPropertySource; + +import static com.google.common.truth.Truth.assertThat; + +/** + * + * Test additional logging due to a log statement with high log level. + * + */ + +@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) +@TestPropertySource(properties = { + "entur.logging.http.ondemand.enabled=true", + "entur.logging.http.ondemand.failure.http.enabled=false", + "entur.logging.http.ondemand.failure.logger.level=error", +}) +@AutoConfigureTestRestTemplate +@CaptureLogStatements({"no.entur", "org.entur"}) +public class OndemandWebLoggingHttpOkHighLogLevelTest { + + @LocalServerPort + private int randomServerPort; + + @Autowired + private TestRestTemplate restTemplate; + + @Test + public void useHumanReadablePlainEncoderExpectFullLogging(LogStatements statements) { + MyEntity entity = new MyEntity(); + entity.setName("Entur"); + entity.setSecret("mySecret"); + + ResponseEntity response = restTemplate.postForEntity("/api/document/some/method", entity, MyEntity.class); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + + WebLoggingFormatTest.assertGcpTrace(statements); + } + + @Test + public void useHumanReadableJsonEncoderExpectFullLogging(LogStatements statements) { + MyEntity entity = new MyEntity(); + entity.setName("Entur"); + entity.setSecret("mySecret"); + + try (CompositeConsoleOutputControlClosable c = CompositeConsoleOutputControl.useHumanReadableJsonEncoder()) { + ResponseEntity response = restTemplate.postForEntity("/api/document/some/method", entity, MyEntity.class); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + } + + WebLoggingFormatTest.assertGcpTrace(statements); + } + + @Test + public void useMachineReadableJsonEncoderExpectFullLogging(LogStatements statements) { + MyEntity entity = new MyEntity(); + entity.setName("Entur"); + entity.setSecret("mySecret"); + + try (CompositeConsoleOutputControlClosable c = CompositeConsoleOutputControl.useMachineReadableJsonEncoder()) { + ResponseEntity response = restTemplate.postForEntity("/api/document/some/method", entity, MyEntity.class); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + } + WebLoggingFormatTest.assertGcpTrace(statements); + } + +} \ No newline at end of file diff --git a/examples/gcp-web-without-test-artifacts-otel-starter-example/src/test/java/org/entur/example/web/otel/agent/ProviderSelectionTest.java b/examples/gcp-web-without-test-artifacts-otel-starter-example/src/test/java/org/entur/example/web/otel/agent/ProviderSelectionTest.java new file mode 100644 index 00000000..aa47c12d --- /dev/null +++ b/examples/gcp-web-without-test-artifacts-otel-starter-example/src/test/java/org/entur/example/web/otel/agent/ProviderSelectionTest.java @@ -0,0 +1,72 @@ +package org.entur.example.web.otel.agent; + +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.LoggerContext; +import ch.qos.logback.core.Appender; +import ch.qos.logback.core.ConsoleAppender; +import ch.qos.logback.core.encoder.Encoder; +import ch.qos.logback.core.spi.AppenderAttachable; +import no.entur.logging.cloud.gcp.logback.logstash.StackdriverLogstashEncoder; +import no.entur.logging.cloud.gcp.logback.logstash.StackdriverMicrometerTraceMdcJsonProvider; +import no.entur.logging.cloud.gcp.logback.logstash.StackdriverOpenTelemetryTraceMdcJsonProvider; +import no.entur.logging.cloud.logback.logstash.test.CompositeConsoleAppender; +import org.junit.jupiter.api.Test; +import org.slf4j.LoggerFactory; +import org.springframework.boot.test.context.SpringBootTest; + +import java.util.Iterator; + +import static com.google.common.truth.Truth.assertThat; + +/** + * Verifies that the {@link StackdriverLogstashEncoder} selects + * {@link StackdriverMicrometerTraceMdcJsonProvider} when using the Spring Boot OpenTelemetry + * starter (no Java agent attached). This is the default when no OTel agent is present. + */ +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +public class ProviderSelectionTest { + + @Test + public void encoderUsesMicrometerTraceMdcJsonProvider() { + StackdriverLogstashEncoder encoder = findEncoder(); + assertThat(encoder).isNotNull(); + + boolean hasOtel = encoder.getProviders().getProviders().stream().anyMatch(p -> p instanceof StackdriverOpenTelemetryTraceMdcJsonProvider); + boolean hasMicrometer = encoder.getProviders().getProviders().stream().anyMatch(p -> p instanceof StackdriverMicrometerTraceMdcJsonProvider); + + assertThat(hasMicrometer).isTrue(); + assertThat(hasOtel).isFalse(); + } + + private static StackdriverLogstashEncoder findEncoder() { + LoggerContext ctx = (LoggerContext) LoggerFactory.getILoggerFactory(); + Logger root = ctx.getLogger(org.slf4j.Logger.ROOT_LOGGER_NAME); + return searchForEncoder(root); + } + + @SuppressWarnings("unchecked") + private static StackdriverLogstashEncoder searchForEncoder(AppenderAttachable attachable) { + Iterator> iter = (Iterator) attachable.iteratorForAppenders(); + while (iter.hasNext()) { + Appender appender = iter.next(); + if (appender instanceof CompositeConsoleAppender composite) { + // Test appender: the machine-readable encoder is the StackdriverLogstashEncoder + Encoder enc = composite.getMachineReadableJsonEncoder(); + if (enc instanceof StackdriverLogstashEncoder stackdriverEncoder) { + return stackdriverEncoder; + } + } else if (appender instanceof ConsoleAppender consoleAppender) { + if (consoleAppender.getEncoder() instanceof StackdriverLogstashEncoder enc) { + return enc; + } + } + if (appender instanceof AppenderAttachable nested) { + StackdriverLogstashEncoder result = searchForEncoder(nested); + if (result != null) { + return result; + } + } + } + return null; + } +} diff --git a/examples/gcp-web-without-test-artifacts-otel-starter-example/src/test/java/org/entur/example/web/otel/agent/WebLoggingFormatTest.java b/examples/gcp-web-without-test-artifacts-otel-starter-example/src/test/java/org/entur/example/web/otel/agent/WebLoggingFormatTest.java new file mode 100644 index 00000000..deb988b5 --- /dev/null +++ b/examples/gcp-web-without-test-artifacts-otel-starter-example/src/test/java/org/entur/example/web/otel/agent/WebLoggingFormatTest.java @@ -0,0 +1,59 @@ +package org.entur.example.web.otel.agent; + +import static com.google.common.truth.Truth.assertThat; + +import no.entur.logging.cloud.gcp.logback.logstash.StackdriverMicrometerTraceMdcJsonProvider; +import no.entur.logging.cloud.logback.logstash.test.junit.CaptureLogStatements; +import no.entur.logging.cloud.logback.logstash.test.junit.LogStatement; +import no.entur.logging.cloud.logback.logstash.test.junit.LogStatements; +import org.entur.example.web.rest.MyEntity; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.resttestclient.autoconfigure.AutoConfigureTestRestTemplate; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; +import org.springframework.boot.resttestclient.TestRestTemplate; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) +@AutoConfigureTestRestTemplate +@CaptureLogStatements({"no.entur", "org.entur"}) +public class WebLoggingFormatTest { + + @LocalServerPort + private int randomServerPort; + + @Autowired + private TestRestTemplate restTemplate; + + @Test + public void useMachineReadableJsonEncoder(LogStatements logStatements) { + MyEntity entity = new MyEntity(); + entity.setName("Entur"); + entity.setSecret("mySecret"); + + ResponseEntity response = restTemplate.postForEntity("/api/document/some/method", entity, MyEntity.class); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + + assertGcpTrace(logStatements); + } + + public static void assertGcpTrace(LogStatements statements) { + // Wait a bit to ensure that the logs have been flushed and captured + try { + Thread.sleep(100); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + Assertions.assertFalse(statements.isEmpty(), "Expected log statements to be captured, but none were found."); + for (LogStatement statement : statements) { + assertThat(statement.getJsonPropertyString(StackdriverMicrometerTraceMdcJsonProvider.GCP_TRACE_KEY)).hasLength(32); + assertThat(statement.getJsonPropertyString(StackdriverMicrometerTraceMdcJsonProvider.GCP_SPAN_ID_KEY)).hasLength(16); + assertThat(statement.getJsonPropertyBoolean(StackdriverMicrometerTraceMdcJsonProvider.GCP_TRACE_SAMPLED)).isTrue(); + } + } + +} \ No newline at end of file diff --git a/gcp/correlation-id-trace-spring-boot-gcp-grpc/README.md b/gcp/correlation-id-trace-spring-boot-gcp-grpc/README.md index a55cea63..7e4c7aa4 100644 --- a/gcp/correlation-id-trace-spring-boot-gcp-grpc/README.md +++ b/gcp/correlation-id-trace-spring-boot-gcp-grpc/README.md @@ -1,2 +1,4 @@ # GCP trace headers with legacy correlation-id tracing for GCP -Adds `logging.googleapis.com/trace` and `logging.googleapis.com/spanId` to MDC context. \ No newline at end of file +Adds `logging.googleapis.com/trace` and `logging.googleapis.com/spanId` to MDC context. + +Automatically disabled if opentelementry is on the classpath or loaded as an agent. diff --git a/gcp/correlation-id-trace-spring-boot-gcp-grpc/build.gradle b/gcp/correlation-id-trace-spring-boot-gcp-grpc/build.gradle index 10f3bcbb..9ada0e87 100644 --- a/gcp/correlation-id-trace-spring-boot-gcp-grpc/build.gradle +++ b/gcp/correlation-id-trace-spring-boot-gcp-grpc/build.gradle @@ -3,9 +3,10 @@ dependencies { api project(':trace:mdc-context-grpc-netty') api project(':trace:server:correlation-id-trace-grpc-netty') api project(':trace:server:correlation-id-trace-spring-boot-grpc') + api project(':gcp:logback-logstash-encoder-gcp') + api project(':gcp:spring-boot-autoconfigure-gcp') api("org.springframework.boot:spring-boot-autoconfigure") - api "io.grpc:grpc-netty:${grpcNettyVersion}" annotationProcessor "org.springframework.boot:spring-boot-configuration-processor:${springBootVersion}" diff --git a/gcp/correlation-id-trace-spring-boot-gcp-grpc/src/main/java/no/entur/logging/cloud/gcp/trace/spring/grpc/GcpGrpcTraceAutoConfiguration.java b/gcp/correlation-id-trace-spring-boot-gcp-grpc/src/main/java/no/entur/logging/cloud/gcp/trace/spring/grpc/GcpGrpcTraceAutoConfiguration.java index ce4cfbb4..a541e670 100644 --- a/gcp/correlation-id-trace-spring-boot-gcp-grpc/src/main/java/no/entur/logging/cloud/gcp/trace/spring/grpc/GcpGrpcTraceAutoConfiguration.java +++ b/gcp/correlation-id-trace-spring-boot-gcp-grpc/src/main/java/no/entur/logging/cloud/gcp/trace/spring/grpc/GcpGrpcTraceAutoConfiguration.java @@ -1,25 +1,27 @@ package no.entur.logging.cloud.gcp.trace.spring.grpc; +import no.entur.logging.cloud.gcp.spring.NoOpenTelemetryAgentCondition; import no.entur.logging.cloud.gcp.trace.spring.grpc.interceptor.OrderedTraceIdGrpcMdcContextServerInterceptor; import no.entur.logging.cloud.trace.spring.grpc.GrpcCorrelationIdAutoConfiguration; import no.entur.logging.cloud.trace.spring.grpc.interceptor.OrderedCorrelationIdGrpcMdcContextServerInterceptor; -import no.entur.logging.cloud.trace.spring.grpc.properties.GrpcMdcProperties; import org.springframework.boot.autoconfigure.AutoConfigureAfter; import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingClass; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Conditional; import org.springframework.context.annotation.Configuration; @Configuration @AutoConfigureAfter(GrpcCorrelationIdAutoConfiguration.class) public class GcpGrpcTraceAutoConfiguration { + @Deprecated @Bean @ConditionalOnBean(OrderedCorrelationIdGrpcMdcContextServerInterceptor.class) @ConditionalOnMissingBean(OrderedTraceIdGrpcMdcContextServerInterceptor.class) + @ConditionalOnMissingClass("io.opentelemetry.api.OpenTelemetry") // otel spring boot starter + @Conditional(NoOpenTelemetryAgentCondition.class) // otel agent public OrderedTraceIdGrpcMdcContextServerInterceptor orderedTraceIdGrpcMdcContextServerInterceptor(OrderedCorrelationIdGrpcMdcContextServerInterceptor interceptor) { int order = interceptor.getOrder(); diff --git a/gcp/correlation-id-trace-spring-boot-gcp-grpc/src/main/java/no/entur/logging/cloud/gcp/trace/spring/grpc/interceptor/OrderedTraceIdGrpcMdcContextServerInterceptor.java b/gcp/correlation-id-trace-spring-boot-gcp-grpc/src/main/java/no/entur/logging/cloud/gcp/trace/spring/grpc/interceptor/OrderedTraceIdGrpcMdcContextServerInterceptor.java index 9a96cbac..d0b9fd9b 100644 --- a/gcp/correlation-id-trace-spring-boot-gcp-grpc/src/main/java/no/entur/logging/cloud/gcp/trace/spring/grpc/interceptor/OrderedTraceIdGrpcMdcContextServerInterceptor.java +++ b/gcp/correlation-id-trace-spring-boot-gcp-grpc/src/main/java/no/entur/logging/cloud/gcp/trace/spring/grpc/interceptor/OrderedTraceIdGrpcMdcContextServerInterceptor.java @@ -10,6 +10,7 @@ import java.util.concurrent.ThreadLocalRandom; +@Deprecated public class OrderedTraceIdGrpcMdcContextServerInterceptor implements ServerInterceptor, Ordered { // The span ID is expected to be a 16-character, hexadecimal encoding of an 8-byte array and should not be zero. It should be unique within the trace and should, ideally, be generated in a manner that is uniformly random. diff --git a/gcp/correlation-id-trace-spring-boot-gcp-web/README.md b/gcp/correlation-id-trace-spring-boot-gcp-web/README.md index 3516339c..20f3d674 100644 --- a/gcp/correlation-id-trace-spring-boot-gcp-web/README.md +++ b/gcp/correlation-id-trace-spring-boot-gcp-web/README.md @@ -1,2 +1,5 @@ # GCP trace headers with legacy correlation-id tracing -Automatically disabled if opentelementry is on the classpath. \ No newline at end of file +Adds `logging.googleapis.com/trace` and `logging.googleapis.com/spanId` to MDC context. + +Automatically disabled if opentelementry is on the classpath or loaded as an agent. + diff --git a/gcp/correlation-id-trace-spring-boot-gcp-web/build.gradle b/gcp/correlation-id-trace-spring-boot-gcp-web/build.gradle index 2aa45a05..cd29f1d7 100644 --- a/gcp/correlation-id-trace-spring-boot-gcp-web/build.gradle +++ b/gcp/correlation-id-trace-spring-boot-gcp-web/build.gradle @@ -4,6 +4,8 @@ dependencies { api("org.springframework.boot:spring-boot-autoconfigure") api("jakarta.servlet:jakarta.servlet-api") api project(':trace:server:correlation-id-trace-spring-boot-web') + api project(':gcp:logback-logstash-encoder-gcp') + api project(':gcp:spring-boot-autoconfigure-gcp') testImplementation("org.springframework.boot:spring-boot-starter-test") diff --git a/gcp/correlation-id-trace-spring-boot-gcp-web/src/main/java/no/entur/logging/cloud/gcp/trace/spring/web/GcpTraceAutoConfiguration.java b/gcp/correlation-id-trace-spring-boot-gcp-web/src/main/java/no/entur/logging/cloud/gcp/trace/spring/web/GcpTraceAutoConfiguration.java index f3c8d8aa..21d659b0 100644 --- a/gcp/correlation-id-trace-spring-boot-gcp-web/src/main/java/no/entur/logging/cloud/gcp/trace/spring/web/GcpTraceAutoConfiguration.java +++ b/gcp/correlation-id-trace-spring-boot-gcp-web/src/main/java/no/entur/logging/cloud/gcp/trace/spring/web/GcpTraceAutoConfiguration.java @@ -1,17 +1,21 @@ package no.entur.logging.cloud.gcp.trace.spring.web; import jakarta.servlet.DispatcherType; +import no.entur.logging.cloud.gcp.spring.NoOpenTelemetryAgentCondition; import no.entur.logging.cloud.trace.spring.web.CorrelationIdAutoConfiguration; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingClass; import org.springframework.boot.web.servlet.FilterRegistrationBean; import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Conditional; import org.springframework.context.annotation.Configuration; @Configuration public class GcpTraceAutoConfiguration { + @Deprecated @Bean - @ConditionalOnMissingClass("io.opentelemetry.api.OpenTelemetry") + @ConditionalOnMissingClass("io.opentelemetry.api.OpenTelemetry") // otel spring boot starter + @Conditional(NoOpenTelemetryAgentCondition.class) // otel agent public FilterRegistrationBean gcpTraceServletFilter() { FilterRegistrationBean registration = new FilterRegistrationBean<>(); registration.setFilter(new GcpTraceFilter()); diff --git a/gcp/correlation-id-trace-spring-boot-gcp-web/src/main/java/no/entur/logging/cloud/gcp/trace/spring/web/GcpTraceFilter.java b/gcp/correlation-id-trace-spring-boot-gcp-web/src/main/java/no/entur/logging/cloud/gcp/trace/spring/web/GcpTraceFilter.java index 13ff62ce..f6a9f46a 100644 --- a/gcp/correlation-id-trace-spring-boot-gcp-web/src/main/java/no/entur/logging/cloud/gcp/trace/spring/web/GcpTraceFilter.java +++ b/gcp/correlation-id-trace-spring-boot-gcp-web/src/main/java/no/entur/logging/cloud/gcp/trace/spring/web/GcpTraceFilter.java @@ -19,6 +19,7 @@ * See https://cloud.google.com/logging/docs/agent/logging/configuration#special_fields_in_structured_payloads */ +@Deprecated public class GcpTraceFilter implements Filter { // The span ID is expected to be a 16-character, hexadecimal encoding of an 8-byte array and should not be zero. It should be unique within the trace and should, ideally, be generated in a manner that is uniformly random. diff --git a/gcp/correlation-id-trace-spring-boot-gcp-web/src/main/java/no/entur/logging/cloud/gcp/trace/spring/web/GcpTraceMdcSupport.java b/gcp/correlation-id-trace-spring-boot-gcp-web/src/main/java/no/entur/logging/cloud/gcp/trace/spring/web/GcpTraceMdcSupport.java index ab53370d..f413e44a 100644 --- a/gcp/correlation-id-trace-spring-boot-gcp-web/src/main/java/no/entur/logging/cloud/gcp/trace/spring/web/GcpTraceMdcSupport.java +++ b/gcp/correlation-id-trace-spring-boot-gcp-web/src/main/java/no/entur/logging/cloud/gcp/trace/spring/web/GcpTraceMdcSupport.java @@ -14,6 +14,7 @@ * */ +@Deprecated public class GcpTraceMdcSupport implements Closeable { public static final String REQUEST_ID_MDC_KEY = CorrelationIdFilter.REQUEST_ID_MDC_KEY; diff --git a/gcp/correlation-id-trace-spring-boot-gcp-web/src/main/java/no/entur/logging/cloud/gcp/trace/spring/web/GcpTraceMdcSupportBuilder.java b/gcp/correlation-id-trace-spring-boot-gcp-web/src/main/java/no/entur/logging/cloud/gcp/trace/spring/web/GcpTraceMdcSupportBuilder.java index a929ed55..492474c1 100644 --- a/gcp/correlation-id-trace-spring-boot-gcp-web/src/main/java/no/entur/logging/cloud/gcp/trace/spring/web/GcpTraceMdcSupportBuilder.java +++ b/gcp/correlation-id-trace-spring-boot-gcp-web/src/main/java/no/entur/logging/cloud/gcp/trace/spring/web/GcpTraceMdcSupportBuilder.java @@ -5,6 +5,7 @@ import java.util.UUID; import java.util.concurrent.ThreadLocalRandom; +@Deprecated public class GcpTraceMdcSupportBuilder { protected static final boolean[] VALUE_CHARACTERS; diff --git a/gcp/logback-logstash-encoder-gcp/src/main/java/no/entur/logging/cloud/gcp/logback/logstash/StackdriverLogstashEncoder.java b/gcp/logback-logstash-encoder-gcp/src/main/java/no/entur/logging/cloud/gcp/logback/logstash/StackdriverLogstashEncoder.java index f1f8f83b..7026f3ba 100644 --- a/gcp/logback-logstash-encoder-gcp/src/main/java/no/entur/logging/cloud/gcp/logback/logstash/StackdriverLogstashEncoder.java +++ b/gcp/logback-logstash-encoder-gcp/src/main/java/no/entur/logging/cloud/gcp/logback/logstash/StackdriverLogstashEncoder.java @@ -47,11 +47,12 @@ protected AbstractCompositeJsonFormatter createFormatter() { } else if(jsonProvider instanceof MdcJsonProvider p) { loggingEventJsonProviders.removeProvider(jsonProvider); - boolean openTelemetry = detectOpenTelemetry(); - if (openTelemetry) { - loggingEventJsonProviders.addProvider(new StackdriverOpenTelemetryTraceMdcJsonProvider()); + String projectId = resolveProjectId(); + + if(StackdriverOpenTelemetryTraceMdcJsonProvider.isOtelAgent()) { + loggingEventJsonProviders.addProvider(new StackdriverOpenTelemetryTraceMdcJsonProvider(projectId)); } else { - loggingEventJsonProviders.addProvider(new SimpleMdcJsonProvider()); + loggingEventJsonProviders.addProvider(new StackdriverMicrometerTraceMdcJsonProvider(projectId)); } } } @@ -62,12 +63,13 @@ protected AbstractCompositeJsonFormatter createFormatter() { return formatter; } - private boolean detectOpenTelemetry() { - try { - Class.forName("io.opentelemetry.api.OpenTelemetry"); - return true; - } catch (Exception e) { - return false; - } + private static String resolveProjectId() { + for (String envName : new String[]{"GOOGLE_CLOUD_PROJECT", "GCP_PROJECT_ID"}) { + String projectId = System.getenv(envName); + if (projectId != null && !projectId.isBlank()) { + return projectId; + } + } + return null; } } diff --git a/gcp/logback-logstash-encoder-gcp/src/main/java/no/entur/logging/cloud/gcp/logback/logstash/StackdriverMicrometerTraceMdcJsonProvider.java b/gcp/logback-logstash-encoder-gcp/src/main/java/no/entur/logging/cloud/gcp/logback/logstash/StackdriverMicrometerTraceMdcJsonProvider.java new file mode 100644 index 00000000..b001242d --- /dev/null +++ b/gcp/logback-logstash-encoder-gcp/src/main/java/no/entur/logging/cloud/gcp/logback/logstash/StackdriverMicrometerTraceMdcJsonProvider.java @@ -0,0 +1,66 @@ +package no.entur.logging.cloud.gcp.logback.logstash; + +import ch.qos.logback.classic.spi.ILoggingEvent; +import net.logstash.logback.composite.AbstractJsonProvider; +import tools.jackson.core.JsonGenerator; + +import java.util.Map; + +/** + * An MDC provider that maps Micrometer tracing MDC keys to the special JSON fields + * recognized by Google Cloud Logging. + * + *

When the Google Cloud Logging agent ingests structured JSON written to stdout, it promotes + * recognized JSON fields into the corresponding {@code LogEntry} fields. In particular: + *

    + *
  • {@code logging.googleapis.com/trace} becomes {@code LogEntry.trace}
  • + *
  • {@code logging.googleapis.com/spanId} becomes {@code LogEntry.spanId}
  • + *
+ * Unrecognized fields remain in {@code LogEntry.jsonPayload}. + * + * @see + * Special fields in structured payloads + * + */ +public class StackdriverMicrometerTraceMdcJsonProvider extends AbstractJsonProvider { + + public static final String MICROMETER_TRACE_ID_KEY = "traceId"; + public static final String MICROMETER_SPAN_ID_KEY = "spanId"; + public static final String MICROMETER_SAMPLED_KEY = "traceSampled"; + + public static final String GCP_TRACE_KEY = "logging.googleapis.com/trace"; + public static final String GCP_SPAN_ID_KEY = "logging.googleapis.com/spanId"; + public static final String GCP_TRACE_SAMPLED = "logging.googleapis.com/trace_sampled"; + + protected final String tracePrefix; + + public StackdriverMicrometerTraceMdcJsonProvider(String projectId) { + this.tracePrefix = projectId != null ? "projects/" + projectId + "/traces/" : null; + } + + @Override + public void writeTo(JsonGenerator generator, ILoggingEvent event) { + Map mdcProperties = event.getMDCPropertyMap(); + if (mdcProperties == null || mdcProperties.isEmpty()) { + return; + } + + // map micrometer MDC keys to GCP special fields; write all others as-is. + for (Map.Entry entry : mdcProperties.entrySet()) { + String key = entry.getKey(); + if (key == null) continue; + String value = entry.getValue(); + if (value == null) continue; + + switch (key) { + case MICROMETER_TRACE_ID_KEY -> generator.writeStringProperty(GCP_TRACE_KEY, tracePrefix != null ? tracePrefix + value : value); + case MICROMETER_SPAN_ID_KEY -> generator.writeStringProperty(GCP_SPAN_ID_KEY, value); + case MICROMETER_SAMPLED_KEY -> { + generator.writeBooleanProperty(GCP_TRACE_SAMPLED, Boolean.parseBoolean(value)); + } + default -> generator.writeStringProperty(key, value); + } + } + } + +} diff --git a/gcp/logback-logstash-encoder-gcp/src/main/java/no/entur/logging/cloud/gcp/logback/logstash/StackdriverOpenTelemetryTraceMdcJsonProvider.java b/gcp/logback-logstash-encoder-gcp/src/main/java/no/entur/logging/cloud/gcp/logback/logstash/StackdriverOpenTelemetryTraceMdcJsonProvider.java index 101fdf81..4579ddea 100644 --- a/gcp/logback-logstash-encoder-gcp/src/main/java/no/entur/logging/cloud/gcp/logback/logstash/StackdriverOpenTelemetryTraceMdcJsonProvider.java +++ b/gcp/logback-logstash-encoder-gcp/src/main/java/no/entur/logging/cloud/gcp/logback/logstash/StackdriverOpenTelemetryTraceMdcJsonProvider.java @@ -4,34 +4,105 @@ import tools.jackson.core.JsonGenerator; import net.logstash.logback.composite.AbstractJsonProvider; -import java.io.IOException; +import java.lang.management.ManagementFactory; +import java.util.List; import java.util.Map; /** + * An MDC provider that maps OpenTelemetry trace fields to the special JSON fields + * recognized by Google Cloud Logging. * - * A simple MDC provider. Renames MDC field name traceId to trace. + *

When the Google Cloud Logging agent ingests structured JSON written to stdout, it promotes + * recognized JSON fields into the corresponding {@code LogEntry} fields. In particular: + *

    + *
  • {@code logging.googleapis.com/trace} becomes {@code LogEntry.trace}
  • + *
  • {@code logging.googleapis.com/spanId} becomes {@code LogEntry.spanId}
  • + *
+ * Unrecognized fields remain in {@code LogEntry.jsonPayload}. * + * @see + * Special fields in structured payloads + * */ - public class StackdriverOpenTelemetryTraceMdcJsonProvider extends AbstractJsonProvider { + public static final String OPENTELEMETRY_TRACE_ID_KEY = "trace_id"; + public static final String OPENTELEMETRY_SPAN_ID_KEY = "span_id"; + public static final String OPENTELEMETRY_TRACE_FLAGS_KEY = "trace_flags"; + + public static final String GCP_TRACE_KEY = "logging.googleapis.com/trace"; + public static final String GCP_SPAN_ID_KEY = "logging.googleapis.com/spanId"; + public static final String GCP_TRACE_SAMPLED = "logging.googleapis.com/trace_sampled"; + + protected final String tracePrefix; + + public StackdriverOpenTelemetryTraceMdcJsonProvider(String projectId) { + this.tracePrefix = projectId != null ? "projects/" + projectId + "/traces/" : null; + } + @Override public void writeTo(JsonGenerator generator, ILoggingEvent event) { Map mdcProperties = event.getMDCPropertyMap(); - if (mdcProperties != null && !mdcProperties.isEmpty()) { - String traceId = mdcProperties.get("traceId"); - if(traceId != null) { - generator.writeStringProperty("trace", traceId); - } - for (Map.Entry entry : mdcProperties.entrySet()) { - String key = entry.getKey(); - String value = entry.getValue(); - if(key == null || value == null) { - continue; + if (mdcProperties == null || mdcProperties.isEmpty()) { + return; + } + + // map OTel MDC keys to GCP special fields; write all others as-is. + for (Map.Entry entry : mdcProperties.entrySet()) { + String key = entry.getKey(); + if (key == null) continue; + String value = entry.getValue(); + if (value == null) continue; + + switch (key) { + case OPENTELEMETRY_TRACE_ID_KEY -> generator.writeStringProperty(GCP_TRACE_KEY, tracePrefix != null ? tracePrefix + value : value); + case OPENTELEMETRY_SPAN_ID_KEY -> generator.writeStringProperty(GCP_SPAN_ID_KEY, value); + case OPENTELEMETRY_TRACE_FLAGS_KEY -> { + if (isSampled(value)) { + generator.writeBooleanProperty(GCP_TRACE_SAMPLED, true); + } } - generator.writeStringProperty(key, value); + default -> generator.writeStringProperty(key, value); + } + } + } + + // W3C trace-flags is a 2-character hex byte; bit 0 is the "sampled" flag. + private static boolean isSampled(String traceFlags) { + if (traceFlags.length() != 2) return false; + char last = traceFlags.charAt(1); + // '1' (0x01) and '3' (0x03) have bit 0 set. also handle any future values + switch(last) { + case '0': case '2': case '4': case '6': + case '8': case 'a': case 'c': case 'e': + case 'A': case 'C': case 'E': + return false; + default: + return true; + } + } + + public static boolean isOtelAgent() { + // 1. Check direct JVM command-line arguments (-javaagent) + List jvmArgs = ManagementFactory.getRuntimeMXBean().getInputArguments(); + for (String arg : jvmArgs) { + if (isOtelArgument(arg)) { + return true; } } + + // 2. Backup check for environment variables that inject JVM arguments + String javaToolOptions = System.getenv("JAVA_TOOL_OPTIONS"); + if (javaToolOptions != null && isOtelArgument(javaToolOptions)) { + return true; + } + + return false; + } + + private static boolean isOtelArgument(String argument) { + String lowerArg = argument.toLowerCase(); + return lowerArg.contains("-javaagent:") && lowerArg.contains("opentelemetry"); } } diff --git a/gcp/logback-logstash-encoder-gcp/src/test/java/no/entur/logging/cloud/gcp/logback/logstash/StackdriverMicrometerTraceMdcJsonProviderTest.java b/gcp/logback-logstash-encoder-gcp/src/test/java/no/entur/logging/cloud/gcp/logback/logstash/StackdriverMicrometerTraceMdcJsonProviderTest.java new file mode 100644 index 00000000..e2ffd50a --- /dev/null +++ b/gcp/logback-logstash-encoder-gcp/src/test/java/no/entur/logging/cloud/gcp/logback/logstash/StackdriverMicrometerTraceMdcJsonProviderTest.java @@ -0,0 +1,67 @@ +package no.entur.logging.cloud.gcp.logback.logstash; + +import ch.qos.logback.classic.spi.ILoggingEvent; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import tools.jackson.core.JsonGenerator; +import tools.jackson.core.json.JsonFactory; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.json.JsonMapper; + +import java.io.StringWriter; +import java.util.LinkedHashMap; +import java.util.Map; + +import static com.google.common.truth.Truth.assertThat; + +public class StackdriverMicrometerTraceMdcJsonProviderTest { + + private static final JsonMapper MAPPER = JsonMapper.builder().build(); + + @Test + void writeTo_openTelemetryTraceFields_mappedToGcpSpecialFields() throws Exception { + Map mdc = new LinkedHashMap<>(); + mdc.put(StackdriverMicrometerTraceMdcJsonProvider.MICROMETER_TRACE_ID_KEY, "06796866738c859f2f19b7cfb3214824"); + mdc.put(StackdriverMicrometerTraceMdcJsonProvider.MICROMETER_SPAN_ID_KEY, "000000000000004a"); + mdc.put("correlationId", "abc123"); + + JsonNode root = write(mdc); + + assertThat(root.get("logging.googleapis.com/trace").asText()) + .isEqualTo("projects/myProject/traces/06796866738c859f2f19b7cfb3214824"); + assertThat(root.get("logging.googleapis.com/spanId").asText()) + .isEqualTo("000000000000004a"); + assertThat(root.get("correlationId").asText()).isEqualTo("abc123"); + assertThat(root.has("trace")).isFalse(); + assertThat(root.has("traceId")).isFalse(); + assertThat(root.has("spanId")).isFalse(); + } + + @Test + void writeTo_existingGcpTraceFields_preservedWithoutOpenTelemetryValues() throws Exception { + Map mdc = new LinkedHashMap<>(); + mdc.put("logging.googleapis.com/trace", "existing-trace"); + mdc.put("logging.googleapis.com/spanId", "existing-span"); + + JsonNode root = write(mdc); + + assertThat(root.get("logging.googleapis.com/trace").asText()).isEqualTo("existing-trace"); + assertThat(root.get("logging.googleapis.com/spanId").asText()).isEqualTo("existing-span"); + } + + private static JsonNode write(Map mdcMap) throws Exception { + StackdriverMicrometerTraceMdcJsonProvider provider = + new StackdriverMicrometerTraceMdcJsonProvider("myProject"); + ILoggingEvent event = Mockito.mock(ILoggingEvent.class); + Mockito.when(event.getMDCPropertyMap()).thenReturn(mdcMap); + + StringWriter stringWriter = new StringWriter(); + JsonFactory factory = new JsonFactory(); + try (JsonGenerator generator = factory.createGenerator(stringWriter)) { + generator.writeStartObject(); + provider.writeTo(generator, event); + generator.writeEndObject(); + } + return MAPPER.readTree(stringWriter.toString()); + } +} diff --git a/gcp/logback-logstash-encoder-gcp/src/test/java/no/entur/logging/cloud/gcp/logback/logstash/StackdriverOpenTelemetryTraceMdcJsonProviderTest.java b/gcp/logback-logstash-encoder-gcp/src/test/java/no/entur/logging/cloud/gcp/logback/logstash/StackdriverOpenTelemetryTraceMdcJsonProviderTest.java new file mode 100644 index 00000000..8eddd424 --- /dev/null +++ b/gcp/logback-logstash-encoder-gcp/src/test/java/no/entur/logging/cloud/gcp/logback/logstash/StackdriverOpenTelemetryTraceMdcJsonProviderTest.java @@ -0,0 +1,67 @@ +package no.entur.logging.cloud.gcp.logback.logstash; + +import ch.qos.logback.classic.spi.ILoggingEvent; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import tools.jackson.core.JsonGenerator; +import tools.jackson.core.json.JsonFactory; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.json.JsonMapper; + +import java.io.StringWriter; +import java.util.LinkedHashMap; +import java.util.Map; + +import static com.google.common.truth.Truth.assertThat; + +public class StackdriverOpenTelemetryTraceMdcJsonProviderTest { + + private static final JsonMapper MAPPER = JsonMapper.builder().build(); + + @Test + void writeTo_openTelemetryTraceFields_mappedToGcpSpecialFields() throws Exception { + Map mdc = new LinkedHashMap<>(); + mdc.put(StackdriverOpenTelemetryTraceMdcJsonProvider.OPENTELEMETRY_TRACE_ID_KEY, "06796866738c859f2f19b7cfb3214824"); + mdc.put(StackdriverOpenTelemetryTraceMdcJsonProvider.OPENTELEMETRY_SPAN_ID_KEY, "000000000000004a"); + mdc.put("correlationId", "abc123"); + + JsonNode root = write(mdc); + + assertThat(root.get("logging.googleapis.com/trace").asText()) + .isEqualTo("projects/myProject/traces/06796866738c859f2f19b7cfb3214824"); + assertThat(root.get("logging.googleapis.com/spanId").asText()) + .isEqualTo("000000000000004a"); + assertThat(root.get("correlationId").asText()).isEqualTo("abc123"); + assertThat(root.has("trace")).isFalse(); + assertThat(root.has("traceId")).isFalse(); + assertThat(root.has("spanId")).isFalse(); + } + + @Test + void writeTo_existingGcpTraceFields_preservedWithoutOpenTelemetryValues() throws Exception { + Map mdc = new LinkedHashMap<>(); + mdc.put("logging.googleapis.com/trace", "existing-trace"); + mdc.put("logging.googleapis.com/spanId", "existing-span"); + + JsonNode root = write(mdc); + + assertThat(root.get("logging.googleapis.com/trace").asText()).isEqualTo("existing-trace"); + assertThat(root.get("logging.googleapis.com/spanId").asText()).isEqualTo("existing-span"); + } + + private static JsonNode write(Map mdcMap) throws Exception { + StackdriverOpenTelemetryTraceMdcJsonProvider provider = + new StackdriverOpenTelemetryTraceMdcJsonProvider("myProject"); + ILoggingEvent event = Mockito.mock(ILoggingEvent.class); + Mockito.when(event.getMDCPropertyMap()).thenReturn(mdcMap); + + StringWriter stringWriter = new StringWriter(); + JsonFactory factory = new JsonFactory(); + try (JsonGenerator generator = factory.createGenerator(stringWriter)) { + generator.writeStartObject(); + provider.writeTo(generator, event); + generator.writeEndObject(); + } + return MAPPER.readTree(stringWriter.toString()); + } +} diff --git a/gcp/spring-boot-autoconfigure-gcp-test/src/main/resources/logback/spring-defaults-test.xml b/gcp/spring-boot-autoconfigure-gcp-test/src/main/resources/logback/spring-defaults-test.xml index 6e8ca452..7dea13c0 100644 --- a/gcp/spring-boot-autoconfigure-gcp-test/src/main/resources/logback/spring-defaults-test.xml +++ b/gcp/spring-boot-autoconfigure-gcp-test/src/main/resources/logback/spring-defaults-test.xml @@ -12,7 +12,7 @@ Default logback configuration provided for import by spring, modified to give me - + diff --git a/gcp/spring-boot-autoconfigure-gcp/build.gradle b/gcp/spring-boot-autoconfigure-gcp/build.gradle index beaa5ca4..fdb0187f 100644 --- a/gcp/spring-boot-autoconfigure-gcp/build.gradle +++ b/gcp/spring-boot-autoconfigure-gcp/build.gradle @@ -5,8 +5,6 @@ dependencies { api project(':appender') api project(':on-demand:on-demand-spring-boot-autoconfigure') - api project(':trace:server:correlation-id-trace-spring-boot-web') - api("org.slf4j:slf4j-api") api ("org.codehaus.janino:commons-compiler") api ("org.codehaus.janino:commons-compiler-jdk") @@ -19,6 +17,8 @@ dependencies { api ("org.codehaus.janino:janino") + compileOnly("io.micrometer:micrometer-tracing") + testImplementation("org.springframework.boot:spring-boot-starter-test") } diff --git a/gcp/spring-boot-autoconfigure-gcp/src/main/java/no/entur/logging/cloud/gcp/spring/GcpMicrometerTraceAutoConfiguration.java b/gcp/spring-boot-autoconfigure-gcp/src/main/java/no/entur/logging/cloud/gcp/spring/GcpMicrometerTraceAutoConfiguration.java new file mode 100644 index 00000000..533262ca --- /dev/null +++ b/gcp/spring-boot-autoconfigure-gcp/src/main/java/no/entur/logging/cloud/gcp/spring/GcpMicrometerTraceAutoConfiguration.java @@ -0,0 +1,42 @@ +package no.entur.logging.cloud.gcp.spring; + +import io.micrometer.tracing.Tracer; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Conditional; + +/** + * Autoconfiguration that registers {@link TraceSampledMdcHandler} when: + *
    + *
  • {@code io.micrometer:micrometer-tracing} is on the classpath ({@code Tracer} class present), and
  • + *
  • the OpenTelemetry Java agent is not attached.
  • + *
+ * Both conditions are evaluated at class level so that the entire configuration is skipped when + * either condition is not met. + * + *

When the OTel agent is used, the agent writes trace context to MDC using its own keys, + * handled by {@code StackdriverOpenTelemetryTraceMdcJsonProvider}. In that case this + * configuration must not interfere. + * + *

{@code @AutoConfigureAfter} on the tracing autoconfiguration name ensures that the + * {@link Tracer} bean is available for injection when this configuration is processed. + */ +@AutoConfiguration(afterName = "org.springframework.boot.micrometer.tracing.autoconfigure.TracingAutoConfiguration") +@ConditionalOnClass(Tracer.class) +@Conditional(NoOpenTelemetryAgentCondition.class) +public class GcpMicrometerTraceAutoConfiguration { + + /** + * Registers {@link TraceSampledMdcHandler}. + * + *

{@link ObjectProvider} is used so that the bean is a graceful no-op when + * micrometer-tracing is on the classpath but no {@link Tracer} bean exists + * (e.g. when {@code management.tracing.enabled=false}). + */ + @Bean + public TraceSampledMdcHandler traceSampledMdcHandler(ObjectProvider tracerProvider) { + return new TraceSampledMdcHandler(tracerProvider.getIfAvailable()); + } +} diff --git a/gcp/spring-boot-autoconfigure-gcp/src/main/java/no/entur/logging/cloud/gcp/spring/NoOpenTelemetryAgentCondition.java b/gcp/spring-boot-autoconfigure-gcp/src/main/java/no/entur/logging/cloud/gcp/spring/NoOpenTelemetryAgentCondition.java new file mode 100644 index 00000000..ce1adc8b --- /dev/null +++ b/gcp/spring-boot-autoconfigure-gcp/src/main/java/no/entur/logging/cloud/gcp/spring/NoOpenTelemetryAgentCondition.java @@ -0,0 +1,16 @@ +package no.entur.logging.cloud.gcp.spring; + +import no.entur.logging.cloud.gcp.logback.logstash.StackdriverOpenTelemetryTraceMdcJsonProvider; +import org.springframework.context.annotation.Condition; +import org.springframework.context.annotation.ConditionContext; +import org.springframework.core.type.AnnotatedTypeMetadata; + +/** + * Condition that matches when the OpenTelemetry Java agent is not present. + */ +public class NoOpenTelemetryAgentCondition implements Condition { + @Override + public boolean matches(ConditionContext context, AnnotatedTypeMetadata metadata) { + return !StackdriverOpenTelemetryTraceMdcJsonProvider.isOtelAgent(); + } +} diff --git a/gcp/spring-boot-autoconfigure-gcp/src/main/java/no/entur/logging/cloud/gcp/spring/TraceSampledMdcHandler.java b/gcp/spring-boot-autoconfigure-gcp/src/main/java/no/entur/logging/cloud/gcp/spring/TraceSampledMdcHandler.java new file mode 100644 index 00000000..0b73b013 --- /dev/null +++ b/gcp/spring-boot-autoconfigure-gcp/src/main/java/no/entur/logging/cloud/gcp/spring/TraceSampledMdcHandler.java @@ -0,0 +1,48 @@ +package no.entur.logging.cloud.gcp.spring; + +import io.micrometer.tracing.Tracer; +import io.micrometer.observation.Observation; +import io.micrometer.observation.ObservationHandler; +import io.micrometer.tracing.TraceContext; +import no.entur.logging.cloud.gcp.logback.logstash.StackdriverMicrometerTraceMdcJsonProvider; +import org.slf4j.MDC; + +/** + * Adds the trace-sampled flag to the SLF4J MDC so that + * {@link StackdriverMicrometerTraceMdcJsonProvider} can map it to the + * {@code logging.googleapis.com/trace_sampled} JSON field recognised by GCP Cloud Logging. + * + *

Registered automatically when: + *

    + *
  • {@code io.micrometer:micrometer-tracing} is on the classpath, and
  • + *
  • the OpenTelemetry Java agent is not attached (the agent provides its own MDC keys).
  • + *
+ * See {@link GcpMicrometerTraceAutoConfiguration} for conditional bean registration. + */ +public class TraceSampledMdcHandler implements ObservationHandler { + + private final Tracer tracer; + + public TraceSampledMdcHandler(Tracer tracer) { + this.tracer = tracer; + } + + @Override + public void onScopeOpened(Observation.Context context) { + if (tracer == null) return; + TraceContext traceContext = this.tracer.currentTraceContext().context(); + if (traceContext != null && Boolean.TRUE.equals(traceContext.sampled())) { + MDC.put(StackdriverMicrometerTraceMdcJsonProvider.MICROMETER_SAMPLED_KEY, "true"); + } + } + + @Override + public void onScopeClosed(Observation.Context context) { + MDC.remove(StackdriverMicrometerTraceMdcJsonProvider.MICROMETER_SAMPLED_KEY); + } + + @Override + public boolean supportsContext(Observation.Context context) { + return true; + } +} diff --git a/gcp/spring-boot-autoconfigure-gcp/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/gcp/spring-boot-autoconfigure-gcp/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports index a739feba..3896d501 100644 --- a/gcp/spring-boot-autoconfigure-gcp/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports +++ b/gcp/spring-boot-autoconfigure-gcp/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -1 +1,2 @@ -no.entur.logging.cloud.gcp.spring.GcpLoggingAutoConfiguration \ No newline at end of file +no.entur.logging.cloud.gcp.spring.GcpLoggingAutoConfiguration +no.entur.logging.cloud.gcp.spring.GcpMicrometerTraceAutoConfiguration \ No newline at end of file diff --git a/settings.gradle b/settings.gradle index a46cfbfd..e7f2e6cb 100644 --- a/settings.gradle +++ b/settings.gradle @@ -47,3 +47,7 @@ include 'examples:gcp-web-example', 'examples:gcp-async-web-example', 'examples: include 'examples:gcp-grpc-spring-example', 'examples:gcp-web-apache-client-example' // Azure include 'examples:azure-web-example', 'examples:azure-grpc-spring-example', 'examples:gcp-grpc-spring-without-test-artifacts-example' + +// OTEL +include 'examples:gcp-web-without-test-artifacts-otel-agent-example', 'examples:gcp-web-otel-starter-example', 'examples:gcp-web-without-test-artifacts-otel-starter-example', 'examples:gcp-web-otel-agent-example', 'examples:gcp-grpc-spring-without-test-artifacts-otel-agent-example', 'examples:gcp-grpc-spring-without-test-artifacts-otel-starter-example' +include 'examples:gcp-grpc-spring-otel-agent-example', 'examples:gcp-grpc-spring-otel-starter-example' \ No newline at end of file diff --git a/test/test-logback-junit/src/main/java/no/entur/logging/cloud/logback/logstash/test/junit/LogStatement.java b/test/test-logback-junit/src/main/java/no/entur/logging/cloud/logback/logstash/test/junit/LogStatement.java index 3ba32467..a61080a9 100644 --- a/test/test-logback-junit/src/main/java/no/entur/logging/cloud/logback/logstash/test/junit/LogStatement.java +++ b/test/test-logback-junit/src/main/java/no/entur/logging/cloud/logback/logstash/test/junit/LogStatement.java @@ -6,6 +6,11 @@ import com.toomuchcoding.jsonassert.JsonVerifiable; import net.logstash.logback.encoder.CompositeJsonEncoder; import net.logstash.logback.encoder.LogstashEncoder; +import tools.jackson.core.JacksonException; +import tools.jackson.core.JsonParser; +import tools.jackson.core.JsonToken; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; import java.nio.charset.StandardCharsets; import java.util.Comparator; @@ -13,6 +18,8 @@ public class LogStatement { + protected static final ObjectMapper objectMapper = new ObjectMapper(); + protected final static Comparator logStatementTimestampComparator = new Comparator() { @Override @@ -120,4 +127,60 @@ public JsonVerifiable assertThatHttpUri(String name) { return JsonAssertion.assertThat(getJson()).field("http").field("uri"); } + public JsonNode getJsonAsNode() { + try { + return objectMapper.readTree(getJson()); + } catch (JacksonException e) { + throw new RuntimeException("Failed to parse JSON", e); + } + } + + public String getJsonPropertyString(String name) { + try { + try (JsonParser parser = objectMapper.createParser(getJson())) { + while (parser.nextToken() != null) { + if (parser.currentToken() == JsonToken.PROPERTY_NAME) { + String currentFieldName = parser.currentName(); + + if (name.equals(currentFieldName)) { + if(JsonToken.VALUE_STRING != parser.nextToken()) { + throw new IllegalArgumentException("Expected a string value for field '" + name + "' but found: " + parser.currentToken()); + } + return parser.getString(); + } + } + } + } + return null; + } catch (JacksonException e) { + throw new RuntimeException("Failed to parse JSON", e); + } + } + + public Boolean getJsonPropertyBoolean(String name) { + try { + try (JsonParser parser = objectMapper.createParser(getJson())) { + while (parser.nextToken() != null) { + if (parser.currentToken() == JsonToken.PROPERTY_NAME) { + String currentFieldName = parser.currentName(); + + if (name.equals(currentFieldName)) { + JsonToken token = parser.nextToken(); + if (token == JsonToken.VALUE_TRUE) { + return Boolean.TRUE; + } else if (token == JsonToken.VALUE_FALSE) { + return Boolean.FALSE; + } else { + throw new IllegalArgumentException("Expected a boolean value for field '" + name + "' but found: " + token); + } + } + } + } + } + return null; + } catch (JacksonException e) { + throw new RuntimeException("Failed to parse JSON", e); + } + } + }