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..036d26f950 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) {
+ 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..1559621646 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) {
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..3e4632b204 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);
+ }
}
/**
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..01b303df0d 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) {
// 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..992ceef196 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.
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..c6d37f9677 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);
+ 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);
+ 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);
+ 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..993d863240
--- /dev/null
+++ b/ebean-opentelemetry/pom.xml
@@ -0,0 +1,66 @@
+
+
+ 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.ebean
+ ebean-test
+ 16.5.0
+ test
+
+
+
+
+
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..df890d2f00
--- /dev/null
+++ b/ebean-opentelemetry/src/main/java/io/ebean/opentelemetry/OtelProfileHandler.java
@@ -0,0 +1,94 @@
+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. */
+ 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
+ */
+ @Override
+ public @Nullable ProfileStream createProfileStream(@Nullable ProfileLocation location) {
+ if (!Span.current().getSpanContext().isValid()) {
+ // No active OTel trace context — don't create spans to avoid noise
+ return null;
+ }
+ String spanName;
+ boolean updateName;
+ if (location != null) {
+ spanName = location.label();
+ updateName = false;
+ } else {
+ // Implicit read-only transaction: name will be refined on the first query event
+ spanName = "ebean.transaction";
+ updateName = true;
+ }
+ Span txnSpan = tracer.spanBuilder(spanName)
+ .setSpanKind(SpanKind.INTERNAL)
+ .setAttribute(OtelProfileStream.DB_SYSTEM, "ebean")
+ .startSpan();
+ return new OtelProfileStream(tracer, txnSpan, updateName);
+ }
+
+ /**
+ * 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..fff9a23ca8
--- /dev/null
+++ b/ebean-opentelemetry/src/main/java/io/ebean/opentelemetry/OtelProfileStream.java
@@ -0,0 +1,140 @@
+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.util.concurrent.TimeUnit;
+
+/**
+ * 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 EBEAN_BEAN_TYPE = AttributeKey.stringKey("ebean.bean_type");
+ static final AttributeKey EBEAN_ROW_COUNT = AttributeKey.longKey("ebean.row_count");
+ static final AttributeKey EBEAN_QUERY_ID = AttributeKey.stringKey("ebean.query_id");
+ static final AttributeKey EBEAN_TOTAL_MICROS = AttributeKey.longKey("ebean.total_micros");
+
+ private final Tracer tracer;
+ private final Span txnSpan;
+ private final long startNanos;
+ private final long startEpochNanos;
+ /** True when the transaction span name is still the generic fallback and can be updated. */
+ private boolean updateName;
+
+ OtelProfileStream(Tracer tracer, Span txnSpan, boolean updateName) {
+ this.tracer = tracer;
+ this.txnSpan = txnSpan;
+ this.startNanos = System.nanoTime();
+ Instant now = Instant.now();
+ this.startEpochNanos = now.getEpochSecond() * 1_000_000_000L + now.getNano();
+ this.updateName = updateName;
+ }
+
+ @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;
+ String operation = operationName(event);
+ maybeUpdateTxnName(operation, beanName);
+ Span child = childSpanBuilder(operation + " " + beanName, offset)
+ .setAttribute(DB_OPERATION, operation)
+ .setAttribute(EBEAN_BEAN_TYPE, beanName)
+ .setAttribute(EBEAN_ROW_COUNT, (long) beanCount)
+ .setAttribute(EBEAN_QUERY_ID, queryId)
+ .setAttribute(DB_QUERY_TEXT, sql)
+ .startSpan();
+ child.end(startEpochNanos + TimeUnit.MICROSECONDS.toNanos(offset + exeMicros), TimeUnit.NANOSECONDS);
+ }
+
+ @Override
+ public void addPersistEvent(String event, long offset, String beanName, int beanCount) {
+ long exeMicros = offset() - offset;
+ String operation = operationName(event);
+ maybeUpdateTxnName(operation, beanName);
+ Span child = childSpanBuilder(operation + " " + beanName, offset)
+ .setAttribute(DB_OPERATION, operation)
+ .setAttribute(EBEAN_BEAN_TYPE, beanName)
+ .setAttribute(EBEAN_ROW_COUNT, (long) beanCount)
+ .startSpan();
+ child.end(startEpochNanos + TimeUnit.MICROSECONDS.toNanos(offset + exeMicros), TimeUnit.NANOSECONDS);
+ }
+
+ @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) {
+ txnSpan.setAttribute(EBEAN_TOTAL_MICROS, offset());
+ txnSpan.end();
+ }
+
+ private SpanBuilder childSpanBuilder(String name, long offsetMicros) {
+ return tracer.spanBuilder(name)
+ .setParent(Context.current().with(txnSpan))
+ .setSpanKind(SpanKind.INTERNAL)
+ .setAttribute(DB_SYSTEM, "ebean")
+ .setStartTimestamp(startEpochNanos + TimeUnit.MICROSECONDS.toNanos(offsetMicros), TimeUnit.NANOSECONDS);
+ }
+
+ private void maybeUpdateTxnName(String operation, String beanName) {
+ if (updateName) {
+ updateName = false;
+ txnSpan.updateName(operation + " " + beanName);
+ }
+ }
+
+ /**
+ * 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..ca68fc6f4a
--- /dev/null
+++ b/ebean-opentelemetry/src/test/java/io/ebean/opentelemetry/OtelProfileHandlerTest.java
@@ -0,0 +1,312 @@
+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));
+ assertNull(handler.createProfileStream(mockLocation("SomeService.doWork")));
+ }
+
+ @Test
+ void createProfileStream_withActiveContext_returnsStream() {
+ Span parent = tracer.spanBuilder("parent").startSpan();
+ try (Scope ignored = parent.makeCurrent()) {
+ var stream = handler.createProfileStream(null);
+ assertNotNull(stream);
+ stream.addEvent("c", 0); // commit
+ stream.end(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"));
+ assertNotNull(stream);
+ stream.addEvent("c", 0);
+ stream.end(null);
+ } finally {
+ parent.end();
+ }
+ // parent + txn span
+ SpanData txnSpan = findSpan("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);
+ 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);
+ } finally {
+ parent.end();
+ }
+ SpanData txnSpan = findSpan("find_many Customer");
+ 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"));
+ 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);
+ } finally {
+ parent.end();
+ }
+
+ SpanData querySpan = findSpan("find_many Order");
+ 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("plan-abc", querySpan.getAttributes().get(OtelProfileStream.EBEAN_QUERY_ID));
+ }
+
+ @Test
+ void addQueryEvent_multipleEvents_eachCreatesChildSpan() {
+ Span parent = tracer.spanBuilder("parent").startSpan();
+ try (Scope ignored = parent.makeCurrent()) {
+ OtelProfileStream stream = (OtelProfileStream) handler.createProfileStream(mockLocation("MyService.doAll"));
+ 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);
+ } finally {
+ parent.end();
+ }
+ assertNotNull(findSpan("find_one User"));
+ assertNotNull(findSpan("find_many Order"));
+ }
+
+ // ----------------------------------------------------------
+ // 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"));
+ assertNotNull(stream);
+ long offset = stream.offset();
+ stream.addPersistEvent("i", offset, "Order", 1);
+ stream.addEvent("c", 0);
+ stream.end(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"));
+ assertNotNull(stream);
+ stream.addEvent("c", 0);
+ stream.end(null);
+ } finally {
+ parent.end();
+ }
+ SpanData txnSpan = findSpan("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"));
+ assertNotNull(stream);
+ stream.addEvent("r", 0);
+ stream.end(null);
+ } finally {
+ parent.end();
+ }
+ SpanData txnSpan = findSpan("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"));
+ assertNotNull(stream);
+ stream.addEvent("c", 0);
+ stream.end(null);
+ } finally {
+ parent.end();
+ }
+ SpanData txnSpan = findSpan("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/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