From f238b238477d71fc56a4554d9c66eb6817c10f85 Mon Sep 17 00:00:00 2001 From: Leon van Zantvoort Date: Sat, 11 Jul 2026 23:15:42 +0200 Subject: [PATCH] feat(micrometer): OpenTelemetry database semantic conventions for query observations Observability backends key their database tooling on the OpenTelemetry database client semantic conventions over OTLP: latency panels, service-map database nodes, and query views recognize db.* attributes. Storm's observations carried only storm.* key values, so its spans rendered as generic custom spans outside that tooling. - OtelDatabaseObservationConvention (storm-micrometer): extends the default convention with db.system.name, db.operation.name (low cardinality) and db.query.text (high cardinality, placeholders only, never parameter values), keeping the storm.* key values for custom dashboards and db.statement for backends that predate the stabilized conventions. fromJdbcUrl derives the database product from a JDBC URL using the conventions' well-known identifiers. - Spring Boot starters: storm.observations.semantic-conventions=otel activates the convention, with the database product detected from the application's DataSource; an ObservationConvention bean still wins, and an unknown property value fails fast. Shared by both starters via StormObservationAutoConfiguration. - Ktor plugin: an ObservationConvention registered in the dependency container overrides the naming and key values, mirroring the starters' convention bean. The result is backend-specific database UX from vendor-neutral telemetry: the same spans light up whichever OTLP-capable backend the application points at. Fixes #231 --- CHANGELOG.md | 1 + docs/configuration.md | 2 + docs/ktor-integration.md | 12 +- docs/spring-integration.md | 12 +- .../src/main/kotlin/st/orm/ktor/Storm.kt | 37 +++++- .../st/orm/ktor/StormObservabilityTest.kt | 36 ++++++ .../OtelDatabaseObservationConvention.java | 105 ++++++++++++++++++ ...OtelDatabaseObservationConventionTest.java | 100 +++++++++++++++++ .../StormAutoConfigurationTest.java | 35 ++++++ .../StormObservationAutoConfiguration.java | 34 +++++- .../st/orm/spring/boot/StormProperties.java | 25 +++++ 11 files changed, 392 insertions(+), 7 deletions(-) create mode 100644 storm-micrometer/src/main/java/st/orm/micrometer/OtelDatabaseObservationConvention.java create mode 100644 storm-micrometer/src/test/java/st/orm/micrometer/OtelDatabaseObservationConventionTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 48daa0441..4e1293634 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,7 @@ Framework integration points are now instance-scoped (#198), and the Ktor integr - Shared Spring Boot auto-configurations in storm-spring (`st.orm.spring.boot`): `StormTransactionAutoConfiguration` (Spring-aware provider beans), `StormValidationAutoConfiguration` (startup schema validation), and the `storm.*` configuration properties (`StormProperties`), used by both starters. Configuration keys are unchanged. - SQL failures raised by Storm translate to Spring's `DataAccessException` hierarchy in Spring applications: `SpringExceptionMapper` (storm-spring) translates on vendor error codes with `SQLException` subclass and SQL state fallback, auto-configured by both starters (`storm.exception-translation.enabled=false` to disable) and applied by the `SpringOrmTemplate.of` / `springOrmTemplate` compositions. Failures without a `SQLException` cause keep Storm's own exceptions. - The Spring Boot starters bind query observations automatically: with an `ObservationRegistry` bean present (Actuator provides one), every query executed by the auto-configured template reports as a `storm.query` Micrometer Observation via the shared `StormObservationAutoConfiguration`. The starters ship storm-micrometer; override the convention with an `ObservationConvention` bean, replace the binding with a `QueryObserver` bean, or disable via `management.observations.enable.storm.query=false`. +- `OtelDatabaseObservationConvention` (storm-micrometer): opt-in OpenTelemetry database client semantic conventions on query observations — `db.system.name` (derived from the JDBC URL), `db.operation.name`, and `db.query.text` (placeholders only) alongside the `storm.*` key values, so Storm queries surface in the database tooling of OTLP-capable observability backends. Activated with `storm.observations.semantic-conventions=otel` in the Spring Boot starters (database product detected from the DataSource) or by registering the convention in the Ktor dependency container. - `@EnableStormRepositories(basePackages = ...)` (storm-spring, usable from both language stacks): switches on repository scanning in plain Spring applications, mirroring `@EnableJpaRepositories`; without packages, the annotated class's package is scanned. In Spring Boot the annotation doubles as the explicit override: the auto-configured scanning backs off. The `RepositoryBeanFactoryPostProcessor` adapters also gain configuring constructors (base packages, template bean name, prefix), so multi-template applications define one bean per repository set instead of one subclass. - `@DataStormTest` test slice in the new storm-spring-boot-test-autoconfigure module, the counterpart of `@DataJpaTest`: starts only the `DataSource`, Storm's auto-configuration, and SQL initialization (plus Flyway/Liquibase when present), keeps regular components out, replaces the data source with an embedded database on Spring Boot 3 (opt out via `spring.test.database.replace=none` for Testcontainers `@ServiceConnection` setups), and rolls back a per-test transaction. One module serves both starters and both Spring Boot 3 and 4: the slice imports the starters' identically named auto-configuration classes, lists relocated platform classes as optional imports, and registers its type exclusion through a context customizer instead of the relocated `@TypeExcludeFilters`. diff --git a/docs/configuration.md b/docs/configuration.md index 3cd43034f..5c6aee589 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -92,6 +92,8 @@ storm: strict: false exception-translation: enabled: true + observations: + semantic-conventions: storm ``` The Spring Boot Starter binds these properties and builds a `StormConfig` that is passed to the `ORMTemplate` factory. Values not set in YAML fall back to system properties and then to built-in defaults. See [Spring Integration](spring-integration.md#configuration-via-applicationyml) for details. diff --git a/docs/ktor-integration.md b/docs/ktor-integration.md index ce0cf278e..8ba42854a 100644 --- a/docs/ktor-integration.md +++ b/docs/ktor-integration.md @@ -705,7 +705,17 @@ fun Application.module() { } ``` -No further configuration is needed; without a registry, queries run unobserved at no cost. What each observation produces depends on the handlers attached to the registry: timing metrics, tracing spans, or both. Storm spans nest under the current trace context, so with context propagation in place (for example the OpenTelemetry agent, or micrometer-tracing with a `TracingObservationHandler`) queries appear under the active request span. +No further configuration is needed; without a registry, queries run unobserved at no cost. To report the OpenTelemetry database client semantic conventions — the standard attributes observability backends key their database tooling on — register the convention alongside the registry: + +```kotlin +dependencies { + provide { observationRegistry } + provide> { + OtelDatabaseObservationConvention.fromJdbcUrl(jdbcUrl) + } +} +``` + What each observation produces depends on the handlers attached to the registry: timing metrics, tracing spans, or both. Storm spans nest under the current trace context, so with context propagation in place (for example the OpenTelemetry agent, or micrometer-tracing with a `TracingObservationHandler`) queries appear under the active request span. Observations are named `storm.query` and carry the following key values: diff --git a/docs/spring-integration.md b/docs/spring-integration.md index 707fa601e..89a459d0b 100644 --- a/docs/spring-integration.md +++ b/docs/spring-integration.md @@ -531,6 +531,8 @@ storm: strict: false exception-translation: enabled: true + observations: + semantic-conventions: storm ``` The `schema-mode` property controls startup schema validation: `fail` (default) blocks startup if any entity definitions do not match the database schema, `warn` logs mismatches without blocking startup, and `none` skips validation. The `strict` property controls whether warnings (type narrowing, nullability mismatches) are treated as errors. See the [Configuration](configuration.md#schema-validation) guide for details. @@ -620,7 +622,15 @@ A generic DataSource proxy can only time statements. Storm knows the entity and - **Low-cardinality key values** (become metric tags): the SQL operation (`SELECT`, `INSERT`, ...), the execution kind (query, update, batch), and the entity or projection type. - **High-cardinality key values** (visible to trace handlers only): the SQL statement with parameter placeholders. Parameter values are never reported. -Customization follows the usual back-off rules: contribute an `ObservationConvention` bean to override the naming and key values, define your own `QueryObserver` bean to replace the binding entirely, or disable the observation at the registry level with `management.observations.enable.storm.query=false`. +Observability backends key their database tooling — latency panels, service-map database nodes, query views — on the OpenTelemetry database client semantic conventions. Set `storm.observations.semantic-conventions: otel` and every observation additionally carries the standard attributes (`db.system.name` detected from the DataSource, `db.operation.name`, and `db.query.text` on spans), so Storm queries surface in the database UX of any OTLP-capable backend, from Elastic to Grafana to Datadog, while the `storm.*` key values remain for custom dashboards: + +```yaml +storm: + observations: + semantic-conventions: otel +``` + +Customization follows the usual back-off rules: contribute an `ObservationConvention` bean to override the naming and key values (it wins over the property), define your own `QueryObserver` bean to replace the binding entirely, or disable the observation at the registry level with `management.observations.enable.storm.query=false`. ## Testing with @DataStormTest diff --git a/storm-ktor/src/main/kotlin/st/orm/ktor/Storm.kt b/storm-ktor/src/main/kotlin/st/orm/ktor/Storm.kt index 28cde7ec2..32d0c1038 100644 --- a/storm-ktor/src/main/kotlin/st/orm/ktor/Storm.kt +++ b/storm-ktor/src/main/kotlin/st/orm/ktor/Storm.kt @@ -25,6 +25,7 @@ import io.ktor.server.plugins.di.dependencies import io.ktor.server.plugins.di.getBlocking import io.ktor.util.reflect.TypeInfo import io.micrometer.common.KeyValues +import io.micrometer.observation.ObservationConvention import io.micrometer.observation.ObservationRegistry import st.orm.core.spi.JdbcConnectionProviderImpl import st.orm.core.spi.JdbcTransactionTemplateProviderImpl @@ -33,6 +34,7 @@ import st.orm.template.ORMTemplate import javax.sql.DataSource import kotlin.reflect.KClass import kotlin.reflect.full.starProjectedType +import kotlin.reflect.typeOf /** * Ktor plugin that configures Storm ORM for the application. @@ -250,6 +252,11 @@ val Storm = createApplicationPlugin(name = "Storm", createConfiguration = ::Stor * registered. Every observation carries a `storm.database` key value: the database name, or `primary` for the * primary database. The tag is always present because meters of one name must share a single set of tag keys; * registries such as Prometheus drop series whose tag keys differ. + * + * An `ObservationConvention` registered in the dependency container overrides + * the naming and key values, mirroring the convention bean of the Spring Boot starters; register + * [st.orm.micrometer.OtelDatabaseObservationConvention] to report the OpenTelemetry database semantic + * conventions. */ private fun Application.bindQueryObservations(delegatingObservers: Map) { val registryKey = DependencyKey(TypeInfo(ObservationRegistry::class, ObservationRegistry::class.starProjectedType)) @@ -257,15 +264,37 @@ private fun Application.bindQueryObservations(delegatingObservers: Map(registryKey) + val convention = resolveObservationConvention() for ((databaseName, observer) in delegatingObservers) { - observer.delegate = MicrometerQueryObserver( - observationRegistry, - KeyValues.of("storm.database", databaseName ?: "primary"), - ) + val extraKeyValues = KeyValues.of("storm.database", databaseName ?: "primary") + observer.delegate = if (convention != null) { + MicrometerQueryObserver(observationRegistry, convention, extraKeyValues) + } else { + MicrometerQueryObserver(observationRegistry, extraKeyValues) + } } log.info("Storm query observations enabled via the ObservationRegistry from the dependency container.") } +/** + * Resolves a query observation convention from the dependency container, registered either under the + * parameterized type or under a plain [ObservationConvention]. + */ +@Suppress("UNCHECKED_CAST") +private fun Application.resolveObservationConvention(): ObservationConvention? { + val keys = listOf( + DependencyKey( + TypeInfo( + ObservationConvention::class, + typeOf>(), + ), + ), + DependencyKey(TypeInfo(ObservationConvention::class, ObservationConvention::class.starProjectedType)), + ) + val key = keys.firstOrNull { dependencies.contains(it) } ?: return null + return dependencies.getBlocking>(key) +} + /** * Registers every repository of the given registry in the dependency container, each under its own interface type. */ diff --git a/storm-ktor/src/test/kotlin/st/orm/ktor/StormObservabilityTest.kt b/storm-ktor/src/test/kotlin/st/orm/ktor/StormObservabilityTest.kt index beb23c233..26caaea23 100644 --- a/storm-ktor/src/test/kotlin/st/orm/ktor/StormObservabilityTest.kt +++ b/storm-ktor/src/test/kotlin/st/orm/ktor/StormObservabilityTest.kt @@ -52,6 +52,42 @@ class StormObservabilityTest { return dataSource } + @Test + fun `a convention from the dependency container overrides the naming and key values`() { + val observationRegistry = TestObservationRegistry.create() + val dataSource = createTestDataSource("storm-observed-otel", "/schema.sql") + try { + testApplication { + application { + dependencies { + provide { observationRegistry } + // The OTel database semantic conventions, mirroring the starters' convention bean. + provide> { + st.orm.micrometer.OtelDatabaseObservationConvention("h2database") + } + } + install(Storm) { + this.dataSource = dataSource + } + routing { + get("/pets") { + call.respondText(repository().findAll().size.toString()) + } + } + } + client.get("/pets").status shouldBe HttpStatusCode.OK + TestObservationRegistryAssert.assertThat(observationRegistry) + .hasObservationWithNameEqualTo("storm.query") + .that() + .hasLowCardinalityKeyValue("db.system.name", "h2database") + .hasLowCardinalityKeyValue("db.operation.name", "SELECT") + .hasLowCardinalityKeyValue("storm.database", "primary") + } + } finally { + dataSource.close() + } + } + @Test fun `queries are observed when an ObservationRegistry is registered`() { val observationRegistry = TestObservationRegistry.create() diff --git a/storm-micrometer/src/main/java/st/orm/micrometer/OtelDatabaseObservationConvention.java b/storm-micrometer/src/main/java/st/orm/micrometer/OtelDatabaseObservationConvention.java new file mode 100644 index 000000000..1e9d59b3b --- /dev/null +++ b/storm-micrometer/src/main/java/st/orm/micrometer/OtelDatabaseObservationConvention.java @@ -0,0 +1,105 @@ +/* + * Copyright 2024 - 2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package st.orm.micrometer; + +import static java.util.Objects.requireNonNull; + +import io.micrometer.common.KeyValues; +import jakarta.annotation.Nonnull; +import java.util.Locale; +import java.util.Map; + +/** + * {@link io.micrometer.observation.ObservationConvention} that reports Storm query observations with the + * OpenTelemetry database client semantic conventions, in addition to the {@code storm.*} key values of + * {@link StormQueryObservationConvention}. + * + *

