Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand Down
2 changes: 2 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
12 changes: 11 additions & 1 deletion docs/ktor-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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> { observationRegistry }
provide<ObservationConvention<StormQueryObservationContext>> {
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:

Expand Down
12 changes: 11 additions & 1 deletion docs/spring-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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<StormQueryObservationContext>` 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<StormQueryObservationContext>` 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

Expand Down
37 changes: 33 additions & 4 deletions storm-ktor/src/main/kotlin/st/orm/ktor/Storm.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -250,22 +252,49 @@ 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<StormQueryObservationContext>` 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<String?, DelegatingQueryObserver>) {
val registryKey = DependencyKey(TypeInfo(ObservationRegistry::class, ObservationRegistry::class.starProjectedType))
if (!dependencies.contains(registryKey)) {
return
}
val observationRegistry = dependencies.getBlocking<ObservationRegistry>(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<st.orm.micrometer.StormQueryObservationContext>? {
val keys = listOf(
DependencyKey(
TypeInfo(
ObservationConvention::class,
typeOf<ObservationConvention<st.orm.micrometer.StormQueryObservationContext>>(),
),
),
DependencyKey(TypeInfo(ObservationConvention::class, ObservationConvention::class.starProjectedType)),
)
val key = keys.firstOrNull { dependencies.contains(it) } ?: return null
return dependencies.getBlocking<ObservationConvention<st.orm.micrometer.StormQueryObservationContext>>(key)
}

/**
* Registers every repository of the given registry in the dependency container, each under its own interface type.
*/
Expand Down
36 changes: 36 additions & 0 deletions storm-ktor/src/test/kotlin/st/orm/ktor/StormObservabilityTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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> { observationRegistry }
// The OTel database semantic conventions, mirroring the starters' convention bean.
provide<io.micrometer.observation.ObservationConvention<st.orm.micrometer.StormQueryObservationContext>> {
st.orm.micrometer.OtelDatabaseObservationConvention("h2database")
}
}
install(Storm) {
this.dataSource = dataSource
}
routing {
get("/pets") {
call.respondText(repository<PetRepository>().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()
Expand Down
Original file line number Diff line number Diff line change
@@ -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}.
*
* <p>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:</p>
*
* <ul>
* <li>{@code db.system.name} — the database product, such as {@code mariadb} (low cardinality)</li>
* <li>{@code db.operation.name} — {@code SELECT}, {@code INSERT}, {@code UPDATE} or {@code DELETE}
* (low cardinality)</li>
* <li>{@code db.query.text} — the SQL statement with parameter placeholders, never parameter values
* (high cardinality, visible to trace handlers only)</li>
* </ul>
*
* <p>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.</p>
*
* <p>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.</p>
*
* @since 1.13
*/
public class OtelDatabaseObservationConvention extends StormQueryObservationConvention {

private static final Map<String, String> 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));
}
}
Loading
Loading