diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e1293634..60c336087 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,7 @@ Framework integration points are now instance-scoped (#198), and the Ktor integr - 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. +- Trace context in SQL statements: the `SqlCommenter` hook (template builder, all language stacks) appends per-execution comment content to statements after all processing and caching; `TraceContextSqlCommenter` (storm-micrometer) renders the current span as a sqlcommenter-style W3C `traceparent` comment, correlating database-side diagnostics such as slow query logs with traces. Opt-in everywhere — `storm.tracing.sql-comments=true` in the Spring Boot starters (with a `Tracer` present), the `sqlCommenter` slot in the Ktor plugin — since a per-execution comment defeats prepared statement caching. Hostile content is rejected (comment terminator, semicolons), and the emitted comment is padded so MySQL/MariaDB executable-comment and optimizer-hint markers are never interpreted. - `@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 5c6aee589..4099903b7 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -94,6 +94,8 @@ storm: enabled: true observations: semantic-conventions: storm + tracing: + sql-comments: false ``` 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 8ba42854a..d515a7db8 100644 --- a/docs/ktor-integration.md +++ b/docs/ktor-integration.md @@ -714,6 +714,14 @@ dependencies { OtelDatabaseObservationConvention.fromJdbcUrl(jdbcUrl) } } +``` + +With tracing in place, the `sqlCommenter` slot appends the current trace context to every statement as a sqlcommenter-style comment, correlating database-side diagnostics such as slow query logs back to the trace. Opt-in: a per-execution comment defeats prepared statement caching. + +```kotlin +install(Storm) { + sqlCommenter = TraceContextSqlCommenter(tracer) +} ``` 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. diff --git a/docs/spring-integration.md b/docs/spring-integration.md index 89a459d0b..e41aad8c8 100644 --- a/docs/spring-integration.md +++ b/docs/spring-integration.md @@ -533,6 +533,8 @@ storm: enabled: true observations: semantic-conventions: storm + tracing: + sql-comments: false ``` 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. @@ -632,6 +634,8 @@ storm: 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`. +With tracing in place, `storm.tracing.sql-comments: true` additionally appends the current trace context to every statement as a sqlcommenter-style comment (`/*traceparent='00-…'*/`), so database-side diagnostics — MariaDB's slow query log, `pg_stat_activity` — correlate directly back to the trace that issued the statement. This is deliberately opt-in: a per-execution comment changes the statement text on every call, which defeats driver-side and server-side prepared statement caching. Enable it where the correlation is worth that trade-off. + ## Testing with @DataStormTest `@DataStormTest` (from `storm-spring-boot-test-autoconfigure`, test scope) is the Storm test slice, the counterpart of `@DataJpaTest`: it starts only the `DataSource`, Storm's auto-configuration (template, repository scanning, transaction integration, schema validation, exception translation), and SQL initialization, plus Flyway or Liquibase when present. It complements [`@StormTest`](testing.md), which tests data logic without a Spring context: use `@StormTest` for fast query-level tests, and the slice when the test should see what production Spring code sees — injected repository beans, translated exceptions, Spring-managed transactions, and your `storm.*` configuration. Regular `@Component`, `@Service`, and `@Controller` beans stay out of the context. Each test method runs in a transaction that is rolled back afterwards, so tests cannot see each other's writes. diff --git a/storm-core/src/main/java/st/orm/core/spi/SqlCommenter.java b/storm-core/src/main/java/st/orm/core/spi/SqlCommenter.java new file mode 100644 index 000000000..594574beb --- /dev/null +++ b/storm-core/src/main/java/st/orm/core/spi/SqlCommenter.java @@ -0,0 +1,48 @@ +/* + * 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.core.spi; + +import java.util.Optional; + +/** + * Contributes a comment appended to SQL statements at execution time. + * + *

The comment is appended after all statement processing and caching, immediately before the statement is + * prepared, so per-execution content such as the current trace context reaches the database without affecting + * Storm's template cache. Database-side diagnostics — slow query logs, statement views — then carry the + * comment, correlating captured statements back to the execution that issued them.

+ * + *

Commenters are configured per ORM template via the template builder; they are deliberately not discovered + * through the {@code ServiceLoader} mechanism. Note that a per-execution comment changes the statement text on + * every call, which defeats driver-side and server-side prepared statement caching; enable selectively.

+ * + * @see st.orm.core.template.ORMTemplate.Builder#sqlCommenter(SqlCommenter) + * @since 1.13 + */ +@FunctionalInterface +public interface SqlCommenter { + + /** + * Returns the comment content for the statement that is about to execute, without comment delimiters, + * or empty when no comment applies. The content must not contain the comment terminator sequence + * (asterisk followed by slash) or semicolons; following the sqlcommenter convention, values are + * URL-encoded, which escapes both. The framework pads the emitted comment with spaces, so leading + * executable-comment and optimizer-hint markers are never interpreted. + * + * @return the comment content, such as {@code traceparent='00-4bf92f35-00f067aa-01'}. + */ + Optional comment(); +} diff --git a/storm-core/src/main/java/st/orm/core/template/ORMTemplate.java b/storm-core/src/main/java/st/orm/core/template/ORMTemplate.java index d180dc1ec..a88d58cfe 100644 --- a/storm-core/src/main/java/st/orm/core/template/ORMTemplate.java +++ b/storm-core/src/main/java/st/orm/core/template/ORMTemplate.java @@ -34,6 +34,7 @@ import st.orm.core.spi.ConnectionProvider; import st.orm.core.spi.ExceptionMapper; import st.orm.core.spi.QueryObserver; +import st.orm.core.spi.SqlCommenter; import st.orm.core.spi.TransactionTemplateProvider; import st.orm.core.template.impl.PreparedStatementTemplateImpl; import st.orm.mapping.TemplateDecorator; @@ -433,6 +434,7 @@ final class Builder { private @Nullable TransactionTemplateProvider transactionTemplateProvider; private @Nullable ExceptionMapper exceptionMapper; private @Nullable QueryObserver queryObserver; + private @Nullable SqlCommenter sqlCommenter; private Builder(@Nullable DataSource dataSource, @Nullable Connection connection) { this.dataSource = dataSource; @@ -510,6 +512,19 @@ public Builder queryObserver(@Nonnull QueryObserver queryObserver) { return this; } + /** + * Sets the SQL commenter that appends per-execution comment content to statements, such as the + * current trace context. Note that per-execution content defeats prepared statement caching. + * + * @param sqlCommenter the SQL commenter; must not be {@code null}. + * @return this builder. + * @since 1.13 + */ + public Builder sqlCommenter(@Nonnull SqlCommenter sqlCommenter) { + this.sqlCommenter = requireNonNull(sqlCommenter, "sqlCommenter"); + return this; + } + /** * Builds the ORM template. * @@ -521,7 +536,7 @@ public ORMTemplate build() { PreparedStatementTemplateImpl template; if (dataSource != null) { template = new PreparedStatementTemplateImpl(dataSource, config, connectionProvider, - transactionTemplateProvider, exceptionMapper, queryObserver); + transactionTemplateProvider, exceptionMapper, queryObserver, sqlCommenter); } else { if (connectionProvider != null) { throw new PersistenceException( @@ -529,7 +544,7 @@ public ORMTemplate build() { } assert connection != null; template = new PreparedStatementTemplateImpl(connection, config, - transactionTemplateProvider, exceptionMapper, queryObserver); + transactionTemplateProvider, exceptionMapper, queryObserver, sqlCommenter); } if (decorator != null) { var decorated = decorator.apply(template); diff --git a/storm-core/src/main/java/st/orm/core/template/impl/PreparedStatementTemplateImpl.java b/storm-core/src/main/java/st/orm/core/template/impl/PreparedStatementTemplateImpl.java index cf5c8fd6c..22b119f3e 100644 --- a/storm-core/src/main/java/st/orm/core/template/impl/PreparedStatementTemplateImpl.java +++ b/storm-core/src/main/java/st/orm/core/template/impl/PreparedStatementTemplateImpl.java @@ -62,6 +62,7 @@ import st.orm.core.spi.QueryObserver; import st.orm.core.spi.RefFactory; import st.orm.core.spi.RefFactoryImpl; +import st.orm.core.spi.SqlCommenter; import st.orm.core.spi.SqlDialectProvider; import st.orm.core.spi.TransactionContext; import st.orm.core.spi.TransactionScope; @@ -98,7 +99,36 @@ PreparedStatement process(@Nonnull Sql sql, record IntegrationStrategies(@Nullable ConnectionProvider connectionProvider, @Nonnull TransactionTemplateProvider transactionTemplateProvider, @Nonnull ExceptionMapper exceptionMapper, - @Nonnull QueryObserver queryObserver) { + @Nonnull QueryObserver queryObserver, + @Nullable SqlCommenter sqlCommenter) { + } + + /** + * Appends the commenter's content to the statement, after all statement processing and caching. A + * commenter must never be able to alter the statement: content containing the comment terminator is + * rejected (the only escape from a block comment), and so are semicolons (inert to the SQL parser inside + * a comment, but naive statement splitters in drivers and proxies split on them; sqlcommenter values + * URL-encode them instead). The content is padded with spaces, which keeps MySQL and MariaDB from + * interpreting leading {@code !} or {@code +} as an executable comment or optimizer hint. + */ + private static String applySqlCommenter(@Nullable SqlCommenter sqlCommenter, @Nonnull String statement) { + if (sqlCommenter == null) { + return statement; + } + var content = sqlCommenter.comment().orElse(null); + if (content == null || content.isBlank()) { + return statement; + } + if (content.contains("*/")) { + throw new PersistenceException( + "SQL comment content must not contain the comment terminator '*/': %s".formatted(content)); + } + if (content.indexOf(';') >= 0) { + throw new PersistenceException( + "SQL comment content must not contain semicolons; URL-encode values instead: %s" + .formatted(content)); + } + return statement + " /* " + content + " */"; } private final TemplateProcessor templateProcessor; @@ -133,11 +163,29 @@ public PreparedStatementTemplateImpl(@Nonnull DataSource dataSource, @Nullable TransactionTemplateProvider transactionTemplateProvider, @Nullable ExceptionMapper exceptionMapper, @Nullable QueryObserver queryObserver) { + this(dataSource, config, connectionProvider, transactionTemplateProvider, exceptionMapper, queryObserver, + null); + } + + /** + * Creates a data source backed template with instance-scoped integration strategies and an optional + * SQL commenter. + * + * @since 1.13 + */ + public PreparedStatementTemplateImpl(@Nonnull DataSource dataSource, + @Nonnull StormConfig config, + @Nullable ConnectionProvider connectionProvider, + @Nullable TransactionTemplateProvider transactionTemplateProvider, + @Nullable ExceptionMapper exceptionMapper, + @Nullable QueryObserver queryObserver, + @Nullable SqlCommenter sqlCommenter) { this(new IntegrationStrategies( requireNonNullElseGet(connectionProvider, Providers::getConnectionProvider), requireNonNullElseGet(transactionTemplateProvider, Providers::getTransactionTemplateProvider), requireNonNullElseGet(exceptionMapper, ExceptionMapper::defaultMapper), - requireNonNullElseGet(queryObserver, QueryObserver::noop)), + requireNonNullElseGet(queryObserver, QueryObserver::noop), + sqlCommenter), dataSource, config); } @@ -182,11 +230,27 @@ public PreparedStatementTemplateImpl(@Nonnull Connection connection, @Nullable TransactionTemplateProvider transactionTemplateProvider, @Nullable ExceptionMapper exceptionMapper, @Nullable QueryObserver queryObserver) { + this(connection, config, transactionTemplateProvider, exceptionMapper, queryObserver, null); + } + + /** + * Creates a connection backed template with instance-scoped integration strategies and an optional + * SQL commenter. + * + * @since 1.13 + */ + public PreparedStatementTemplateImpl(@Nonnull Connection connection, + @Nonnull StormConfig config, + @Nullable TransactionTemplateProvider transactionTemplateProvider, + @Nullable ExceptionMapper exceptionMapper, + @Nullable QueryObserver queryObserver, + @Nullable SqlCommenter sqlCommenter) { this(new IntegrationStrategies( null, requireNonNullElseGet(transactionTemplateProvider, Providers::getTransactionTemplateProvider), requireNonNullElseGet(exceptionMapper, ExceptionMapper::defaultMapper), - requireNonNullElseGet(queryObserver, QueryObserver::noop)), + requireNonNullElseGet(queryObserver, QueryObserver::noop), + sqlCommenter), connection, config); } @@ -240,7 +304,7 @@ private static TemplateProcessor createDataSourceProcessor(@Nonnull DataSource d throw new PersistenceException("%s Use Query.unsafe() to allow this operation.".formatted(warning)); }); } - var statement = sql.statement(); + var statement = applySqlCommenter(strategies.sqlCommenter(), sql.statement()); var parameters = sql.parameters(); var bindVariables = sql.bindVariables().orElse(null); var generatedKeys = sql.generatedKeys(); @@ -297,7 +361,7 @@ private static TemplateProcessor createConnectionProcessor(@Nonnull Connection c throw new PersistenceException("%s Use Query.unsafe() to allow this operation.".formatted(warning)); }); } - var statement = sql.statement(); + var statement = applySqlCommenter(strategies.sqlCommenter(), sql.statement()); var parameters = sql.parameters(); var bindVariables = sql.bindVariables().orElse(null); var generatedKeys = sql.generatedKeys(); diff --git a/storm-java21/src/main/java/st/orm/template/ORMTemplate.java b/storm-java21/src/main/java/st/orm/template/ORMTemplate.java index a54f83189..a90abba76 100644 --- a/storm-java21/src/main/java/st/orm/template/ORMTemplate.java +++ b/storm-java21/src/main/java/st/orm/template/ORMTemplate.java @@ -431,6 +431,19 @@ public Builder queryObserver(@Nonnull st.orm.core.spi.QueryObserver queryObserve return this; } + /** + * Sets the SQL commenter that appends per-execution comment content to statements, such as the + * current trace context. Note that per-execution content defeats prepared statement caching. + * + * @param sqlCommenter the SQL commenter; must not be {@code null}. + * @return this builder. + * @since 1.13 + */ + public Builder sqlCommenter(@Nonnull st.orm.core.spi.SqlCommenter sqlCommenter) { + core.sqlCommenter(sqlCommenter); + return this; + } + /** * Builds the ORM template. * diff --git a/storm-kotlin-spring-boot-starter/src/main/kotlin/st/orm/spring/boot/autoconfigure/StormAutoConfiguration.kt b/storm-kotlin-spring-boot-starter/src/main/kotlin/st/orm/spring/boot/autoconfigure/StormAutoConfiguration.kt index 2bfdd07bb..572c0db44 100644 --- a/storm-kotlin-spring-boot-starter/src/main/kotlin/st/orm/spring/boot/autoconfigure/StormAutoConfiguration.kt +++ b/storm-kotlin-spring-boot-starter/src/main/kotlin/st/orm/spring/boot/autoconfigure/StormAutoConfiguration.kt @@ -27,6 +27,7 @@ import st.orm.StormConfig import st.orm.core.spi.ConnectionProvider import st.orm.core.spi.ExceptionMapper import st.orm.core.spi.QueryObserver +import st.orm.core.spi.SqlCommenter import st.orm.core.spi.TransactionTemplateProvider import st.orm.spring.boot.StormProperties import st.orm.template.ORMTemplate @@ -75,12 +76,14 @@ open class StormAutoConfiguration { transactionTemplateProvider: ObjectProvider, exceptionMapper: ObjectProvider, queryObserver: ObjectProvider, + sqlCommenter: ObjectProvider, ): ORMTemplate { val builder = ORMTemplate.builder(dataSource).config(properties.toStormConfig()) connectionProvider.ifAvailable { builder.connectionProvider(it) } transactionTemplateProvider.ifAvailable { builder.transactionTemplateProvider(it) } exceptionMapper.ifAvailable { builder.exceptionMapper(it) } queryObserver.ifAvailable { builder.queryObserver(it) } + sqlCommenter.ifAvailable { builder.sqlCommenter(it) } return builder.build().withEntityCallbacks(entityCallbacks) } } diff --git a/storm-kotlin-spring-boot-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/storm-kotlin-spring-boot-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports index d0a4f6146..4c9b19fcc 100644 --- a/storm-kotlin-spring-boot-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports +++ b/storm-kotlin-spring-boot-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -4,3 +4,4 @@ st.orm.spring.boot.StormTransactionAutoConfiguration st.orm.spring.boot.StormValidationAutoConfiguration st.orm.spring.boot.StormExceptionTranslationAutoConfiguration st.orm.spring.boot.StormObservationAutoConfiguration +st.orm.spring.boot.StormTracingAutoConfiguration diff --git a/storm-kotlin/src/main/kotlin/st/orm/template/ORMTemplate.kt b/storm-kotlin/src/main/kotlin/st/orm/template/ORMTemplate.kt index 7e3b44e20..f97c60add 100644 --- a/storm-kotlin/src/main/kotlin/st/orm/template/ORMTemplate.kt +++ b/storm-kotlin/src/main/kotlin/st/orm/template/ORMTemplate.kt @@ -374,6 +374,14 @@ interface ORMTemplate : */ fun queryObserver(queryObserver: st.orm.core.spi.QueryObserver): Builder = apply { core.queryObserver(queryObserver) } + /** + * Sets the SQL commenter that appends per-execution comment content to statements, such as the + * current trace context. Note that per-execution content defeats prepared statement caching. + * + * @since 1.13 + */ + fun sqlCommenter(sqlCommenter: st.orm.core.spi.SqlCommenter): Builder = apply { core.sqlCommenter(sqlCommenter) } + /** * Builds the ORM template. */ 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 32d0c1038..d211befb2 100644 --- a/storm-ktor/src/main/kotlin/st/orm/ktor/Storm.kt +++ b/storm-ktor/src/main/kotlin/st/orm/ktor/Storm.kt @@ -123,6 +123,7 @@ val Storm = createApplicationPlugin(name = "Storm", createConfiguration = ::Stor .connectionProvider(pluginConfig.connectionProvider ?: JdbcConnectionProviderImpl()) .transactionTemplateProvider(pluginConfig.transactionTemplateProvider ?: JdbcTransactionTemplateProviderImpl()) pluginConfig.exceptionMapper?.let { builder.exceptionMapper(it) } + pluginConfig.sqlCommenter?.let { builder.sqlCommenter(it) } builder.queryObserver( pluginConfig.queryObserver ?: DelegatingQueryObserver().also { delegatingObservers[null] = it }, ) @@ -165,6 +166,7 @@ val Storm = createApplicationPlugin(name = "Storm", createConfiguration = ::Stor databaseConfig.transactionTemplateProvider ?: JdbcTransactionTemplateProviderImpl(), ) databaseConfig.exceptionMapper?.let { databaseBuilder.exceptionMapper(it) } + (databaseConfig.sqlCommenter ?: pluginConfig.sqlCommenter)?.let { databaseBuilder.sqlCommenter(it) } databaseBuilder.queryObserver( databaseConfig.queryObserver ?: DelegatingQueryObserver().also { delegatingObservers[name] = it }, ) diff --git a/storm-ktor/src/main/kotlin/st/orm/ktor/StormDatabaseConfig.kt b/storm-ktor/src/main/kotlin/st/orm/ktor/StormDatabaseConfig.kt index 40c61be38..8a21b5400 100644 --- a/storm-ktor/src/main/kotlin/st/orm/ktor/StormDatabaseConfig.kt +++ b/storm-ktor/src/main/kotlin/st/orm/ktor/StormDatabaseConfig.kt @@ -69,6 +69,14 @@ class StormDatabaseConfig internal constructor(internal val name: String) { */ var queryObserver: st.orm.core.spi.QueryObserver? = null + /** + * Optional [st.orm.core.spi.SqlCommenter] for this database; inherits the plugin-level commenter when + * unset. + * + * @since 1.13 + */ + var sqlCommenter: st.orm.core.spi.SqlCommenter? = null + /** * Schema validation mode for this database: `"none"`, `"warn"`, or `"fail"`. * diff --git a/storm-ktor/src/main/kotlin/st/orm/ktor/StormPluginConfig.kt b/storm-ktor/src/main/kotlin/st/orm/ktor/StormPluginConfig.kt index 32ee63a09..1f6267512 100644 --- a/storm-ktor/src/main/kotlin/st/orm/ktor/StormPluginConfig.kt +++ b/storm-ktor/src/main/kotlin/st/orm/ktor/StormPluginConfig.kt @@ -78,6 +78,15 @@ class StormPluginConfig { */ var queryObserver: st.orm.core.spi.QueryObserver? = null + /** + * Optional [st.orm.core.spi.SqlCommenter] that appends per-execution comment content to statements, such + * as the current trace context ([st.orm.micrometer.TraceContextSqlCommenter]). Note that per-execution + * content defeats prepared statement caching; enable selectively. + * + * @since 1.13 + */ + var sqlCommenter: st.orm.core.spi.SqlCommenter? = null + /** * Whether to expose the [st.orm.template.ORMTemplate] and the registered repositories through Ktor's * dependency injection (`ktor-server-di`). Each repository is registered under its own interface type, so diff --git a/storm-micrometer/pom.xml b/storm-micrometer/pom.xml index e7bb1d49b..17355130b 100644 --- a/storm-micrometer/pom.xml +++ b/storm-micrometer/pom.xml @@ -70,7 +70,7 @@ io.micrometer micrometer-tracing ${micrometer-tracing.version} - test + provided io.micrometer diff --git a/storm-micrometer/src/main/java/module-info.java b/storm-micrometer/src/main/java/module-info.java index 47e10d288..3aaf82260 100644 --- a/storm-micrometer/src/main/java/module-info.java +++ b/storm-micrometer/src/main/java/module-info.java @@ -3,6 +3,7 @@ requires storm.foundation; requires storm.core; requires micrometer.observation; + requires static micrometer.tracing; requires micrometer.commons; requires jakarta.annotation; } diff --git a/storm-micrometer/src/main/java/st/orm/micrometer/TraceContextSqlCommenter.java b/storm-micrometer/src/main/java/st/orm/micrometer/TraceContextSqlCommenter.java new file mode 100644 index 000000000..02da0fd04 --- /dev/null +++ b/storm-micrometer/src/main/java/st/orm/micrometer/TraceContextSqlCommenter.java @@ -0,0 +1,64 @@ +/* + * 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.tracing.Span; +import io.micrometer.tracing.TraceContext; +import io.micrometer.tracing.Tracer; +import jakarta.annotation.Nonnull; +import java.util.Optional; +import st.orm.core.spi.SqlCommenter; + +/** + * {@link SqlCommenter} that appends the current trace context to SQL statements, following the sqlcommenter + * convention with a W3C {@code traceparent} value: {@code traceparent='00-{traceId}-{spanId}-{flags}'}. + * + *

Database-side diagnostics that capture statements — slow query logs, statement views — then carry the + * trace identity, so a captured statement correlates directly to the trace that issued it, and the trace to + * the exact database-side execution.

+ * + *

Statements execute uncommented when no span is current. Note that a per-execution comment changes the + * statement text on every call, which defeats driver-side and server-side prepared statement caching; enable + * selectively where the correlation is worth that trade-off.

+ * + * @since 1.13 + */ +public class TraceContextSqlCommenter implements SqlCommenter { + + private final Tracer tracer; + + /** + * Creates a commenter reading the current span from the given tracer. + * + * @param tracer the tracer providing the current trace context. + */ + public TraceContextSqlCommenter(@Nonnull Tracer tracer) { + this.tracer = requireNonNull(tracer, "tracer"); + } + + @Override + public Optional comment() { + Span span = tracer.currentSpan(); + if (span == null) { + return Optional.empty(); + } + TraceContext context = span.context(); + String flags = Boolean.TRUE.equals(context.sampled()) ? "01" : "00"; + return Optional.of("traceparent='00-%s-%s-%s'".formatted(context.traceId(), context.spanId(), flags)); + } +} diff --git a/storm-micrometer/src/test/java/st/orm/micrometer/SqlCommenterTest.java b/storm-micrometer/src/test/java/st/orm/micrometer/SqlCommenterTest.java new file mode 100644 index 000000000..c9f851dbd --- /dev/null +++ b/storm-micrometer/src/test/java/st/orm/micrometer/SqlCommenterTest.java @@ -0,0 +1,133 @@ +/* + * 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 static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import io.micrometer.tracing.test.simple.SimpleTracer; +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.Proxy; +import java.sql.Connection; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import javax.sql.DataSource; +import org.junit.jupiter.api.Test; +import st.orm.PersistenceException; +import st.orm.core.template.ORMTemplate; + +/** + * Tests for the SQL commenter: comment content is appended to statements at execution time, hostile content + * is rejected, and {@link TraceContextSqlCommenter} renders the current trace context in W3C traceparent + * format. + */ +public class SqlCommenterTest { + + /** Wraps a DataSource, recording the SQL of every prepared statement. */ + private static DataSource capturing(DataSource delegate, List statements) { + return (DataSource) Proxy.newProxyInstance(SqlCommenterTest.class.getClassLoader(), + new Class[] {DataSource.class}, (proxy, method, args) -> { + Object result = method.invoke(delegate, args); + if (result instanceof Connection connection) { + return Proxy.newProxyInstance(SqlCommenterTest.class.getClassLoader(), + new Class[] {Connection.class}, connectionHandler(connection, statements)); + } + return result; + }); + } + + private static InvocationHandler connectionHandler(Connection connection, List statements) { + return (proxy, method, args) -> { + if (method.getName().equals("prepareStatement") && args != null && args[0] instanceof String sql) { + statements.add(sql); + } + return method.invoke(connection, args); + }; + } + + @Test + public void commentIsAppendedAtExecutionTime() { + List statements = new ArrayList<>(); + var orm = ORMTemplate.builder(capturing( + new SimpleDataSource("jdbc:h2:mem:commenter;DB_CLOSE_DELAY=-1"), statements)) + .sqlCommenter(() -> Optional.of("traceparent='00-abc-def-01'")) + .build(); + orm.query("SELECT 1").getSingleResult(Integer.class); + assertTrue(statements.stream().anyMatch(sql -> sql.endsWith("/* traceparent='00-abc-def-01' */")), + statements.toString()); + } + + @Test + public void emptyCommentLeavesTheStatementUntouched() { + List statements = new ArrayList<>(); + var orm = ORMTemplate.builder(capturing( + new SimpleDataSource("jdbc:h2:mem:commenterempty;DB_CLOSE_DELAY=-1"), statements)) + .sqlCommenter(Optional::empty) + .build(); + orm.query("SELECT 1").getSingleResult(Integer.class); + assertFalse(statements.stream().anyMatch(sql -> sql.contains("/*")), statements.toString()); + } + + @Test + public void hostileCommentContentIsRejected() { + var orm = ORMTemplate.builder(new SimpleDataSource("jdbc:h2:mem:commenterhostile;DB_CLOSE_DELAY=-1")) + .sqlCommenter(() -> Optional.of("x'*/ DROP TABLE pets; --")) + .build(); + assertThrows(PersistenceException.class, () -> orm.query("SELECT 1").getSingleResult(Integer.class)); + } + + @Test + public void semicolonsInCommentContentAreRejected() { + // Inert to the SQL parser inside a comment, but naive statement splitters in drivers and proxies + // split on semicolons; sqlcommenter values URL-encode them instead. + var orm = ORMTemplate.builder(new SimpleDataSource("jdbc:h2:mem:commentersemi;DB_CLOSE_DELAY=-1")) + .sqlCommenter(() -> Optional.of("route='a;b'")) + .build(); + assertThrows(PersistenceException.class, () -> orm.query("SELECT 1").getSingleResult(Integer.class)); + } + + @Test + public void executableCommentMarkersAreNeutralizedByPadding() { + // MySQL and MariaDB interpret /*! ... */ as an executable comment and /*+ ... */ as an optimizer + // hint; the padding space after the comment opener keeps such content a plain comment. + List statements = new ArrayList<>(); + var orm = ORMTemplate.builder(capturing( + new SimpleDataSource("jdbc:h2:mem:commenterexec;DB_CLOSE_DELAY=-1"), statements)) + .sqlCommenter(() -> Optional.of("!40101 SELECT 1")) + .build(); + orm.query("SELECT 1").getSingleResult(Integer.class); + assertTrue(statements.stream().anyMatch(sql -> sql.endsWith("/* !40101 SELECT 1 */")), + statements.toString()); + } + + @Test + public void traceContextCommenterRendersTraceparent() { + var tracer = new SimpleTracer(); + var commenter = new TraceContextSqlCommenter(tracer); + assertEquals(Optional.empty(), commenter.comment()); + var span = tracer.nextSpan().start(); + try (var ignored = tracer.withSpan(span)) { + var comment = commenter.comment().orElseThrow(); + assertTrue(comment.matches("traceparent='00-[0-9a-f]+-[0-9a-f]+-0[01]'"), comment); + } finally { + span.end(); + } + assertEquals(Optional.empty(), commenter.comment()); + } +} diff --git a/storm-spring-boot-starter/pom.xml b/storm-spring-boot-starter/pom.xml index 6d7a7a7a9..948f6f5fc 100644 --- a/storm-spring-boot-starter/pom.xml +++ b/storm-spring-boot-starter/pom.xml @@ -160,5 +160,11 @@ ${micrometer.version} test
+ + io.micrometer + micrometer-tracing + ${micrometer-tracing.version} + test + diff --git a/storm-spring-boot-starter/src/main/java/st/orm/spring/boot/autoconfigure/StormAutoConfiguration.java b/storm-spring-boot-starter/src/main/java/st/orm/spring/boot/autoconfigure/StormAutoConfiguration.java index 11c40c954..05a345431 100644 --- a/storm-spring-boot-starter/src/main/java/st/orm/spring/boot/autoconfigure/StormAutoConfiguration.java +++ b/storm-spring-boot-starter/src/main/java/st/orm/spring/boot/autoconfigure/StormAutoConfiguration.java @@ -29,6 +29,7 @@ import st.orm.core.spi.ConnectionProvider; import st.orm.core.spi.ExceptionMapper; import st.orm.core.spi.QueryObserver; +import st.orm.core.spi.SqlCommenter; import st.orm.core.spi.TransactionTemplateProvider; import st.orm.spring.boot.StormProperties; import st.orm.template.ORMTemplate; @@ -69,12 +70,14 @@ public ORMTemplate ormTemplate(DataSource dataSource, StormProperties properties ObjectProvider connectionProvider, ObjectProvider transactionTemplateProvider, ObjectProvider exceptionMapper, - ObjectProvider queryObserver) { + ObjectProvider queryObserver, + ObjectProvider sqlCommenter) { var builder = ORMTemplate.builder(dataSource).config(properties.toStormConfig()); connectionProvider.ifAvailable(builder::connectionProvider); transactionTemplateProvider.ifAvailable(builder::transactionTemplateProvider); exceptionMapper.ifAvailable(builder::exceptionMapper); queryObserver.ifAvailable(builder::queryObserver); + sqlCommenter.ifAvailable(builder::sqlCommenter); return builder.build().withEntityCallbacks(entityCallbacks); } diff --git a/storm-spring-boot-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/storm-spring-boot-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports index d0a4f6146..4c9b19fcc 100644 --- a/storm-spring-boot-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports +++ b/storm-spring-boot-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -4,3 +4,4 @@ st.orm.spring.boot.StormTransactionAutoConfiguration st.orm.spring.boot.StormValidationAutoConfiguration st.orm.spring.boot.StormExceptionTranslationAutoConfiguration st.orm.spring.boot.StormObservationAutoConfiguration +st.orm.spring.boot.StormTracingAutoConfiguration 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 42c32b025..c6125973a 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 @@ -32,6 +32,7 @@ import st.orm.spring.boot.StormExceptionTranslationAutoConfiguration; import st.orm.spring.boot.StormObservationAutoConfiguration; import st.orm.spring.boot.StormProperties; +import st.orm.spring.boot.StormTracingAutoConfiguration; import st.orm.spring.boot.StormValidationAutoConfiguration; import st.orm.template.ORMTemplate; @@ -45,7 +46,8 @@ class StormAutoConfigurationTest { StormRepositoryAutoConfiguration.class, StormValidationAutoConfiguration.class, StormExceptionTranslationAutoConfiguration.class, - StormObservationAutoConfiguration.class + StormObservationAutoConfiguration.class, + StormTracingAutoConfiguration.class )); @Test @@ -575,6 +577,31 @@ void unknownSemanticConventionsValueFailsFast() { .run(context -> assertThat(context).hasFailed()); } + @Test + void traceSqlCommentsAreOptIn() { + // Off by default even with a Tracer present: a per-execution comment defeats prepared statement + // caching, so the correlation is enabled deliberately. + contextRunner + .withPropertyValues( + "spring.datasource.url=jdbc:h2:mem:sqlCommentsDefaultTest;DB_CLOSE_DELAY=-1", + "spring.datasource.driver-class-name=org.h2.Driver" + ) + .withBean(io.micrometer.tracing.Tracer.class, () -> org.mockito.Mockito.mock(io.micrometer.tracing.Tracer.class)) + .run(context -> assertThat(context).doesNotHaveBean(st.orm.core.spi.SqlCommenter.class)); + contextRunner + .withPropertyValues( + "spring.datasource.url=jdbc:h2:mem:sqlCommentsEnabledTest;DB_CLOSE_DELAY=-1", + "spring.datasource.driver-class-name=org.h2.Driver", + "storm.tracing.sql-comments=true" + ) + .withBean(io.micrometer.tracing.Tracer.class, () -> org.mockito.Mockito.mock(io.micrometer.tracing.Tracer.class)) + .run(context -> { + assertThat(context).hasSingleBean(st.orm.core.spi.SqlCommenter.class); + assertThat(context).getBean(st.orm.core.spi.SqlCommenter.class) + .isInstanceOf(st.orm.micrometer.TraceContextSqlCommenter.class); + }); + } + @Configuration static class TestObservationRegistryConfig { @Bean diff --git a/storm-spring/pom.xml b/storm-spring/pom.xml index 5f21d8f47..127fd7530 100644 --- a/storm-spring/pom.xml +++ b/storm-spring/pom.xml @@ -111,6 +111,12 @@ ${project.version} provided + + io.micrometer + micrometer-tracing + ${micrometer-tracing.version} + provided + org.springframework.boot spring-boot-starter diff --git a/storm-spring/src/main/java/module-info.java b/storm-spring/src/main/java/module-info.java index 70520afa9..c18bf10d3 100644 --- a/storm-spring/src/main/java/module-info.java +++ b/storm-spring/src/main/java/module-info.java @@ -2,6 +2,7 @@ requires static storm.java; requires static storm.micrometer; requires static micrometer.observation; + requires static micrometer.tracing; requires static micrometer.commons; requires spring.jdbc; requires spring.tx; 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 7dfa9588c..0bb819703 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 @@ -60,6 +60,9 @@ public class StormProperties { /** Query observation configuration. */ private Observations observations = new Observations(); + /** Tracing configuration. */ + private Tracing tracing = new Tracing(); + /** Whether to enable ANSI escape sequences in Storm's log output. */ private Boolean ansiEscaping; @@ -90,6 +93,12 @@ public class StormProperties { /** Returns the query observation configuration. */ public Observations getObservations() { return observations; } + /** Returns the tracing configuration. */ + public Tracing getTracing() { return tracing; } + + /** Sets the tracing configuration. */ + public void setTracing(Tracing tracing) { this.tracing = tracing; } + /** Sets the query observation configuration. */ public void setObservations(Observations observations) { this.observations = observations; } @@ -232,6 +241,22 @@ public static class Validation { * * @since 1.13 */ + /** Tracing configuration. */ + public static class Tracing { + + /** + * Whether the current trace context is appended to SQL statements as a sqlcommenter-style comment. + * Defaults to false: a per-execution comment defeats prepared statement caching. + */ + private Boolean sqlComments; + + /** Returns whether trace context SQL comments are enabled. */ + public Boolean getSqlComments() { return sqlComments; } + + /** Sets whether trace context SQL comments are enabled. */ + public void setSqlComments(Boolean sqlComments) { this.sqlComments = sqlComments; } + } + /** Query observation configuration. */ public static class Observations { diff --git a/storm-spring/src/main/java/st/orm/spring/boot/StormTracingAutoConfiguration.java b/storm-spring/src/main/java/st/orm/spring/boot/StormTracingAutoConfiguration.java new file mode 100644 index 000000000..c18c2c58e --- /dev/null +++ b/storm-spring/src/main/java/st/orm/spring/boot/StormTracingAutoConfiguration.java @@ -0,0 +1,54 @@ +/* + * 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.spring.boot; + +import io.micrometer.tracing.Tracer; +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.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import st.orm.core.spi.SqlCommenter; +import st.orm.micrometer.TraceContextSqlCommenter; + +/** + * Auto-configuration that appends the current trace context to SQL statements as a sqlcommenter-style + * comment ({@code /*traceparent='00-…'*/}), correlating database-side diagnostics such as slow query + * logs with traces. Shared by the Java and Kotlin Spring Boot starters. + * + *

Opt-in via {@code storm.tracing.sql-comments=true}: a per-execution comment changes the statement text + * on every call, which defeats driver-side and server-side prepared statement caching. The commenter is + * handed to the {@code ORMTemplate} created by the starter's auto-configuration; define your own + * {@link SqlCommenter} bean to append different content.

+ * + * @since 1.13 + */ +@AutoConfiguration +@ConditionalOnClass({Tracer.class, TraceContextSqlCommenter.class}) +@ConditionalOnBean(Tracer.class) +@ConditionalOnProperty(name = "storm.tracing.sql-comments", havingValue = "true") +public class StormTracingAutoConfiguration { + + /** + * Provides the SQL commenter that appends the current trace context to statements. + */ + @Bean + @ConditionalOnMissingBean(SqlCommenter.class) + public SqlCommenter stormSqlCommenter(Tracer tracer) { + return new TraceContextSqlCommenter(tracer); + } +}