From 6a07130def508dfda23e21bd166ce581625d0f5c Mon Sep 17 00:00:00 2001 From: Roy Teeuwen Date: Sun, 14 Jun 2026 19:44:46 +0200 Subject: [PATCH 1/5] Add native OSGi bundle metadata to published jars Wire the bnd Gradle plugin into the shared otel.java-conventions plugin so the semconv and semconv-incubating jars are published with OSGi manifest headers (Bundle-SymbolicName, Export-Package, Import-Package). This lets the artifacts be consumed directly in OSGi containers without external re-wrapping. Fixes #494 --- buildSrc/build.gradle.kts | 1 + .../opentelemetry/gradle/OtelJavaExtension.kt | 7 ++++ .../kotlin/otel.java-conventions.gradle.kts | 34 +++++++++++++++++++ 3 files changed, 42 insertions(+) diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts index 7644f991..35901048 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.8.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/OtelJavaExtension.kt b/buildSrc/src/main/kotlin/io/opentelemetry/gradle/OtelJavaExtension.kt index bf584e71..99cbd6d2 100644 --- a/buildSrc/src/main/kotlin/io/opentelemetry/gradle/OtelJavaExtension.kt +++ b/buildSrc/src/main/kotlin/io/opentelemetry/gradle/OtelJavaExtension.kt @@ -5,8 +5,15 @@ package io.opentelemetry.gradle +import org.gradle.api.provider.ListProperty import org.gradle.api.provider.Property abstract class OtelJavaExtension { abstract val moduleName: Property + + /** Whether to generate OSGi bundle metadata. Enabled by default. */ + abstract val osgiEnabled: Property + + /** Extra packages imported as optional (resolution:=optional), e.g. compileOnly deps. */ + abstract val osgiOptionalPackages: ListProperty } diff --git a/buildSrc/src/main/kotlin/otel.java-conventions.gradle.kts b/buildSrc/src/main/kotlin/otel.java-conventions.gradle.kts index 104f8ec5..7f1b96b5 100644 --- a/buildSrc/src/main/kotlin/otel.java-conventions.gradle.kts +++ b/buildSrc/src/main/kotlin/otel.java-conventions.gradle.kts @@ -8,10 +8,13 @@ plugins { eclipse idea + id("biz.aQute.bnd.builder") id("otel.spotless-conventions") } val otelJava = extensions.create("otelJava") +otelJava.osgiEnabled.convention(true) +otelJava.osgiOptionalPackages.convention(emptyList()) java { toolchain { @@ -29,6 +32,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 +95,30 @@ tasks { } } + afterEvaluate { + if (otelJava.osgiEnabled.get()) { + named("jar") { + // Configure OSGi metadata. semconv has no SPI / ServiceLoader needs, so this is the + // trimmed form of opentelemetry-java's otel.java-conventions OSGi config. + bundle { + // Modules may declare optional imports (typically compileOnly deps). The trailing + // "*" lets BND auto-import everything else (e.g. io.opentelemetry.api.*). + val optionalPackages = otelJava.osgiOptionalPackages.get() + val optionalImports = + optionalPackages.joinToString(",") { "$it.*;resolution:=optional;version=\"\${@}\"" } + val importPackages = if (optionalImports.isEmpty()) "*" else "$optionalImports,*" + + bnd( + mapOf( + "-exportcontents" to "io.opentelemetry.*", + "Import-Package" to importPackages, + ), + ) + } + } + } + } + withType().configureEach { inputs.property("moduleName", otelJava.moduleName) From ea02984c84419c9af8108511e8dc61d310a9f7d2 Mon Sep 17 00:00:00 2001 From: Roy Teeuwen Date: Sun, 19 Jul 2026 21:39:29 +0200 Subject: [PATCH 2/5] Add OSGi integration test that resolves semconv bundles in Felix --- osgi-test/build.gradle.kts | 125 ++++++++++++++++++ .../integrationtest/osgi/SemconvOsgiTest.java | 39 ++++++ settings.gradle.kts | 1 + 3 files changed, 165 insertions(+) create mode 100644 osgi-test/build.gradle.kts create mode 100644 osgi-test/src/test/java/io/opentelemetry/semconv/integrationtest/osgi/SemconvOsgiTest.java diff --git a/osgi-test/build.gradle.kts b/osgi-test/build.gradle.kts new file mode 100644 index 00000000..8ab6c336 --- /dev/null +++ b/osgi-test/build.gradle.kts @@ -0,0 +1,125 @@ +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") +// This module runs OSGi tests against the published semconv bundles; it does not publish an OSGi +// bundle itself. +otelJava.osgiEnabled.set(false) + +// 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") From f297d0a97dfdb19118ed99f0885153aefb5fa0b1 Mon Sep 17 00:00:00 2001 From: Roy Teeuwen Date: Mon, 3 Aug 2026 08:08:27 +0200 Subject: [PATCH 3/5] Simplify OSGi config: apply unconditionally, drop osgiEnabled/osgiOptionalPackages --- .../opentelemetry/gradle/OtelJavaExtension.kt | 7 ----- .../kotlin/otel.java-conventions.gradle.kts | 27 +++---------------- osgi-test/build.gradle.kts | 3 --- 3 files changed, 4 insertions(+), 33 deletions(-) diff --git a/buildSrc/src/main/kotlin/io/opentelemetry/gradle/OtelJavaExtension.kt b/buildSrc/src/main/kotlin/io/opentelemetry/gradle/OtelJavaExtension.kt index 99cbd6d2..bf584e71 100644 --- a/buildSrc/src/main/kotlin/io/opentelemetry/gradle/OtelJavaExtension.kt +++ b/buildSrc/src/main/kotlin/io/opentelemetry/gradle/OtelJavaExtension.kt @@ -5,15 +5,8 @@ package io.opentelemetry.gradle -import org.gradle.api.provider.ListProperty import org.gradle.api.provider.Property abstract class OtelJavaExtension { abstract val moduleName: Property - - /** Whether to generate OSGi bundle metadata. Enabled by default. */ - abstract val osgiEnabled: Property - - /** Extra packages imported as optional (resolution:=optional), e.g. compileOnly deps. */ - abstract val osgiOptionalPackages: ListProperty } diff --git a/buildSrc/src/main/kotlin/otel.java-conventions.gradle.kts b/buildSrc/src/main/kotlin/otel.java-conventions.gradle.kts index 7f1b96b5..abdd4a5d 100644 --- a/buildSrc/src/main/kotlin/otel.java-conventions.gradle.kts +++ b/buildSrc/src/main/kotlin/otel.java-conventions.gradle.kts @@ -13,8 +13,6 @@ plugins { } val otelJava = extensions.create("otelJava") -otelJava.osgiEnabled.convention(true) -otelJava.osgiOptionalPackages.convention(emptyList()) java { toolchain { @@ -95,27 +93,10 @@ tasks { } } - afterEvaluate { - if (otelJava.osgiEnabled.get()) { - named("jar") { - // Configure OSGi metadata. semconv has no SPI / ServiceLoader needs, so this is the - // trimmed form of opentelemetry-java's otel.java-conventions OSGi config. - bundle { - // Modules may declare optional imports (typically compileOnly deps). The trailing - // "*" lets BND auto-import everything else (e.g. io.opentelemetry.api.*). - val optionalPackages = otelJava.osgiOptionalPackages.get() - val optionalImports = - optionalPackages.joinToString(",") { "$it.*;resolution:=optional;version=\"\${@}\"" } - val importPackages = if (optionalImports.isEmpty()) "*" else "$optionalImports,*" - - bnd( - mapOf( - "-exportcontents" to "io.opentelemetry.*", - "Import-Package" to importPackages, - ), - ) - } - } + named("jar") { + // Configure OSGi metadata; BND auto-detects the imports. + bundle { + bnd(mapOf("-exportcontents" to "io.opentelemetry.*")) } } diff --git a/osgi-test/build.gradle.kts b/osgi-test/build.gradle.kts index 8ab6c336..bd2c4b49 100644 --- a/osgi-test/build.gradle.kts +++ b/osgi-test/build.gradle.kts @@ -8,9 +8,6 @@ plugins { description = "OpenTelemetry Semantic Conventions OSGi Integration Tests" otelJava.moduleName.set("io.opentelemetry.semconv.integration.tests.osgi") -// This module runs OSGi tests against the published semconv bundles; it does not publish an OSGi -// bundle itself. -otelJava.osgiEnabled.set(false) // 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 From 4b599151e6736d4f36b88d4cd652fdf6c9198681 Mon Sep 17 00:00:00 2001 From: Roy Teeuwen Date: Mon, 3 Aug 2026 22:20:09 +0200 Subject: [PATCH 4/5] Pin io.opentelemetry.api.* OSGi import to [1.33,2) range --- .../src/main/kotlin/otel.java-conventions.gradle.kts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/buildSrc/src/main/kotlin/otel.java-conventions.gradle.kts b/buildSrc/src/main/kotlin/otel.java-conventions.gradle.kts index abdd4a5d..35aa8ae2 100644 --- a/buildSrc/src/main/kotlin/otel.java-conventions.gradle.kts +++ b/buildSrc/src/main/kotlin/otel.java-conventions.gradle.kts @@ -94,9 +94,17 @@ tasks { } named("jar") { - // Configure OSGi metadata; BND auto-detects the imports. + // Configure OSGi metadata. BND auto-detects imports, but can't infer a version range for + // io.opentelemetry.api.* because the pinned compileOnly opentelemetry-api (1.33.0) 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 [1.33,2) baseline that :dependencyManagement already requires. bundle { - bnd(mapOf("-exportcontents" to "io.opentelemetry.*")) + bnd( + mapOf( + "-exportcontents" to "io.opentelemetry.*", + "Import-Package" to "io.opentelemetry.api.*;version=\"[1.33,2)\",*", + ), + ) } } From b05fd8caaf0563c5228eba299e2a446ef5804443 Mon Sep 17 00:00:00 2001 From: Roy Teeuwen Date: Mon, 3 Aug 2026 22:35:10 +0200 Subject: [PATCH 5/5] Derive OSGi api import range from a shared baseline constant --- .../io/opentelemetry/gradle/OtelVersions.kt | 29 +++++++++++++++++++ .../kotlin/otel.java-conventions.gradle.kts | 7 +++-- dependencyManagement/build.gradle.kts | 8 +++-- 3 files changed, 39 insertions(+), 5 deletions(-) create mode 100644 buildSrc/src/main/kotlin/io/opentelemetry/gradle/OtelVersions.kt 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 35aa8ae2..22b8d21c 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 { @@ -95,14 +96,14 @@ 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 (1.33.0) isn't a bnd + // 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 [1.33,2) baseline that :dependencyManagement already requires. + // 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=\"[1.33,2)\",*", + "Import-Package" to "io.opentelemetry.api.*;version=\"${OtelVersions.OTEL_API_OSGI_RANGE}\",*", ), ) } 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}") } }