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 @@ -38,6 +38,7 @@ Framework integration points are now instance-scoped (#198), and the Ktor integr
- 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.
- Transactions are observed alongside queries: every physical transaction (outermost block or `REQUIRES_NEW`, blocking, suspend, and Spring-bridged alike) reports as a `storm.transaction` Micrometer Observation with duration, outcome (`committed`/`rolled_back`), propagation, and read-only key values; joined blocks are not double-counted. The `QueryObserver` SPI gains a default no-op transaction hook, so existing observers and wiring are unaffected.
- `@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: 1 addition & 1 deletion docs/ktor-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -725,7 +725,7 @@ install(Storm) {
```
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:
Physical transactions report as `storm.transaction` observations with their duration, outcome (`committed` or `rolled_back`), propagation, and read-only flag; joined blocks are not double-counted. Query observations are named `storm.query` and carry the following key values:

| Key | Cardinality | Value |
|-----|-------------|-------|
Expand Down
2 changes: 2 additions & 0 deletions docs/spring-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -624,6 +624,8 @@ 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.

Transactions are observed alongside the queries: every physical transaction — an outermost `transaction` block or a `REQUIRES_NEW` block, in any language stack and through the Spring bridge alike — reports as a `storm.transaction` observation with its duration, `storm.tx.outcome` (`committed` or `rolled_back`), `storm.tx.propagation`, and `storm.tx.read_only`. Joined blocks run inside an existing transaction and are deliberately not double-counted. Long-running transactions and rollback rates become queryable per application, and per domain with the multi-template tagging.

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
Expand Down
55 changes: 55 additions & 0 deletions storm-core/src/main/java/st/orm/core/spi/QueryObserver.java
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,61 @@ public interface QueryObserver {
*/
Observation onExecute(@Nonnull QueryContext context);

/**
* Called when a physical transaction opens: an outermost transaction block, or a {@code REQUIRES_NEW}
* block. Joined blocks and savepoint scopes are not physical transactions and are not observed.
*
* <p>The default implementation ignores transactions.</p>
*
* @param options the options the transaction was opened with; never {@code null}.
* @return the observation tracking this transaction; never {@code null}.
* @since 1.13
*/
default TransactionObservation onTransaction(@Nonnull TransactionScope.Options options) {
return TransactionObservation.NOOP;
}

/**
* Tracks a single observed physical transaction.
*
* @since 1.13
*/
interface TransactionObservation {

/**
* An observation that ignores all signals.
*/
TransactionObservation NOOP = new TransactionObservation() {
@Override
public void error(@Nonnull Throwable throwable) {
// Ignore.
}

@Override
public void close(boolean rolledBack) {
// Ignore.
}
};

/**
* Signals that completing the observed transaction failed.
*
* <p>Invoked at most once, before {@link #close(boolean)}.</p>
*
* @param throwable the failure.
*/
void error(@Nonnull Throwable throwable);

/**
* Signals that the observed transaction has completed.
*
* <p>Invoked exactly once per observation.</p>
*
* @param rolledBack whether the transaction rolled back rather than committed.
*/
void close(boolean rolledBack);
}

/**
* Tracks a single observed statement execution.
*
Expand Down
85 changes: 83 additions & 2 deletions storm-core/src/main/java/st/orm/core/spi/TransactionScope.java
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,9 @@ public record Options(@Nullable TransactionPropagation propagation,

private static final ThreadLocal<TransactionScope> CURRENT = new ThreadLocal<>();

private static final java.util.logging.Logger OBSERVER_LOGGER =
java.util.logging.Logger.getLogger("st.orm.transaction");

/**
* Returns the thread local that holds the current transaction scope.
*
Expand Down Expand Up @@ -127,6 +130,19 @@ public static TransactionScope create(@Nonnull Options options, @Nullable Transa
* @return the transaction context to execute under, or {@code null} when no transaction is active.
* @throws PersistenceException if the active scope is owned by a different provider.
*/
public static @Nullable TransactionContext resolveContext(@Nonnull TransactionTemplateProvider provider,
@Nullable QueryObserver queryObserver) {
var scope = current();
if (scope != null) {
return scope.getOrMaterializeContext(provider, queryObserver);
}
return provider.getTransactionTemplate().currentContext().orElse(null);
}

/**
* Resolves the transaction context without a query observer; physical transactions opened through this
* path are not observed.
*/
public static @Nullable TransactionContext resolveContext(@Nonnull TransactionTemplateProvider provider) {
var scope = CURRENT.get();
if (scope != null) {
Expand Down Expand Up @@ -165,6 +181,7 @@ public static TransactionScope create(@Nonnull Options options, @Nullable Transa

private volatile @Nullable TransactionTemplateProvider owner;
private volatile @Nullable TransactionHandle handle;
private @Nullable QueryObserver.TransactionObservation observation;
private boolean rollbackOnly; // Buffered until materialization; guarded by this.
private boolean rollbackInherited; // Set when the mark came from a joined inner scope; guarded by this.

Expand Down Expand Up @@ -232,6 +249,21 @@ public boolean isMaterialized() {
* @throws PersistenceException if this scope, or an outer scope it must join, is owned by a different provider.
*/
public synchronized TransactionContext getOrMaterializeContext(@Nonnull TransactionTemplateProvider provider) {
return getOrMaterializeContext(provider, null);
}

/**
* Returns the transaction context of this scope, materializing it through the given provider if needed and
* reporting a newly opened physical transaction to the given query observer.
*
* @param provider the transaction template provider of the executing template.
* @param queryObserver the query observer of the executing template, or {@code null} to observe nothing.
* @return the transaction context of this scope.
* @throws PersistenceException if this scope, or an outer scope it must join, is owned by a different provider.
* @since 1.13
*/
public synchronized TransactionContext getOrMaterializeContext(@Nonnull TransactionTemplateProvider provider,
@Nullable QueryObserver queryObserver) {
var existingHandle = handle;
if (existingHandle != null) {
if (owner != provider) {
Expand All @@ -243,7 +275,7 @@ public synchronized TransactionContext getOrMaterializeContext(@Nonnull Transact
// place before this scope opens. This preserves the semantics of eagerly entered transaction blocks: joining
// propagations share the outer transaction, MANDATORY finds it, NEVER detects it, and REQUIRES_NEW suspends
// it. The provider-identity check happens naturally in the ancestor's own materialization.
TransactionContext outerContext = parent != null ? parent.getOrMaterializeContext(provider) : null;
TransactionContext outerContext = parent != null ? parent.getOrMaterializeContext(provider, queryObserver) : null;
var template = provider.getTransactionTemplate();
if (options.propagation() != null) {
template = template.propagation(options.propagation());
Expand All @@ -264,9 +296,33 @@ public synchronized TransactionContext getOrMaterializeContext(@Nonnull Transact
}
this.owner = provider;
this.handle = newHandle;
if (queryObserver != null && isPhysicalTransaction(outerContext)) {
try {
this.observation = queryObserver.onTransaction(options);
} catch (Throwable observerFailure) {
OBSERVER_LOGGER.log(java.util.logging.Level.WARNING,
"Transaction observer failed on open; transaction unaffected.", observerFailure);
}
}
return newHandle.context();
}

/**
* Returns whether this scope opens a physical transaction of its own: an outermost transactional block,
* or a block that suspends the enclosing transaction with {@code REQUIRES_NEW}. Joined blocks and
* savepoint scopes run inside an existing physical transaction.
*/
private boolean isPhysicalTransaction(@Nullable TransactionContext outerContext) {
var propagation = options.propagation();
if (propagation == TransactionPropagation.REQUIRES_NEW) {
return true;
}
return outerContext == null
&& (propagation == null
|| propagation == TransactionPropagation.REQUIRED
|| propagation == TransactionPropagation.MANDATORY);
}

/**
* Returns the number of seconds remaining until the scope's deadline, rounded up; {@code 0} when the deadline
* has already passed.
Expand Down Expand Up @@ -370,14 +426,39 @@ public synchronized boolean isRollbackOnly() {
public synchronized void complete(boolean rollback) {
var currentHandle = handle;
if (currentHandle != null) {
currentHandle.complete(rollback);
var currentObservation = observation;
observation = null;
try {
currentHandle.complete(rollback);
} catch (Throwable completionFailure) {
closeObservation(currentObservation, true, completionFailure);
throw completionFailure;
}
closeObservation(currentObservation, rollback || rollbackOnly, null);
return;
}
if ((rollback || rollbackOnly) && isJoining() && parent != null) {
parent.markRollbackInherited();
}
}

private static void closeObservation(@Nullable QueryObserver.TransactionObservation observation,
boolean rolledBack,
@Nullable Throwable error) {
if (observation == null) {
return;
}
try {
if (error != null) {
observation.error(error);
}
observation.close(rolledBack);
} catch (Throwable observerFailure) {
OBSERVER_LOGGER.log(java.util.logging.Level.WARNING,
"Transaction observer failed on completion; transaction unaffected.", observerFailure);
}
}

/**
* Uninstalls this scope from the current thread, restoring its parent as the current scope.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -308,7 +308,8 @@ private static TemplateProcessor createDataSourceProcessor(@Nonnull DataSource d
var parameters = sql.parameters();
var bindVariables = sql.bindVariables().orElse(null);
var generatedKeys = sql.generatedKeys();
var transactionContext = TransactionScope.resolveContext(transactionTemplateProvider);
var transactionContext = TransactionScope.resolveContext(transactionTemplateProvider,
strategies.queryObserver());
Connection connection = connectionProvider.getConnection(dataSource, transactionContext);
PreparedStatement preparedStatement = null;
boolean success = false;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
import jakarta.annotation.Nonnull;
import st.orm.core.spi.QueryContext;
import st.orm.core.spi.QueryObserver;
import st.orm.core.spi.TransactionScope;

/**
* {@link QueryObserver} that reports Storm query executions as Micrometer
Expand Down Expand Up @@ -84,6 +85,33 @@ public MicrometerQueryObserver(@Nonnull ObservationRegistry observationRegistry,
this.extraLowCardinalityKeyValues = requireNonNull(extraLowCardinalityKeyValues, "extraLowCardinalityKeyValues");
}

@Override
public TransactionObservation onTransaction(@Nonnull TransactionScope.Options options) {
var observation = io.micrometer.observation.Observation
.createNotStarted("storm.transaction", observationRegistry)
.contextualName("transaction")
.lowCardinalityKeyValue("storm.tx.propagation",
options.propagation() != null ? options.propagation().name() : "REQUIRED")
.lowCardinalityKeyValue("storm.tx.read_only",
String.valueOf(Boolean.TRUE.equals(options.readOnly())));
for (var keyValue : extraLowCardinalityKeyValues) {
observation = observation.lowCardinalityKeyValue(keyValue.getKey(), keyValue.getValue());
}
var started = observation.start();
return new TransactionObservation() {
@Override
public void error(@Nonnull Throwable throwable) {
started.error(throwable);
}

@Override
public void close(boolean rolledBack) {
started.lowCardinalityKeyValue("storm.tx.outcome", rolledBack ? "rolled_back" : "committed");
started.stop();
}
};
}

@Override
public Observation onExecute(@Nonnull QueryContext context) {
var observationContext = new StormQueryObservationContext(context, extraLowCardinalityKeyValues);
Expand Down
Loading
Loading