dependentTables() {
diff --git a/ebean-core/src/main/java/io/ebeaninternal/server/query/CQueryUpdate.java b/ebean-core/src/main/java/io/ebeaninternal/server/query/CQueryUpdate.java
index add07241a7..bf05565552 100644
--- a/ebean-core/src/main/java/io/ebeaninternal/server/query/CQueryUpdate.java
+++ b/ebean-core/src/main/java/io/ebeaninternal/server/query/CQueryUpdate.java
@@ -115,7 +115,7 @@ private void close() {
public void profile() {
transaction()
.profileStream()
- .addQueryEvent(query.profileEventId(), profileOffset, desc.name(), rowCount, query.profileId());
+ .addQueryEvent(query.profileEventId(), profileOffset, desc.name(), rowCount, query.profileId(), query.getGeneratedSql());
}
@Override
diff --git a/ebean-core/src/main/java/io/ebeaninternal/server/transaction/DefaultProfileHandler.java b/ebean-core/src/main/java/io/ebeaninternal/server/transaction/DefaultProfileHandler.java
index c690a80619..755c4e44d9 100644
--- a/ebean-core/src/main/java/io/ebeaninternal/server/transaction/DefaultProfileHandler.java
+++ b/ebean-core/src/main/java/io/ebeaninternal/server/transaction/DefaultProfileHandler.java
@@ -7,6 +7,7 @@
import io.ebean.util.IOUtils;
import io.ebeaninternal.api.CoreLog;
import io.ebeaninternal.api.SpiProfileHandler;
+import org.jspecify.annotations.Nullable;
import java.io.File;
import java.io.IOException;
@@ -94,10 +95,14 @@ public void collectTransactionProfile(TransactionProfile transactionProfile) {
}
/**
- * Create and return a ProfileStream.
+ * Create and return a ProfileStream, or null if location is null (implicit transactions
+ * are not profiled by the default file-based handler).
*/
@Override
- public ProfileStream createProfileStream(ProfileLocation location) {
+ public ProfileStream createProfileStream(@Nullable ProfileLocation location, String label) {
+ if (location == null) {
+ return null;
+ }
return new DefaultProfileStream(location, verbose);
}
diff --git a/ebean-core/src/main/java/io/ebeaninternal/server/transaction/DefaultProfileStream.java b/ebean-core/src/main/java/io/ebeaninternal/server/transaction/DefaultProfileStream.java
index 0cb1b2959a..f554bd117f 100644
--- a/ebean-core/src/main/java/io/ebeaninternal/server/transaction/DefaultProfileStream.java
+++ b/ebean-core/src/main/java/io/ebeaninternal/server/transaction/DefaultProfileStream.java
@@ -1,6 +1,7 @@
package io.ebeaninternal.server.transaction;
import io.ebean.ProfileLocation;
+import org.jspecify.annotations.Nullable;
/**
* Default transaction profiling event collection.
@@ -12,7 +13,7 @@ public final class DefaultProfileStream implements ProfileStream {
private final TransactionProfile profile;
private final TransactionProfile.Summary summary;
- DefaultProfileStream(ProfileLocation location, boolean verbose) {
+ DefaultProfileStream(@Nullable ProfileLocation location, boolean verbose) {
this.startNanos = System.nanoTime();
this.profile = new TransactionProfile(System.currentTimeMillis(), location);
this.summary = profile.getSummary();
@@ -35,7 +36,7 @@ private long exeMicros(long offset) {
* Add a query execution event.
*/
@Override
- public void addQueryEvent(String event, long offset, String beanName, int beanCount, String queryId) {
+ public void addQueryEvent(String event, long offset, String beanName, int beanCount, String queryId, String sql) {
long micros = exeMicros(offset);
summary.addQuery(micros, beanCount);
if (buffer != null) {
@@ -82,7 +83,7 @@ private void add(long micros, String event, long offset, String beanName, int be
* End the transaction profiling.
*/
@Override
- public void end(TransactionManager manager) {
+ public void end(TransactionManager manager, String label) {
profile.setTotalMicros(offset());
if (buffer != null) {
profile.setData(buffer.toString());
diff --git a/ebean-core/src/main/java/io/ebeaninternal/server/transaction/ImplicitReadOnlyTransaction.java b/ebean-core/src/main/java/io/ebeaninternal/server/transaction/ImplicitReadOnlyTransaction.java
index b8b647a325..b656b521d2 100644
--- a/ebean-core/src/main/java/io/ebeaninternal/server/transaction/ImplicitReadOnlyTransaction.java
+++ b/ebean-core/src/main/java/io/ebeaninternal/server/transaction/ImplicitReadOnlyTransaction.java
@@ -41,6 +41,7 @@ final class ImplicitReadOnlyTransaction implements SpiTransaction, TxnProfileEve
private final SpiTxnLogger logger;
private final boolean logSql;
private final boolean logSummary;
+ private ProfileStream profileStream;
/**
* The status of the transaction.
@@ -117,22 +118,24 @@ public String label() {
@Override
public long profileOffset() {
- return 0;
+ return (profileStream == null) ? 0 : profileStream.offset();
}
@Override
public void profileEvent(SpiProfileTransactionEvent event) {
- // do nothing
+ if (profileStream != null) {
+ event.profile();
+ }
}
@Override
public void setProfileStream(ProfileStream profileStream) {
- // do nothing
+ this.profileStream = profileStream;
}
@Override
public ProfileStream profileStream() {
- return null;
+ return profileStream;
}
@Override
@@ -497,6 +500,9 @@ private void deactivate() {
connection = null;
active = false;
manager.collectMetricReadOnly((System.nanoTime() - startNanos) / 1000L);
+ if (profileStream != null) {
+ profileStream.end(manager, null);
+ }
}
/**
diff --git a/ebean-core/src/main/java/io/ebeaninternal/server/transaction/JdbcTransaction.java b/ebean-core/src/main/java/io/ebeaninternal/server/transaction/JdbcTransaction.java
index f1e41a5df5..3e85dac982 100644
--- a/ebean-core/src/main/java/io/ebeaninternal/server/transaction/JdbcTransaction.java
+++ b/ebean-core/src/main/java/io/ebeaninternal/server/transaction/JdbcTransaction.java
@@ -856,7 +856,7 @@ private void profileEnd() {
}
manager.collectMetric(exeMicros);
if (profileStream != null) {
- profileStream.end(manager);
+ profileStream.end(manager, label);
}
}
}
diff --git a/ebean-core/src/main/java/io/ebeaninternal/server/transaction/NoopProfileHandler.java b/ebean-core/src/main/java/io/ebeaninternal/server/transaction/NoopProfileHandler.java
index a994987c52..fee341ca69 100644
--- a/ebean-core/src/main/java/io/ebeaninternal/server/transaction/NoopProfileHandler.java
+++ b/ebean-core/src/main/java/io/ebeaninternal/server/transaction/NoopProfileHandler.java
@@ -2,6 +2,7 @@
import io.ebean.ProfileLocation;
import io.ebeaninternal.api.SpiProfileHandler;
+import org.jspecify.annotations.Nullable;
/**
* A do nothing SpiProfileHandler.
@@ -14,7 +15,7 @@ public void collectTransactionProfile(TransactionProfile transactionProfile) {
}
@Override
- public ProfileStream createProfileStream(ProfileLocation location) {
+ public ProfileStream createProfileStream(@Nullable ProfileLocation location, String label) {
// always return null
return null;
}
diff --git a/ebean-core/src/main/java/io/ebeaninternal/server/transaction/ProfileStream.java b/ebean-core/src/main/java/io/ebeaninternal/server/transaction/ProfileStream.java
index 1ce0000851..2fb4660c3e 100644
--- a/ebean-core/src/main/java/io/ebeaninternal/server/transaction/ProfileStream.java
+++ b/ebean-core/src/main/java/io/ebeaninternal/server/transaction/ProfileStream.java
@@ -13,7 +13,7 @@ public interface ProfileStream {
/**
* Add a query event.
*/
- void addQueryEvent(String event, long offset, String beanName, int beanCount, String queryId);
+ void addQueryEvent(String event, long offset, String beanName, int beanCount, String queryId, String sql);
/**
* Add a persist event.
@@ -28,5 +28,5 @@ public interface ProfileStream {
/**
* Transaction completed collect the profiling information.
*/
- void end(TransactionManager manager);
+ void end(TransactionManager manager, String label);
}
diff --git a/ebean-core/src/main/java/io/ebeaninternal/server/transaction/TransactionManager.java b/ebean-core/src/main/java/io/ebeaninternal/server/transaction/TransactionManager.java
index 8463c4616f..60f8fcb2d6 100644
--- a/ebean-core/src/main/java/io/ebeaninternal/server/transaction/TransactionManager.java
+++ b/ebean-core/src/main/java/io/ebeaninternal/server/transaction/TransactionManager.java
@@ -301,7 +301,12 @@ public SpiTransaction createTransaction(boolean explicit, int isolationLevel) {
* Create a new Transaction for query only purposes (can use read only datasource).
*/
public SpiTransaction createReadOnlyTransaction(Object tenantId, boolean useMaster) {
- return transactionFactory.createReadOnlyTransaction(tenantId, useMaster);
+ SpiTransaction t = transactionFactory.createReadOnlyTransaction(tenantId, useMaster);
+ ProfileStream stream = profileHandler.createProfileStream(null, "readOnly");
+ if (stream != null) {
+ t.setProfileStream(stream);
+ }
+ return t;
}
/**
@@ -464,6 +469,10 @@ public final void clearServerTransaction() {
*/
public final SpiTransaction beginServerTransaction() {
SpiTransaction t = createTransaction(false, -1);
+ ProfileStream stream = profileHandler.createProfileStream(null, null);
+ if (stream != null) {
+ t.setProfileStream(stream);
+ }
scopeManager.set(t);
return t;
}
@@ -569,9 +578,10 @@ private void initNewTransaction(SpiTransaction transaction, TxScope txScope) {
registerProfileLocation(profileLocation);
}
transaction.setProfileLocation(profileLocation);
- if (profileLocation.trace()) {
- transaction.setProfileStream(profileHandler.createProfileStream(profileLocation));
- }
+ }
+ ProfileStream stream = profileHandler.createProfileStream(profileLocation, label);
+ if (stream != null) {
+ transaction.setProfileStream(stream);
}
}
diff --git a/ebean-core/src/main/java/io/ebeaninternal/server/transaction/TransactionProfile.java b/ebean-core/src/main/java/io/ebeaninternal/server/transaction/TransactionProfile.java
index cb749cd546..d671ac31a4 100644
--- a/ebean-core/src/main/java/io/ebeaninternal/server/transaction/TransactionProfile.java
+++ b/ebean-core/src/main/java/io/ebeaninternal/server/transaction/TransactionProfile.java
@@ -28,7 +28,7 @@ public final class TransactionProfile {
*/
public TransactionProfile(long startTime, ProfileLocation location) {
this.location = location;
- this.label = location.label();
+ this.label = (location != null) ? location.label() : null;
this.startTime = startTime;
this.summary = new Summary();
}
diff --git a/ebean-core/src/main/java/module-info.java b/ebean-core/src/main/java/module-info.java
index 572b535e89..127cf00b93 100644
--- a/ebean-core/src/main/java/module-info.java
+++ b/ebean-core/src/main/java/module-info.java
@@ -18,6 +18,7 @@
uses io.ebeaninternal.api.SpiDdlGeneratorProvider;
uses io.ebeaninternal.xmapping.api.XmapService;
uses io.ebeaninternal.server.autotune.AutoTuneServiceProvider;
+ uses io.ebeaninternal.api.SpiProfileHandler;
uses io.ebeaninternal.server.cluster.ClusterBroadcastFactory;
requires transitive io.ebean.api;
@@ -48,7 +49,7 @@
exports io.ebeanservice.docstore.api.support to io.ebean.elastic, io.ebean.test;
exports io.ebeanservice.docstore.api.mapping to io.ebean.elastic;
- exports io.ebeaninternal.api to io.ebean.ddl.generator, io.ebean.querybean, io.ebean.autotune, io.ebean.postgis, io.ebean.test, io.ebean.elastic, io.ebean.spring.txn, io.ebean.postgis.types;
+ exports io.ebeaninternal.api to io.ebean.ddl.generator, io.ebean.querybean, io.ebean.autotune, io.ebean.postgis, io.ebean.test, io.ebean.elastic, io.ebean.spring.txn, io.ebean.postgis.types, io.ebean.opentelemetry;
exports io.ebeaninternal.api.json to io.ebean.test;
exports io.ebeaninternal.server.autotune to io.ebean.autotune;
exports io.ebeaninternal.server.core to io.ebean.test, io.ebean.elastic;
@@ -70,7 +71,7 @@
exports io.ebeaninternal.server.rawsql to io.ebean.test;
exports io.ebeaninternal.server.json to io.ebean.test, io.ebean.elastic;
exports io.ebeaninternal.server.type to io.ebean.postgis, io.ebean.test, io.ebean.postgis.types, io.ebean.pgvector;
- exports io.ebeaninternal.server.transaction to io.ebean.test, io.ebean.elastic, io.ebean.spring.txn, io.ebean.k8scache;
+ exports io.ebeaninternal.server.transaction to io.ebean.test, io.ebean.elastic, io.ebean.spring.txn, io.ebean.k8scache, io.ebean.opentelemetry;
exports io.ebeaninternal.server.util to io.ebean.querybean;
provides io.ebean.service.BootstrapService with
diff --git a/ebean-opentelemetry/pom.xml b/ebean-opentelemetry/pom.xml
new file mode 100644
index 0000000000..8c300303af
--- /dev/null
+++ b/ebean-opentelemetry/pom.xml
@@ -0,0 +1,117 @@
+
+
+ 4.0.0
+
+ ebean-parent
+ io.ebean
+ 16.5.0
+
+
+ ebean-opentelemetry
+ ebean-opentelemetry
+ Ebean OpenTelemetry integration - transaction and query tracing via SpiProfileHandler
+
+
+ 1.51.0
+ false
+
+
+
+
+
+ io.avaje
+ avaje-jsr305-x
+ 1.1
+ provided
+
+
+
+ io.ebean
+ ebean-core
+ 16.5.0
+ provided
+
+
+
+ io.opentelemetry
+ opentelemetry-context
+ ${opentelemetry.version}
+ provided
+
+
+
+ io.opentelemetry
+ opentelemetry-api
+ ${opentelemetry.version}
+ provided
+
+
+
+
+ io.opentelemetry
+ opentelemetry-sdk
+ ${opentelemetry.version}
+ test
+
+
+
+ io.opentelemetry
+ opentelemetry-exporter-otlp
+ ${opentelemetry.version}
+ test
+
+
+
+ io.avaje
+ junit
+ 1.8
+ test
+
+
+
+ io.ebean
+ ebean-test
+ 16.5.0
+ test
+
+
+
+ io.ebean
+ ebean-querybean
+ 16.5.0
+ test
+
+
+
+ io.ebean
+ querybean-generator
+ 16.5.0
+ provided
+
+
+
+
+
+
+
+ io.ebean
+ ebean-maven-plugin
+ ${ebean-maven-plugin.version}
+
+
+ test
+ process-test-classes
+
+ debug=0
+
+
+ testEnhance
+
+
+
+
+
+
+
+
+
diff --git a/ebean-opentelemetry/src/main/java/io/ebean/opentelemetry/OtelProfileHandler.java b/ebean-opentelemetry/src/main/java/io/ebean/opentelemetry/OtelProfileHandler.java
new file mode 100644
index 0000000000..b616aed4db
--- /dev/null
+++ b/ebean-opentelemetry/src/main/java/io/ebean/opentelemetry/OtelProfileHandler.java
@@ -0,0 +1,89 @@
+package io.ebean.opentelemetry;
+
+import io.ebean.ProfileLocation;
+import io.ebean.plugin.Plugin;
+import io.ebean.plugin.SpiServer;
+import io.ebeaninternal.api.SpiProfileHandler;
+import io.ebeaninternal.server.transaction.ProfileStream;
+import io.ebeaninternal.server.transaction.TransactionProfile;
+import io.opentelemetry.api.GlobalOpenTelemetry;
+import io.opentelemetry.api.trace.Span;
+import io.opentelemetry.api.trace.SpanKind;
+import io.opentelemetry.api.trace.Tracer;
+import org.jspecify.annotations.Nullable;
+
+/**
+ * OpenTelemetry implementation of SpiProfileHandler.
+ *
+ * Creates a transaction span as a child of the currently active OpenTelemetry
+ * span. If no active span exists on the current thread, no profiling stream is
+ * created (returns null) to avoid generating noisy root-level spans.
+ *
+ * Register via ServiceLoader: add this class to
+ * {@code META-INF/services/io.ebeaninternal.api.SpiProfileHandler}.
+ */
+public final class OtelProfileHandler implements SpiProfileHandler, Plugin {
+
+ static final String INSTRUMENTATION_NAME = "io.ebean";
+
+ private Tracer tracer;
+
+ public OtelProfileHandler() {
+ // tracer resolved lazily in configure() once the OTel SDK is initialized
+ }
+
+ /** For testing: inject tracer directly rather than using GlobalOpenTelemetry. */
+ public OtelProfileHandler(Tracer tracer) {
+ this.tracer = tracer;
+ }
+
+ @Override
+ public void configure(SpiServer server) {
+ if (this.tracer == null) {
+ this.tracer = GlobalOpenTelemetry.getTracer(INSTRUMENTATION_NAME);
+ }
+ }
+
+ @Override
+ public void online(boolean online) {
+ // nothing to do
+ }
+
+ @Override
+ public void shutdown() {
+ // nothing to do — OTel SDK lifecycle is managed by the application
+ }
+
+ /**
+ * Create a ProfileStream for this transaction, or return null if there is no
+ * active OpenTelemetry span on the current thread.
+ *
+ * @param location the profile location for explicit {@code @Transactional} methods,
+ * or null for implicit read-only transactions
+ * @param label the transaction label
+ */
+ @Override
+ public @Nullable ProfileStream createProfileStream(@Nullable ProfileLocation location, @Nullable String label) {
+ if (!Span.current().getSpanContext().isValid()) {
+ // No active OTel trace context — don't create spans to avoid noise
+ return null;
+ }
+ String txnSpanName = location != null
+ ? "txn." + location.label()
+ : label != null ? "txn." + label : "ebean.txn";
+
+ Span txnSpan = tracer.spanBuilder(txnSpanName)
+ .setSpanKind(SpanKind.INTERNAL)
+ .setAttribute(OtelProfileStream.DB_SYSTEM, "ebean")
+ .startSpan();
+ return new OtelProfileStream(tracer, txnSpan);
+ }
+
+ /**
+ * The stream handles span lifecycle inline — nothing to do here.
+ */
+ @Override
+ public void collectTransactionProfile(TransactionProfile transactionProfile) {
+ // no-op: OtelProfileStream.end() already closed the span
+ }
+}
diff --git a/ebean-opentelemetry/src/main/java/io/ebean/opentelemetry/OtelProfileStream.java b/ebean-opentelemetry/src/main/java/io/ebean/opentelemetry/OtelProfileStream.java
new file mode 100644
index 0000000000..47de536b15
--- /dev/null
+++ b/ebean-opentelemetry/src/main/java/io/ebean/opentelemetry/OtelProfileStream.java
@@ -0,0 +1,132 @@
+package io.ebean.opentelemetry;
+
+import io.ebeaninternal.server.transaction.ProfileStream;
+import io.ebeaninternal.server.transaction.TransactionManager;
+import io.opentelemetry.api.common.AttributeKey;
+import io.opentelemetry.api.trace.Span;
+import io.opentelemetry.api.trace.SpanBuilder;
+import io.opentelemetry.api.trace.SpanKind;
+import io.opentelemetry.api.trace.StatusCode;
+import io.opentelemetry.api.trace.Tracer;
+import io.opentelemetry.context.Context;
+
+import java.time.Instant;
+import java.time.temporal.ChronoUnit;
+
+/**
+ * OpenTelemetry implementation of ProfileStream.
+ *
+ * Holds the transaction span and creates a child span per query/persist event
+ * using retrospective timestamps derived from the profiling offsets.
+ */
+final class OtelProfileStream implements ProfileStream {
+
+ static final AttributeKey DB_SYSTEM = AttributeKey.stringKey("db.system.name");
+ static final AttributeKey DB_OPERATION = AttributeKey.stringKey("db.operation.name");
+ static final AttributeKey DB_QUERY_TEXT = AttributeKey.stringKey("db.query.text");
+ static final AttributeKey DB_QUERY_TIME = AttributeKey.longKey("db.query.time");
+ static final AttributeKey EBEAN_BEAN_TYPE = AttributeKey.stringKey("ebean.bean_type");
+ static final AttributeKey EBEAN_ROW_COUNT = AttributeKey.longKey("ebean.row_count");
+ static final AttributeKey EBEAN_TOTAL_MICROS = AttributeKey.longKey("ebean.total_micros");
+
+ private final Tracer tracer;
+ private final Span txnSpan;
+ private final long startNanos;
+
+ OtelProfileStream(Tracer tracer, Span txnSpan) {
+ this.tracer = tracer;
+ this.txnSpan = txnSpan;
+ this.startNanos = System.nanoTime();
+ }
+
+ @Override
+ public long offset() {
+ return (System.nanoTime() - startNanos) / 1_000L;
+ }
+
+ @Override
+ public void addQueryEvent(String event, long offset, String beanName, int beanCount, String queryId, String sql) {
+ long exeMicros = offset() - offset;
+ var now = Instant.now();
+ String operation = operationName(event);
+ String name = queryId != null ? queryId : operation + " " + beanName;
+ Span child = childSpanBuilder(name, now.minus(exeMicros, ChronoUnit.MICROS))
+ .setAttribute(DB_OPERATION, operation)
+ .setAttribute(EBEAN_BEAN_TYPE, beanName)
+ .setAttribute(EBEAN_ROW_COUNT, (long) beanCount)
+ .setAttribute(DB_QUERY_TEXT, sql)
+ .setAttribute(DB_QUERY_TIME, exeMicros)
+ .startSpan();
+ child.end(now);
+ }
+
+ @Override
+ public void addPersistEvent(String event, long offset, String beanName, int beanCount) {
+ long exeMicros = offset() - offset;
+ var now = Instant.now();
+ String operation = operationName(event);
+ Span child = childSpanBuilder(operation + " " + beanName, now.minus(exeMicros, ChronoUnit.MICROS))
+ .setAttribute(DB_OPERATION, operation)
+ .setAttribute(EBEAN_BEAN_TYPE, beanName)
+ .setAttribute(EBEAN_ROW_COUNT, (long) beanCount)
+ .setAttribute(DB_QUERY_TIME, exeMicros)
+ .startSpan();
+ child.end(now);
+ }
+
+ @Override
+ public void addEvent(String event, long startOffset) {
+ if ("r".equals(event)) {
+ txnSpan.setStatus(StatusCode.ERROR, "rollback");
+ } else {
+ txnSpan.setStatus(StatusCode.OK);
+ }
+ }
+
+ @Override
+ public void end(TransactionManager manager, String label) {
+ txnSpan.setAttribute(EBEAN_TOTAL_MICROS, offset());
+ if (label != null) {
+ txnSpan.updateName("txn." + label);
+ }
+ txnSpan.end();
+ }
+
+ private SpanBuilder childSpanBuilder(String name, Instant start) {
+ return tracer.spanBuilder(name)
+ .setParent(Context.current().with(txnSpan))
+ .setSpanKind(SpanKind.INTERNAL)
+ .setAttribute(DB_SYSTEM, "ebean")
+ .setStartTimestamp(start);
+ }
+
+ /**
+ * Map ebean event codes (from TxnProfileEventCodes) to human-readable operation names.
+ */
+ static String operationName(String event) {
+ switch (event) {
+ case "fo": return "find_one";
+ case "fm": return "find_many";
+ case "fe": return "find_iterate";
+ case "fi": return "find_id_list";
+ case "ex": return "find_exists";
+ case "fa": return "find_attribute";
+ case "fas": return "find_attribute_set";
+ case "fc": return "find_count";
+ case "fs": return "find_subquery";
+ case "lm": return "lazy_load_many";
+ case "lo": return "lazy_load_one";
+ case "i": return "insert";
+ case "u": return "update";
+ case "d": return "delete";
+ case "ds": return "delete_soft";
+ case "dp": return "delete_permanent";
+ case "uo": return "orm_update";
+ case "uq": return "update_query";
+ case "dq": return "delete_query";
+ case "su": return "update_sql";
+ case "sc": return "callable_sql";
+ default: return event;
+ }
+ }
+}
diff --git a/ebean-opentelemetry/src/main/java/module-info.java b/ebean-opentelemetry/src/main/java/module-info.java
new file mode 100644
index 0000000000..ec7abc0e2e
--- /dev/null
+++ b/ebean-opentelemetry/src/main/java/module-info.java
@@ -0,0 +1,10 @@
+module io.ebean.opentelemetry {
+
+ requires io.ebean.core;
+ requires io.opentelemetry.api;
+ requires io.opentelemetry.context;
+ requires static io.avaje.jsr305x;
+
+ provides io.ebeaninternal.api.SpiProfileHandler with io.ebean.opentelemetry.OtelProfileHandler;
+
+}
diff --git a/ebean-opentelemetry/src/main/resources/META-INF/services/io.ebeaninternal.api.SpiProfileHandler b/ebean-opentelemetry/src/main/resources/META-INF/services/io.ebeaninternal.api.SpiProfileHandler
new file mode 100644
index 0000000000..8627cd48bd
--- /dev/null
+++ b/ebean-opentelemetry/src/main/resources/META-INF/services/io.ebeaninternal.api.SpiProfileHandler
@@ -0,0 +1 @@
+io.ebean.opentelemetry.OtelProfileHandler
diff --git a/ebean-opentelemetry/src/test/java/io/ebean/opentelemetry/OtelProfileHandlerTest.java b/ebean-opentelemetry/src/test/java/io/ebean/opentelemetry/OtelProfileHandlerTest.java
new file mode 100644
index 0000000000..3a0dba9cd7
--- /dev/null
+++ b/ebean-opentelemetry/src/test/java/io/ebean/opentelemetry/OtelProfileHandlerTest.java
@@ -0,0 +1,333 @@
+package io.ebean.opentelemetry;
+
+import io.opentelemetry.api.trace.Span;
+import io.opentelemetry.api.trace.StatusCode;
+import io.opentelemetry.api.trace.Tracer;
+import io.opentelemetry.context.Scope;
+import io.opentelemetry.sdk.trace.SdkTracerProvider;
+import io.opentelemetry.sdk.trace.data.SpanData;
+import io.opentelemetry.sdk.trace.export.SimpleSpanProcessor;
+import io.opentelemetry.sdk.trace.export.SpanExporter;
+import io.opentelemetry.sdk.common.CompletableResultCode;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * Tests for OtelProfileHandler and OtelProfileStream.
+ */
+class OtelProfileHandlerTest {
+
+ /** Simple in-memory span collector for assertions. */
+ static final class CapturingExporter implements SpanExporter {
+
+ final List spans = new ArrayList<>();
+
+ @Override
+ public CompletableResultCode export(Collection spans) {
+ this.spans.addAll(spans);
+ return CompletableResultCode.ofSuccess();
+ }
+
+ @Override
+ public CompletableResultCode flush() {
+ return CompletableResultCode.ofSuccess();
+ }
+
+ @Override
+ public CompletableResultCode shutdown() {
+ return CompletableResultCode.ofSuccess();
+ }
+
+ }
+
+ private CapturingExporter exporter;
+ private SdkTracerProvider tracerProvider;
+ private Tracer tracer;
+ private OtelProfileHandler handler;
+
+ @BeforeEach
+ void setup() {
+ exporter = new CapturingExporter();
+ tracerProvider = SdkTracerProvider.builder()
+ .addSpanProcessor(SimpleSpanProcessor.create(exporter))
+ .build();
+ tracer = tracerProvider.get("test");
+ handler = new OtelProfileHandler(tracer);
+ }
+
+ @AfterEach
+ void tearDown() {
+ tracerProvider.close();
+ }
+
+ // ----------------------------------------------------------
+ // createProfileStream
+ // ----------------------------------------------------------
+
+ @Test
+ void createProfileStream_noActiveContext_returnsNull() {
+ // No span active on thread — should not create a stream
+ assertNull(handler.createProfileStream(null, null));
+ assertNull(handler.createProfileStream(mockLocation("SomeService.doWork"), null));
+ }
+
+ @Test
+ void createProfileStream_withActiveContext_returnsStream() {
+ Span parent = tracer.spanBuilder("parent").startSpan();
+ try (Scope ignored = parent.makeCurrent()) {
+ var stream = handler.createProfileStream(null, null);
+ assertNotNull(stream);
+ stream.addEvent("c", 0); // commit
+ stream.end(null, null);
+ } finally {
+ parent.end();
+ }
+ }
+
+ @Test
+ void createProfileStream_withLocation_usesLabelAsSpanName() {
+ Span parent = tracer.spanBuilder("parent").startSpan();
+ try (Scope ignored = parent.makeCurrent()) {
+ var stream = handler.createProfileStream(mockLocation("OrderService.placeOrder"), null);
+ assertNotNull(stream);
+ stream.addEvent("c", 0);
+ stream.end(null, null);
+ } finally {
+ parent.end();
+ }
+ // parent + txn span
+ SpanData txnSpan = findSpan("txn.OrderService.placeOrder");
+ assertNotNull(txnSpan, "Expected span named 'OrderService.placeOrder'");
+ assertEquals("ebean", txnSpan.getAttributes().get(OtelProfileStream.DB_SYSTEM));
+ }
+
+ // ----------------------------------------------------------
+ // Transaction span name update for implicit transactions
+ // ----------------------------------------------------------
+
+ @Test
+ void implicitTransaction_nameUpdatedOnFirstQueryEvent() {
+ Span parent = tracer.spanBuilder("parent").startSpan();
+ try (Scope ignored = parent.makeCurrent()) {
+ OtelProfileStream stream = (OtelProfileStream) handler.createProfileStream(null, null);
+ assertNotNull(stream);
+ // First query event should update the transaction span name
+ stream.addQueryEvent("fm", stream.offset(), "Customer", 5, "qplan-1", "select ...");
+ stream.addEvent("c", 0);
+ stream.end(null, null);
+ } finally {
+ parent.end();
+ }
+ SpanData txnSpan = findSpan("ebean.txn");
+ assertNotNull(txnSpan, "Expected txn span renamed to 'find_many Customer'");
+ }
+
+ // ----------------------------------------------------------
+ // Query events → child spans
+ // ----------------------------------------------------------
+
+ @Test
+ void addQueryEvent_createsChildSpanWithAttributes() {
+ Span parent = tracer.spanBuilder("http.get /orders").startSpan();
+ try (Scope ignored = parent.makeCurrent()) {
+ OtelProfileStream stream = (OtelProfileStream) handler.createProfileStream(mockLocation("OrderService.findAll"), null);
+ assertNotNull(stream);
+ long offset = stream.offset();
+ // Simulate some time passing then record a find_many
+ stream.addQueryEvent("fm", offset, "Order", 42, "plan-abc", "select ...");
+ stream.addEvent("c", 0);
+ stream.end(null, null);
+ } finally {
+ parent.end();
+ }
+
+ SpanData querySpan = findSpan("plan-abc");
+ assertNotNull(querySpan, "Expected child span 'find_many Order'");
+ assertEquals("ebean", querySpan.getAttributes().get(OtelProfileStream.DB_SYSTEM));
+ assertEquals("find_many", querySpan.getAttributes().get(OtelProfileStream.DB_OPERATION));
+ assertEquals("Order", querySpan.getAttributes().get(OtelProfileStream.EBEAN_BEAN_TYPE));
+ assertEquals(42L, querySpan.getAttributes().get(OtelProfileStream.EBEAN_ROW_COUNT));
+ assertEquals("select ...", querySpan.getAttributes().get(OtelProfileStream.DB_QUERY_TEXT));
+ }
+
+ @Test
+ void addQueryEvent_multipleEvents_eachCreatesChildSpan() {
+ Span parent = tracer.spanBuilder("parent").startSpan();
+ try (Scope ignored = parent.makeCurrent()) {
+ OtelProfileStream stream = (OtelProfileStream) handler.createProfileStream(mockLocation("MyService.doAll"), null);
+ assertNotNull(stream);
+ stream.addQueryEvent("fo", stream.offset(), "User", 1, "p1", "select from user");
+ stream.addQueryEvent("fm", stream.offset(), "Order", 10, "p2", "select from order");
+ stream.addEvent("c", 0);
+ stream.end(null, null);
+ } finally {
+ parent.end();
+ }
+ SpanData findOne = findSpan("p1");
+ SpanData findMany = findSpan("p2");
+ assertNotNull(findOne);
+ assertNotNull(findMany);
+ assertEquals("select from user", findOne.getAttributes().get(OtelProfileStream.DB_QUERY_TEXT));
+ assertEquals("select from order", findMany.getAttributes().get(OtelProfileStream.DB_QUERY_TEXT));
+ }
+
+ @Test
+ void addQueryEvent_emptySql_setsEmptyQueryTextAttribute() {
+ Span parent = tracer.spanBuilder("parent").startSpan();
+ try (Scope ignored = parent.makeCurrent()) {
+ OtelProfileStream stream = (OtelProfileStream) handler.createProfileStream(mockLocation("MyService.emptySql"), null);
+ assertNotNull(stream);
+ stream.addQueryEvent("fo", stream.offset(), "User", 1, "p1", "");
+ stream.addEvent("c", 0);
+ stream.end(null, null);
+ } finally {
+ parent.end();
+ }
+ SpanData querySpan = findSpan("p1");
+ assertNotNull(querySpan);
+ assertEquals("", querySpan.getAttributes().get(OtelProfileStream.DB_QUERY_TEXT));
+ }
+
+ // ----------------------------------------------------------
+ // Persist events → child spans
+ // ----------------------------------------------------------
+
+ @Test
+ void addPersistEvent_createsChildSpanWithAttributes() {
+ Span parent = tracer.spanBuilder("parent").startSpan();
+ try (Scope ignored = parent.makeCurrent()) {
+ OtelProfileStream stream = (OtelProfileStream) handler.createProfileStream(mockLocation("CartService.checkout"), null);
+ assertNotNull(stream);
+ long offset = stream.offset();
+ stream.addPersistEvent("i", offset, "Order", 1);
+ stream.addEvent("c", 0);
+ stream.end(null, null);
+ } finally {
+ parent.end();
+ }
+ SpanData persistSpan = findSpan("insert Order");
+ assertNotNull(persistSpan, "Expected child span 'insert Order'");
+ assertEquals("insert", persistSpan.getAttributes().get(OtelProfileStream.DB_OPERATION));
+ assertEquals("Order", persistSpan.getAttributes().get(OtelProfileStream.EBEAN_BEAN_TYPE));
+ assertEquals(1L, persistSpan.getAttributes().get(OtelProfileStream.EBEAN_ROW_COUNT));
+ }
+
+ // ----------------------------------------------------------
+ // Commit / Rollback → span status
+ // ----------------------------------------------------------
+
+ @Test
+ void addEvent_commit_setsStatusOk() {
+ Span parent = tracer.spanBuilder("parent").startSpan();
+ try (Scope ignored = parent.makeCurrent()) {
+ OtelProfileStream stream = (OtelProfileStream) handler.createProfileStream(mockLocation("Svc.ok"), null);
+ assertNotNull(stream);
+ stream.addEvent("c", 0);
+ stream.end(null, null);
+ } finally {
+ parent.end();
+ }
+ SpanData txnSpan = findSpan("txn.Svc.ok");
+ assertNotNull(txnSpan);
+ assertEquals(StatusCode.OK, txnSpan.getStatus().getStatusCode());
+ }
+
+ @Test
+ void addEvent_rollback_setsStatusError() {
+ Span parent = tracer.spanBuilder("parent").startSpan();
+ try (Scope ignored = parent.makeCurrent()) {
+ OtelProfileStream stream = (OtelProfileStream) handler.createProfileStream(mockLocation("Svc.fail"), null);
+ assertNotNull(stream);
+ stream.addEvent("r", 0);
+ stream.end(null, null);
+ } finally {
+ parent.end();
+ }
+ SpanData txnSpan = findSpan("txn.Svc.fail");
+ assertNotNull(txnSpan);
+ assertEquals(StatusCode.ERROR, txnSpan.getStatus().getStatusCode());
+ }
+
+ // ----------------------------------------------------------
+ // end() — total_micros attribute
+ // ----------------------------------------------------------
+
+ @Test
+ void end_setsTotalMicrosAttribute() {
+ Span parent = tracer.spanBuilder("parent").startSpan();
+ try (Scope ignored = parent.makeCurrent()) {
+ OtelProfileStream stream = (OtelProfileStream) handler.createProfileStream(mockLocation("Svc.timed"), null);
+ assertNotNull(stream);
+ stream.addEvent("c", 0);
+ stream.end(null, null);
+ } finally {
+ parent.end();
+ }
+ SpanData txnSpan = findSpan("txn.Svc.timed");
+ assertNotNull(txnSpan);
+ Long totalMicros = txnSpan.getAttributes().get(OtelProfileStream.EBEAN_TOTAL_MICROS);
+ assertNotNull(totalMicros);
+ assertTrue(totalMicros >= 0, "total_micros should be non-negative");
+ }
+
+ // ----------------------------------------------------------
+ // operationName mapping
+ // ----------------------------------------------------------
+
+ @Test
+ void operationName_allEventCodes() {
+ assertEquals("find_one", OtelProfileStream.operationName("fo"));
+ assertEquals("find_many", OtelProfileStream.operationName("fm"));
+ assertEquals("find_iterate", OtelProfileStream.operationName("fe"));
+ assertEquals("find_id_list", OtelProfileStream.operationName("fi"));
+ assertEquals("find_exists", OtelProfileStream.operationName("ex"));
+ assertEquals("find_attribute", OtelProfileStream.operationName("fa"));
+ assertEquals("find_attribute_set", OtelProfileStream.operationName("fas"));
+ assertEquals("find_count", OtelProfileStream.operationName("fc"));
+ assertEquals("find_subquery", OtelProfileStream.operationName("fs"));
+ assertEquals("lazy_load_many", OtelProfileStream.operationName("lm"));
+ assertEquals("lazy_load_one", OtelProfileStream.operationName("lo"));
+ assertEquals("insert", OtelProfileStream.operationName("i"));
+ assertEquals("update", OtelProfileStream.operationName("u"));
+ assertEquals("delete", OtelProfileStream.operationName("d"));
+ assertEquals("delete_soft", OtelProfileStream.operationName("ds"));
+ assertEquals("delete_permanent", OtelProfileStream.operationName("dp"));
+ assertEquals("orm_update", OtelProfileStream.operationName("uo"));
+ assertEquals("update_query", OtelProfileStream.operationName("uq"));
+ assertEquals("delete_query", OtelProfileStream.operationName("dq"));
+ assertEquals("update_sql", OtelProfileStream.operationName("su"));
+ assertEquals("callable_sql", OtelProfileStream.operationName("sc"));
+ // Unknown code returned as-is
+ assertEquals("xyz", OtelProfileStream.operationName("xyz"));
+ }
+
+ // ----------------------------------------------------------
+ // Helpers
+ // ----------------------------------------------------------
+
+ private SpanData findSpan(String name) {
+ return exporter.spans.stream()
+ .filter(s -> name.equals(s.getName()))
+ .findFirst()
+ .orElse(null);
+ }
+
+ private io.ebean.ProfileLocation mockLocation(String label) {
+ return new io.ebean.ProfileLocation() {
+ @Override public boolean obtain() { return false; }
+ @Override public String location() { return label; }
+ @Override public String label() { return label; }
+ @Override public String fullLocation() { return label; }
+ @Override public void add(long executionTime) {}
+ @Override public boolean trace() { return true; }
+ @Override public void setTraceCount(int traceCount) {}
+ };
+ }
+}
diff --git a/ebean-opentelemetry/src/test/java/org/example/domain/OtelJaegerIntegrationTest.java b/ebean-opentelemetry/src/test/java/org/example/domain/OtelJaegerIntegrationTest.java
new file mode 100644
index 0000000000..83d665602a
--- /dev/null
+++ b/ebean-opentelemetry/src/test/java/org/example/domain/OtelJaegerIntegrationTest.java
@@ -0,0 +1,149 @@
+package org.example.domain;
+
+import io.ebean.Database;
+import io.ebean.Transaction;
+import io.ebean.config.ProfilingConfig;
+import io.ebean.datasource.DataSourceBuilder;
+import io.ebean.datasource.DataSourcePool;
+import io.ebean.opentelemetry.OtelProfileHandler;
+import io.ebeaninternal.api.SpiProfileHandler;
+import io.opentelemetry.api.common.AttributeKey;
+import io.opentelemetry.api.common.Attributes;
+import io.opentelemetry.api.trace.Span;
+import io.opentelemetry.api.trace.Tracer;
+import io.opentelemetry.context.Scope;
+import io.opentelemetry.exporter.otlp.trace.OtlpGrpcSpanExporter;
+import io.opentelemetry.sdk.resources.Resource;
+import io.opentelemetry.sdk.trace.SdkTracerProvider;
+import io.opentelemetry.sdk.trace.export.SimpleSpanProcessor;
+import org.example.domain.query.QOtelOrder;
+import org.junit.jupiter.api.Disabled;
+import org.junit.jupiter.api.Test;
+
+import java.time.Duration;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ *
+ * docker run --rm --name jaeger \
+ * -e COLLECTOR_OTLP_ENABLED=true \
+ * -p 4317:4317 \
+ * -p 16686:16686 \
+ * jaegertracing/all-in-one:1.62.0
+ *
+ * Jaeger UI will be at http://localhost:16686, and OTLP gRPC ingest at http://localhost:4317.
+ *
+ */
+class OtelJaegerIntegrationTest {
+
+ private static final String SERVICE_NAME = "ebean-otel-it";
+
+ @Disabled
+ @Test
+ void otlpExport_visibleInJaeger_fromRealEbeanQueryFlow() {
+
+ Resource resource = Resource.getDefault().merge(Resource.create(
+ Attributes.of(AttributeKey.stringKey("service.name"), SERVICE_NAME)));
+
+ OtlpGrpcSpanExporter exporter = OtlpGrpcSpanExporter.builder()
+ .setEndpoint("http://localhost:4317")
+ .setTimeout(Duration.ofSeconds(5))
+ .build();
+
+ SdkTracerProvider tracerProvider = SdkTracerProvider.builder()
+ .setResource(resource)
+ .addSpanProcessor(SimpleSpanProcessor.create(exporter))
+ .build();
+
+ try {
+ Tracer tracer = tracerProvider.get("it");
+ Database db = createDatabase(tracer);
+ try {
+
+ Span parent = tracer.spanBuilder("parent").startSpan();
+ try (Scope ignored = parent.makeCurrent()) {
+ doStuff(db);
+ doStuff(db);
+ doStuff(db);
+ doStuff(db);
+
+ } finally {
+ parent.end();
+ }
+ } finally {
+ db.shutdown();
+ }
+
+ tracerProvider.forceFlush().join(5, TimeUnit.SECONDS);
+
+ } finally {
+ tracerProvider.close();
+ }
+ }
+
+ private void doStuff(Database db) {
+ var first = exercise(db);
+
+ var found = new QOtelOrder()
+ .id.eq(first.getId())
+ .findOne();
+
+ assertThat(found).isNotNull();
+ }
+
+ // @Transactional
+ private OtelOrder exercise(Database db) {
+ var first = insertSome(db);
+
+ List rows = db.find(OtelOrder.class)
+ .setLabel("findNewOrders")
+ .where()
+ .eq("status", "NEW")
+ .findList();
+
+ assertThat(rows).isNotEmpty();
+ return first;
+ }
+
+ private OtelOrder insertSome(Database db) {
+ try (Transaction transaction = db.beginTransaction()) {
+ transaction.setLabel("saveNewOrders");
+ var first = new OtelOrder("NEW");
+ db.save(first);
+ db.save(new OtelOrder("NEW"));
+ db.save(new OtelOrder("NEW"));
+ db.save(new OtelOrder("NEW"));
+ db.save(new OtelOrder("NEW"));
+ transaction.commit();
+ return first;
+ }
+ }
+
+ private Database createDatabase(Tracer tracer) {
+ ProfilingConfig profilingConfig = new ProfilingConfig();
+ profilingConfig.setEnabled(true);
+
+ DataSourcePool ds = DataSourceBuilder.create()
+ .url("jdbc:h2:mem:otelit;DB_CLOSE_DELAY=-1")
+ .username("sa")
+ .password("")
+ .driver("org.h2.Driver")
+ .build();
+
+ return Database.builder()
+ .name("otel_h2")
+ .defaultDatabase(true)
+ .register(true)
+ .loadFromProperties()
+ .ddlGenerate(true)
+ .ddlRun(true)
+ .profilingConfig(profilingConfig)
+ .putServiceObject(SpiProfileHandler.class, new OtelProfileHandler(tracer))
+ .addClass(OtelOrder.class)
+ .dataSource(ds)
+ .build();
+ }
+}
diff --git a/ebean-opentelemetry/src/test/java/org/example/domain/OtelOrder.java b/ebean-opentelemetry/src/test/java/org/example/domain/OtelOrder.java
new file mode 100644
index 0000000000..b5e199f221
--- /dev/null
+++ b/ebean-opentelemetry/src/test/java/org/example/domain/OtelOrder.java
@@ -0,0 +1,35 @@
+package org.example.domain;
+
+import jakarta.persistence.Entity;
+import jakarta.persistence.Id;
+import jakarta.persistence.Table;
+
+@Entity
+@Table(name = "otel_order")
+public class OtelOrder {
+
+ @Id
+ private Long id;
+
+ private String status;
+
+ public OtelOrder(String status) {
+ this.status = status;
+ }
+
+ public Long getId() {
+ return id;
+ }
+
+ public void setId(Long id) {
+ this.id = id;
+ }
+
+ public String getStatus() {
+ return status;
+ }
+
+ public void setStatus(String status) {
+ this.status = status;
+ }
+}
diff --git a/pom.xml b/pom.xml
index c885127da1..4a89ca428c 100644
--- a/pom.xml
+++ b/pom.xml
@@ -97,6 +97,7 @@
composites
ebean-jackson-mapper
ebean-spring-txn
+ ebean-opentelemetry
ebean-core-json