Observability backends key their database tooling on the standard attributes: with this convention, + * Storm queries surface in the database views of OTLP-capable backends (latency panels, service-map + * database nodes) instead of rendering as generic custom spans. The emitted attributes:

+ * + *
    + *
  • {@code db.system.name} — the database product, such as {@code mariadb} (low cardinality)
  • + *
  • {@code db.operation.name} — {@code SELECT}, {@code INSERT}, {@code UPDATE} or {@code DELETE} + * (low cardinality)
  • + *
  • {@code db.query.text} — the SQL statement with parameter placeholders, never parameter values + * (high cardinality, visible to trace handlers only)
  • + *
+ * + *

The {@code storm.*} key values remain available for custom dashboards, and the {@code db.statement} + * key value of the default convention is kept for backends that predate the stabilized conventions.

+ * + *

Activate by handing the convention to the {@link MicrometerQueryObserver}; the Spring Boot starters + * activate it through {@code storm.observations.semantic-conventions=otel}, and the Ktor plugin picks it + * up from the dependency container.

+ * + * @since 1.13 + */ +public class OtelDatabaseObservationConvention extends StormQueryObservationConvention { + + private static final Map JDBC_SUBPROTOCOL_TO_SYSTEM = Map.of( + "mariadb", "mariadb", + "mysql", "mysql", + "postgresql", "postgresql", + "h2", "h2database", + "oracle", "oracle.db", + "sqlserver", "microsoft.sql_server", + "db2", "ibm.db2"); + + /** + * The {@code db.system.name} value for databases not covered by a well-known identifier. + */ + public static final String OTHER_SQL = "other_sql"; + + private final String dbSystemName; + + /** + * Creates a convention reporting the given database product. + * + * @param dbSystemName the {@code db.system.name} value, such as {@code mariadb} or {@code postgresql}; + * well-known values are defined by the OpenTelemetry database conventions. + */ + public OtelDatabaseObservationConvention(@Nonnull String dbSystemName) { + this.dbSystemName = requireNonNull(dbSystemName, "dbSystemName"); + } + + /** + * Creates a convention with the database product derived from a JDBC URL. + * + * @param jdbcUrl the JDBC URL of the data source, such as {@code jdbc:mariadb://localhost/db}. + * @return the convention, reporting {@link #OTHER_SQL} when the URL names an unrecognized product. + */ + public static OtelDatabaseObservationConvention fromJdbcUrl(@Nonnull String jdbcUrl) { + String[] parts = jdbcUrl.split(":", 3); + String subprotocol = parts.length > 1 ? parts[1].toLowerCase(Locale.ROOT) : ""; + return new OtelDatabaseObservationConvention( + JDBC_SUBPROTOCOL_TO_SYSTEM.getOrDefault(subprotocol, OTHER_SQL)); + } + + @Override + public KeyValues getLowCardinalityKeyValues(@Nonnull StormQueryObservationContext context) { + return super.getLowCardinalityKeyValues(context).and( + "db.system.name", dbSystemName, + "db.operation.name", context.queryContext().operation().name()); + } + + @Override + public KeyValues getHighCardinalityKeyValues(@Nonnull StormQueryObservationContext context) { + return context.queryContext().statement() + .map(statement -> super.getHighCardinalityKeyValues(context).and("db.query.text", statement)) + .orElseGet(() -> super.getHighCardinalityKeyValues(context)); + } +} diff --git a/storm-micrometer/src/test/java/st/orm/micrometer/OtelDatabaseObservationConventionTest.java b/storm-micrometer/src/test/java/st/orm/micrometer/OtelDatabaseObservationConventionTest.java new file mode 100644 index 000000000..34eb01795 --- /dev/null +++ b/storm-micrometer/src/test/java/st/orm/micrometer/OtelDatabaseObservationConventionTest.java @@ -0,0 +1,100 @@ +/* + * Copyright 2024 - 2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package st.orm.micrometer; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import io.micrometer.common.KeyValues; +import io.micrometer.observation.tck.TestObservationRegistry; +import io.micrometer.observation.tck.TestObservationRegistryAssert; +import java.util.Optional; +import java.util.OptionalInt; +import org.junit.jupiter.api.Test; +import st.orm.Data; +import st.orm.Entity; +import st.orm.core.spi.QueryContext; +import st.orm.core.template.SqlOperation; + +/** + * Tests for {@link OtelDatabaseObservationConvention}: the OpenTelemetry database attributes are emitted + * alongside the {@code storm.*} key values of the default convention. + */ +public class OtelDatabaseObservationConventionTest { + + record TestPet(Integer id) implements Entity { + } + + private record FakeQueryContext(SqlOperation operation, + Class type, + ExecutionKind kind, + String sql) implements QueryContext { + @Override + public Optional> dataType() { + return Optional.ofNullable(type); + } + + @Override + public OptionalInt batchSize() { + return OptionalInt.empty(); + } + + @Override + public Optional statement() { + return Optional.ofNullable(sql); + } + } + + @Test + public void emitsOtelDatabaseAttributesAlongsideStormKeyValues() { + var registry = TestObservationRegistry.create(); + var observer = new MicrometerQueryObserver( + registry, new OtelDatabaseObservationConvention("mariadb"), KeyValues.empty()); + observer.onExecute(new FakeQueryContext( + SqlOperation.SELECT, TestPet.class, QueryContext.ExecutionKind.QUERY, "SELECT id FROM test_pet")) + .close(); + TestObservationRegistryAssert.assertThat(registry) + .hasObservationWithNameEqualTo("storm.query") + .that() + .hasLowCardinalityKeyValue("db.system.name", "mariadb") + .hasLowCardinalityKeyValue("db.operation.name", "SELECT") + .hasLowCardinalityKeyValue("storm.operation", "SELECT") + .hasLowCardinalityKeyValue("storm.data_type", "TestPet") + .hasHighCardinalityKeyValue("db.query.text", "SELECT id FROM test_pet") + .hasHighCardinalityKeyValue("db.statement", "SELECT id FROM test_pet"); + } + + @Test + public void fromJdbcUrlMapsWellKnownProducts() { + assertEquals("mariadb", systemOf("jdbc:mariadb://localhost:3306/db")); + assertEquals("postgresql", systemOf("jdbc:postgresql://localhost/db")); + assertEquals("h2database", systemOf("jdbc:h2:mem:test")); + assertEquals("microsoft.sql_server", systemOf("jdbc:sqlserver://localhost")); + assertEquals("oracle.db", systemOf("jdbc:oracle:thin:@localhost")); + assertEquals(OtelDatabaseObservationConvention.OTHER_SQL, systemOf("jdbc:exotic://localhost")); + } + + private static String systemOf(String url) { + var registry = TestObservationRegistry.create(); + var observer = new MicrometerQueryObserver( + registry, OtelDatabaseObservationConvention.fromJdbcUrl(url), KeyValues.empty()); + observer.onExecute(new FakeQueryContext( + SqlOperation.SELECT, null, QueryContext.ExecutionKind.QUERY, null)).close(); + var system = new String[1]; + TestObservationRegistryAssert.assertThat(registry).hasSingleObservationThat() + .satisfies(context -> system[0] = context.getLowCardinalityKeyValue("db.system.name").getValue()); + return system[0]; + } +} diff --git a/storm-spring-boot-starter/src/test/java/st/orm/spring/boot/autoconfigure/StormAutoConfigurationTest.java b/storm-spring-boot-starter/src/test/java/st/orm/spring/boot/autoconfigure/StormAutoConfigurationTest.java index b66427b56..42c32b025 100644 --- a/storm-spring-boot-starter/src/test/java/st/orm/spring/boot/autoconfigure/StormAutoConfigurationTest.java +++ b/storm-spring-boot-starter/src/test/java/st/orm/spring/boot/autoconfigure/StormAutoConfigurationTest.java @@ -540,6 +540,41 @@ void queryExecutionsObservedWhenRegistryPresent() { }); } + @Test + void otelSemanticConventionsActivatedByProperty() { + // storm.observations.semantic-conventions=otel adds the OTel database attributes, with the + // database product detected from the DataSource. + contextRunner + .withPropertyValues( + "spring.datasource.url=jdbc:h2:mem:otelConventionTest;DB_CLOSE_DELAY=-1", + "spring.datasource.driver-class-name=org.h2.Driver", + "storm.observations.semantic-conventions=otel" + ) + .withUserConfiguration(TestObservationRegistryConfig.class) + .run(context -> { + ORMTemplate orm = context.getBean(ORMTemplate.class); + orm.query("CREATE TABLE otel_check (id INT PRIMARY KEY)").executeUpdate(); + var registry = context.getBean(io.micrometer.observation.tck.TestObservationRegistry.class); + io.micrometer.observation.tck.TestObservationRegistryAssert.assertThat(registry) + .hasObservationWithNameEqualTo("storm.query") + .that() + .hasLowCardinalityKeyValue("db.system.name", "h2database") + .hasLowCardinalityKeyValue("db.operation.name", "UNDEFINED"); + }); + } + + @Test + void unknownSemanticConventionsValueFailsFast() { + contextRunner + .withPropertyValues( + "spring.datasource.url=jdbc:h2:mem:otelBogusTest;DB_CLOSE_DELAY=-1", + "spring.datasource.driver-class-name=org.h2.Driver", + "storm.observations.semantic-conventions=bogus" + ) + .withUserConfiguration(TestObservationRegistryConfig.class) + .run(context -> assertThat(context).hasFailed()); + } + @Configuration static class TestObservationRegistryConfig { @Bean diff --git a/storm-spring/src/main/java/st/orm/spring/boot/StormObservationAutoConfiguration.java b/storm-spring/src/main/java/st/orm/spring/boot/StormObservationAutoConfiguration.java index a80e6aec7..92dee96a7 100644 --- a/storm-spring/src/main/java/st/orm/spring/boot/StormObservationAutoConfiguration.java +++ b/storm-spring/src/main/java/st/orm/spring/boot/StormObservationAutoConfiguration.java @@ -17,14 +17,18 @@ import io.micrometer.observation.ObservationConvention; import io.micrometer.observation.ObservationRegistry; +import javax.sql.DataSource; import org.springframework.beans.factory.ObjectProvider; import org.springframework.boot.autoconfigure.AutoConfiguration; import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; +import st.orm.PersistenceException; import st.orm.core.spi.QueryObserver; import st.orm.micrometer.MicrometerQueryObserver; +import st.orm.micrometer.OtelDatabaseObservationConvention; import st.orm.micrometer.StormQueryObservationContext; /** @@ -53,6 +57,7 @@ }) @ConditionalOnClass({ObservationRegistry.class, MicrometerQueryObserver.class}) @ConditionalOnBean(ObservationRegistry.class) +@EnableConfigurationProperties(StormProperties.class) public class StormObservationAutoConfiguration { /** @@ -65,10 +70,37 @@ public class StormObservationAutoConfiguration { @ConditionalOnMissingBean(QueryObserver.class) public QueryObserver stormQueryObserver( ObservationRegistry observationRegistry, - ObjectProvider> convention) { + ObjectProvider> convention, + StormProperties properties, + ObjectProvider dataSource) { ObservationConvention customConvention = convention.getIfAvailable(); + if (customConvention == null) { + customConvention = conventionFor(properties, dataSource); + } return customConvention != null ? new MicrometerQueryObserver(observationRegistry, customConvention, io.micrometer.common.KeyValues.empty()) : new MicrometerQueryObserver(observationRegistry); } + + private static ObservationConvention conventionFor( + StormProperties properties, ObjectProvider dataSource) { + String semanticConventions = properties.getObservations().getSemanticConventions(); + if (semanticConventions == null || semanticConventions.isBlank() + || "storm".equalsIgnoreCase(semanticConventions.trim())) { + return null; + } + if (!"otel".equalsIgnoreCase(semanticConventions.trim())) { + throw new PersistenceException(("Unknown storm.observations.semantic-conventions value: '%s'. " + + "Expected 'storm' or 'otel'.").formatted(semanticConventions)); + } + DataSource uniqueDataSource = dataSource.getIfUnique(); + if (uniqueDataSource != null) { + try (var connection = uniqueDataSource.getConnection()) { + return OtelDatabaseObservationConvention.fromJdbcUrl(connection.getMetaData().getURL()); + } catch (Exception ignored) { + // Fall through: the database product could not be determined. + } + } + return new OtelDatabaseObservationConvention(OtelDatabaseObservationConvention.OTHER_SQL); + } } diff --git a/storm-spring/src/main/java/st/orm/spring/boot/StormProperties.java b/storm-spring/src/main/java/st/orm/spring/boot/StormProperties.java index 25abab77d..7dfa9588c 100644 --- a/storm-spring/src/main/java/st/orm/spring/boot/StormProperties.java +++ b/storm-spring/src/main/java/st/orm/spring/boot/StormProperties.java @@ -57,6 +57,9 @@ public class StormProperties { /** Exception translation configuration. */ private ExceptionTranslation exceptionTranslation = new ExceptionTranslation(); + /** Query observation configuration. */ + private Observations observations = new Observations(); + /** Whether to enable ANSI escape sequences in Storm's log output. */ private Boolean ansiEscaping; @@ -84,6 +87,12 @@ public class StormProperties { /** Returns the exception translation configuration. */ public ExceptionTranslation getExceptionTranslation() { return exceptionTranslation; } + /** Returns the query observation configuration. */ + public Observations getObservations() { return observations; } + + /** Sets the query observation configuration. */ + public void setObservations(Observations observations) { this.observations = observations; } + /** Sets the exception translation configuration. */ public void setExceptionTranslation(ExceptionTranslation exceptionTranslation) { this.exceptionTranslation = exceptionTranslation; } @@ -223,6 +232,22 @@ public static class Validation { * * @since 1.13 */ + /** Query observation configuration. */ + public static class Observations { + + /** + * The key-value vocabulary of query observations: "storm" (default) for the storm.* key values, + * or "otel" to add the OpenTelemetry database client semantic conventions. + */ + private String semanticConventions; + + /** Returns the semantic conventions of query observations. */ + public String getSemanticConventions() { return semanticConventions; } + + /** Sets the semantic conventions of query observations. */ + public void setSemanticConventions(String semanticConventions) { this.semanticConventions = semanticConventions; } + } + /** Exception translation configuration. */ public static class ExceptionTranslation {