diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts index e91462f1..ae9d44e2 100644 --- a/buildSrc/build.gradle.kts +++ b/buildSrc/build.gradle.kts @@ -9,6 +9,7 @@ repositories { } dependencies { + implementation("biz.aQute.bnd:biz.aQute.bnd.gradle:7.3.0") implementation("com.diffplug.spotless:spotless-plugin-gradle:8.9.0") implementation("ru.vyarus:gradle-animalsniffer-plugin:2.0.1") implementation("me.champeau.gradle:japicmp-gradle-plugin:0.4.6") diff --git a/buildSrc/src/main/kotlin/io/opentelemetry/gradle/OtelVersions.kt b/buildSrc/src/main/kotlin/io/opentelemetry/gradle/OtelVersions.kt new file mode 100644 index 00000000..8496ba5a --- /dev/null +++ b/buildSrc/src/main/kotlin/io/opentelemetry/gradle/OtelVersions.kt @@ -0,0 +1,29 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.gradle + +/** Shared version constants used across build scripts and convention plugins. */ +object OtelVersions { + /** + * The minimum opentelemetry-api version the semconv modules are built against. It is the + * compileOnly baseline pinned in :dependencyManagement and the lower bound of the OSGi + * Import-Package range emitted for io.opentelemetry.api.* by otel.java-conventions. Keep it as + * the single source of truth for both. + */ + const val OTEL_API_BASELINE = "1.33.0" + + /** + * OSGi version range for io.opentelemetry.api.* imports: the baseline up to (but excluding) the + * next major, e.g. "[1.33,2)". bnd can't infer this itself because the pinned opentelemetry-api + * baseline isn't a bnd bundle, so we set it explicitly. + */ + val OTEL_API_OSGI_RANGE: String + get() { + val floor = OTEL_API_BASELINE.substringBeforeLast('.') // e.g. 1.33 + val nextMajor = OTEL_API_BASELINE.substringBefore('.').toInt() + 1 // e.g. 2 + return "[$floor,$nextMajor)" + } +} diff --git a/buildSrc/src/main/kotlin/otel.java-conventions.gradle.kts b/buildSrc/src/main/kotlin/otel.java-conventions.gradle.kts index a27302c9..8024a1fc 100644 --- a/buildSrc/src/main/kotlin/otel.java-conventions.gradle.kts +++ b/buildSrc/src/main/kotlin/otel.java-conventions.gradle.kts @@ -1,4 +1,5 @@ import io.opentelemetry.gradle.OtelJavaExtension +import io.opentelemetry.gradle.OtelVersions import org.gradle.api.tasks.testing.logging.TestExceptionFormat plugins { @@ -8,6 +9,7 @@ plugins { eclipse idea + id("biz.aQute.bnd.builder") id("otel.spotless-conventions") } @@ -29,6 +31,13 @@ checkstyle { configProperties["rootDir"] = rootDir } +// normalize timestamps and file ordering in jars, making the outputs (including OSGi +// manifests) reproducible. see open-telemetry/opentelemetry-java#4488 +tasks.withType().configureEach { + isPreserveFileTimestamps = false + isReproducibleFileOrder = true +} + val testJavaVersion = gradle.startParameter.projectProperties.get("testJavaVersion")?.let(JavaVersion::toVersion) tasks { @@ -85,6 +94,21 @@ tasks { } } + named("jar") { + // Configure OSGi metadata. BND auto-detects imports, but can't infer a version range for + // io.opentelemetry.api.* because the pinned compileOnly opentelemetry-api baseline isn't a bnd + // bundle, so it would import with no range and wire to any version (incl. a future 2.x). Pin it + // to the range derived from the baseline that :dependencyManagement already requires. + bundle { + bnd( + mapOf( + "-exportcontents" to "io.opentelemetry.*", + "Import-Package" to "io.opentelemetry.api.*;version=\"${OtelVersions.OTEL_API_OSGI_RANGE}\",*", + ), + ) + } + } + withType().configureEach { inputs.property("moduleName", otelJava.moduleName) diff --git a/dependencyManagement/build.gradle.kts b/dependencyManagement/build.gradle.kts index 2feb5bb1..1da7953d 100644 --- a/dependencyManagement/build.gradle.kts +++ b/dependencyManagement/build.gradle.kts @@ -1,3 +1,5 @@ +import io.opentelemetry.gradle.OtelVersions + plugins { `java-platform` } @@ -14,7 +16,9 @@ dependencies { constraints { // pinned to: avoid churn, for conservative api version requirement, - // and because opentelemetry-api is a compileOnly dependency - api("io.opentelemetry:opentelemetry-api:1.33.0") + // and because opentelemetry-api is a compileOnly dependency. + // OtelVersions.OTEL_API_BASELINE is the single source of truth: otel.java-conventions derives + // the OSGi Import-Package range for io.opentelemetry.api.* from the same value. + api("io.opentelemetry:opentelemetry-api:${OtelVersions.OTEL_API_BASELINE}") } } diff --git a/osgi-test/build.gradle.kts b/osgi-test/build.gradle.kts new file mode 100644 index 00000000..bd2c4b49 --- /dev/null +++ b/osgi-test/build.gradle.kts @@ -0,0 +1,122 @@ +import aQute.bnd.gradle.Bundle +import aQute.bnd.gradle.Resolve +import aQute.bnd.gradle.TestOSGi + +plugins { + id("otel.java-conventions") +} + +description = "OpenTelemetry Semantic Conventions OSGi Integration Tests" +otelJava.moduleName.set("io.opentelemetry.semconv.integration.tests.osgi") + +// The semconv bundles Import-Package io.opentelemetry.api.common, so resolution needs an +// opentelemetry-api that is itself a proper OSGi bundle. OSGi metadata was added to the core +// artifacts in opentelemetry-java#8417, well after the 1.33.0 compileOnly baseline pinned in +// :dependencyManagement, so we force a recent version on this test module's classpath only. +val osgiOtelApiVersion = "1.63.0" + +configurations.configureEach { + resolutionStrategy { + force("io.opentelemetry:opentelemetry-api:$osgiOtelApiVersion") + } +} + +dependencies { + testImplementation(project(":semconv")) + testImplementation(project(":semconv-incubating")) + // Provides the io.opentelemetry.api.* packages the semconv bundles import, as an OSGi bundle. + testImplementation("io.opentelemetry:opentelemetry-api:$osgiOtelApiVersion") + + testImplementation("org.osgi:org.osgi.test.junit5:1.3.0") + // Provided by the OSGi framework at runtime. + testCompileOnly("org.osgi:osgi.core:8.0.0") + + testRuntimeOnly("org.junit.platform:junit-platform-launcher") + testRuntimeOnly("org.apache.felix:org.apache.felix.framework:7.0.5") + // opentelemetry-common (pulled in transitively by opentelemetry-api) declares + // Require-Capability: osgi.extender=osgi.serviceloader.processor, so the container needs Aries + // SPI Fly to resolve even though semconv itself has no ServiceLoader providers. + testRuntimeOnly("org.apache.aries.spifly:org.apache.aries.spifly.dynamic.bundle:1.3.7") +} + +// The testing bundle (our JUnit tests + Test-Cases header) is booted inside a real Felix container +// via bnd's Bundle -> Resolve -> TestOSGi task chain, modeled on opentelemetry-java's +// integration-tests/osgi module. +val bsn = "opentelemetry-semconv-osgi-testing" +val runee = "JavaSE-${java.toolchain.languageVersion.get()}" +val testRuntimeClasspath = sourceSets.test.get().runtimeClasspath + +val testingBundle = tasks.register("testingBundle") { + archiveClassifier.set("testing") + from(sourceSets.test.get().output) + bundle { + // BND analyses compileClasspath by default; use the runtime classpath so testImplementation + // deps (junit-jupiter, assertj) are visible and Test-Cases gets populated. + classpath(testRuntimeClasspath) + bnd( + "Bundle-SymbolicName: $bsn", + "Test-Cases: \${classes;HIERARCHY_INDIRECTLY_ANNOTATED;org.junit.platform.commons.annotation.Testable;CONCRETE}", + ) + } +} + +val inputBndrun = layout.buildDirectory.file("bndrun/test.bndrun") +val generateBndrun = tasks.register("generateBndrun") { + // Local copies so the doLast closure captures only serializable values (String, Provider), not + // the enclosing Kotlin build-script object (required by the configuration cache). + val bndrunFile = inputBndrun + val bndrunContent = + """ + |-tester: biz.aQute.tester.junit-platform + |-runfw: org.apache.felix.framework + |-runee: $runee + | + |-runrequires: \ + | bnd.identity;id='$bsn',\ + | bnd.identity;id='junit-jupiter-engine',\ + | bnd.identity;id='junit-platform-launcher' + """.trimMargin() + inputs.property("content", bndrunContent) + outputs.file(bndrunFile) + doLast { + bndrunFile.get().asFile.apply { parentFile.mkdirs() }.writeText(bndrunContent) + } +} + +val resolvedBndrun = layout.buildDirectory.file("test.bndrun") +val resolve = tasks.register("resolve") { + dependsOn(testingBundle, generateBndrun) + description = "Resolve the semconv OSGi test suite" + group = JavaBasePlugin.VERIFICATION_GROUP + bndrun = inputBndrun.get().asFile + outputBndrun = resolvedBndrun + bundles = files(testRuntimeClasspath, testingBundle.get().archiveFile) + // The resolved bndrun embeds an absolute path to the input, so it is not safe to share via cache. + outputs.cacheIf { false } +} + +tasks.register("testOsgi") { + dependsOn(resolve) + description = "Run the semconv OSGi test suite inside an Apache Felix container" + group = JavaBasePlugin.VERIFICATION_GROUP + bndrun = resolvedBndrun + bundles = files(testRuntimeClasspath, testingBundle.get().archiveFile) + // BND reports success when zero tests ran (e.g. if bundles failed to start). Fail explicitly. + val testResultsDir = layout.buildDirectory.dir("test-results/$name") + doLast { + check(testResultsDir.get().asFile.listFiles()?.isNotEmpty() == true) { + "No OSGi test results found — bundles may have failed to start. Check the output above." + } + } +} + +tasks { + named("jar") { + enabled = false + } + named("test") { + // Replace plain JUnit execution with the in-container OSGi test. + actions.clear() + dependsOn("testOsgi") + } +} diff --git a/osgi-test/src/test/java/io/opentelemetry/semconv/integrationtest/osgi/SemconvOsgiTest.java b/osgi-test/src/test/java/io/opentelemetry/semconv/integrationtest/osgi/SemconvOsgiTest.java new file mode 100644 index 00000000..09b8bfb8 --- /dev/null +++ b/osgi-test/src/test/java/io/opentelemetry/semconv/integrationtest/osgi/SemconvOsgiTest.java @@ -0,0 +1,39 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.semconv.integrationtest.osgi; + +import static org.assertj.core.api.Assertions.assertThat; + +import io.opentelemetry.api.common.AttributeKey; +import io.opentelemetry.semconv.SchemaUrls; +import io.opentelemetry.semconv.ServiceAttributes; +import io.opentelemetry.semconv.incubating.ServiceIncubatingAttributes; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.osgi.test.junit5.context.BundleContextExtension; + +/** + * Boots the semconv and semconv-incubating bundles inside a real OSGi (Apache Felix) container and + * exercises their exported packages. If either bundle's generated OSGi metadata is wrong (missing + * exports, unsatisfiable imports), the bundle fails to resolve and these tests never run — which + * the build treats as a failure. + */ +@ExtendWith(BundleContextExtension.class) +public class SemconvOsgiTest { + + @Test + public void stableAttributesAreUsable() { + AttributeKey serviceName = ServiceAttributes.SERVICE_NAME; + assertThat(serviceName.getKey()).isEqualTo("service.name"); + assertThat(SchemaUrls.V1_41_1).isEqualTo("https://opentelemetry.io/schemas/1.41.1"); + } + + @Test + public void incubatingAttributesAreUsable() { + AttributeKey instanceId = ServiceIncubatingAttributes.SERVICE_INSTANCE_ID; + assertThat(instanceId.getKey()).isEqualTo("service.instance.id"); + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 7c3fc3a7..0cdd3f6a 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -60,3 +60,4 @@ rootProject.name = "semantic-conventions-java" include(":dependencyManagement") include(":semconv-incubating") include(":semconv") +include(":osgi-test")