From 0023f0ce131158aec69218a34f7ddb690bcaf973 Mon Sep 17 00:00:00 2001 From: Rob Bygrave Date: Mon, 8 Jun 2026 10:42:12 +1200 Subject: [PATCH 1/3] [open telemetry] Add query lable as an attribute to the query spans (#3778) Co-authored-by: robin.bygrave --- .../java/io/ebean/opentelemetry/OtelProfileStream.java | 4 ++++ .../io/ebean/opentelemetry/OtelProfileHandlerTest.java | 8 +++++--- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/ebean-opentelemetry/src/main/java/io/ebean/opentelemetry/OtelProfileStream.java b/ebean-opentelemetry/src/main/java/io/ebean/opentelemetry/OtelProfileStream.java index a6f7afac3..564d8d679 100644 --- a/ebean-opentelemetry/src/main/java/io/ebean/opentelemetry/OtelProfileStream.java +++ b/ebean-opentelemetry/src/main/java/io/ebean/opentelemetry/OtelProfileStream.java @@ -26,6 +26,7 @@ final class OtelProfileStream implements ProfileStream { 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_QUERY_LABEL = AttributeKey.stringKey("ebean.query_label"); static final AttributeKey EBEAN_QUERY_HASH = AttributeKey.stringKey("ebean.query_hash"); static final AttributeKey EBEAN_ROW_COUNT = AttributeKey.longKey("ebean.row_count"); static final AttributeKey EBEAN_TOTAL_MICROS = AttributeKey.longKey("ebean.total_micros"); @@ -60,6 +61,9 @@ final class OtelProfileStream implements ProfileStream { if (hash != null) { builder.setAttribute(EBEAN_QUERY_HASH, hash); } + if (queryId != null) { + builder.setAttribute(EBEAN_QUERY_LABEL, queryId); + } Span child = builder.startSpan(); child.end(now); } diff --git a/ebean-opentelemetry/src/test/java/io/ebean/opentelemetry/OtelProfileHandlerTest.java b/ebean-opentelemetry/src/test/java/io/ebean/opentelemetry/OtelProfileHandlerTest.java index b79e1bab8..6131e5846 100644 --- a/ebean-opentelemetry/src/test/java/io/ebean/opentelemetry/OtelProfileHandlerTest.java +++ b/ebean-opentelemetry/src/test/java/io/ebean/opentelemetry/OtelProfileHandlerTest.java @@ -156,6 +156,7 @@ class OtelProfileHandlerTest { assertEquals(42L, querySpan.getAttributes().get(OtelProfileStream.EBEAN_ROW_COUNT)); assertEquals("select ...", querySpan.getAttributes().get(OtelProfileStream.DB_QUERY_TEXT)); assertEquals("abc123hash", querySpan.getAttributes().get(OtelProfileStream.EBEAN_QUERY_HASH)); + assertEquals("plan-abc", querySpan.getAttributes().get(OtelProfileStream.EBEAN_QUERY_LABEL)); } @Test @@ -185,17 +186,18 @@ class OtelProfileHandlerTest { try (Scope ignored = parent.makeCurrent()) { OtelProfileStream stream = (OtelProfileStream) handler.createProfileStream(mockLocation("MyService.emptySql"), null); assertNotNull(stream); - stream.addQueryEvent("fo", stream.offset(), "User", 1, "p1", null, ""); + stream.addQueryEvent("fo", stream.offset(), "User", 1, null, null, ""); stream.addEvent("c", 0); stream.end(null); } finally { parent.end(); } - SpanData querySpan = findSpan("p1"); + SpanData querySpan = findSpan("find_one User"); assertNotNull(querySpan); assertEquals("", querySpan.getAttributes().get(OtelProfileStream.DB_QUERY_TEXT)); - // null hash should not set the attribute + // null hash / label should not set the attributes assertNull(querySpan.getAttributes().get(OtelProfileStream.EBEAN_QUERY_HASH)); + assertNull(querySpan.getAttributes().get(OtelProfileStream.EBEAN_QUERY_LABEL)); } // ---------------------------------------------------------- From 140869691228f4d995c3ec7e4efec6eb1cc2786e Mon Sep 17 00:00:00 2001 From: Rob Bygrave <130515035+rob-bygrave@users.noreply.github.com> Date: Mon, 8 Jun 2026 19:03:43 +1200 Subject: [PATCH 2/3] =?UTF-8?q?Redesign=20of=20metric=20labels=20-=20secon?= =?UTF-8?q?dary=20queries=20(lazy|query)=20now=20just=20u=E2=80=A6=20(#377?= =?UTF-8?q?9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Redesign of metric labels - secondary queries (lazy|query) now just use parent + relativePath + type # Query metric/plan label change — comparison Improves the metric/plan name generated for **secondary** (`_lazy` / `_query`) loads so they relate to their parent/root query, and unifies separators on `.`. The secondary lazy name is now **always** `orm...`, so it always prefixes the real parent metric. ## Root query name | Root query source | Original | New | |---|---|---| | `setLabel("custMain")` | `orm.Customer_custMain` | `orm.Customer.custMain` | | ProfileLocation `CustomerFinder.byName` | `orm.CustomerFinder.byName` | `orm.CustomerFinder.byName` *(same)* | | ProfileLocation `DataLoader.loadAll` (Customer query) | `orm.Customer_DataLoader.loadAll` | `orm.Customer.DataLoader.loadAll` | | Unlabeled, no location | `orm.Customer.findList` | `orm.Customer.findList` *(same)* | ## Secondary lazy (`contacts`) name Original prefixes the **loaded** type (`Contact`) + the call-site location and uses `__` between path and load mode. New prefixes the **parent's full name**. | Root query source | Original lazy name | New lazy name | |---|---|---| | `setLabel("custMain")` *(profile location also present)* | `orm.Contact_CustomerFinder.findActive_contacts__lazy` — explicit label **lost** | `orm.Customer.custMain.contacts.lazy` | | `setLabel("custMain")` *(no profile location)* | `orm.Contact_custMain_contacts__lazy` | `orm.Customer.custMain.contacts.lazy` | | ProfileLocation `CustomerFinder.byName` | `orm.Contact_CustomerFinder.byName_contacts__lazy` | `orm.CustomerFinder.byName.contacts.lazy` | | ProfileLocation `DataLoader.loadAll` | `orm.Contact_DataLoader.loadAll_contacts__lazy` | `orm.Customer.DataLoader.loadAll.contacts.lazy` | | Unlabeled, no location | `orm.Contact.findList` *(own name; no parent path)* | `orm.Contact.findList` *(same)* | ## Problems fixed - **(A) Secondary load didn't relate to its parent** — original prefixed the *loaded* type (`Contact`) + the *call-site* location, never the parent query's name. New name literally starts with the parent's full name. - Explicit `setLabel` was **silently dropped** for secondary queries when a profile location existed. - `__` path/loadMode separator and mixed `_`/`.` replaced by uniform `.`. ## Nested and `.query` secondary loads Each secondary query name is `..`, so every hop literally prefixes its parent metric. Example with root `setLabel("custMain")` on `Customer`, chain `Customer -> orders -> details`: Nested lazy: ``` orm.Customer.custMain orm.Customer.custMain.orders.lazy orm.Customer.custMain.orders.lazy.details.lazy ``` Secondary eager `.query` fetch: ``` orm.Customer.custMain orm.Customer.custMain.orders.query orm.Customer.custMain.orders.query.details.query ``` The intermediate load mode (`.lazy.` / `.query.`) is retained so each name is an exact extension of its immediate parent's name. * Use ProfileLocation as leading metric name without prefix * docs: Add guide for ebean-query-metrics.md * docs: Add guide for query plan capture --- docs/guides/AGENTS.md | 2 + docs/guides/README.md | 4 + docs/guides/ebean-query-metrics.md | 258 ++++++++++++++++++ docs/guides/ebean-query-plan-capture.md | 206 ++++++++++++++ .../java/io/ebeaninternal/api/SpiQuery.java | 13 +- .../server/loadcontext/DLoadBaseContext.java | 17 +- .../server/loadcontext/DLoadContext.java | 44 ++- .../server/query/CQueryPlan.java | 53 +++- .../server/querydefn/DefaultOrmQuery.java | 6 +- .../server/query/CQueryPlanNameTest.java | 71 +++++ .../test/java/org/querytest/QOrderTest.java | 4 +- 11 files changed, 655 insertions(+), 23 deletions(-) create mode 100644 docs/guides/ebean-query-metrics.md create mode 100644 docs/guides/ebean-query-plan-capture.md create mode 100644 ebean-core/src/test/java/io/ebeaninternal/server/query/CQueryPlanNameTest.java diff --git a/docs/guides/AGENTS.md b/docs/guides/AGENTS.md index d926c678e..0fec50b33 100644 --- a/docs/guides/AGENTS.md +++ b/docs/guides/AGENTS.md @@ -14,6 +14,8 @@ Key guides (fetch and follow when performing the relevant task): - Migrate to `Database.builder()`: https://raw.githubusercontent.com/ebean-orm/ebean/HEAD/docs/guides/migrating-to-database-builder.md - Write queries with query beans: https://raw.githubusercontent.com/ebean-orm/ebean/HEAD/docs/guides/writing-ebean-query-beans.md - Persisting and transactions: https://raw.githubusercontent.com/ebean-orm/ebean/HEAD/docs/guides/persisting-and-transactions-with-ebean.md +- Query metrics and naming: https://raw.githubusercontent.com/ebean-orm/ebean/HEAD/docs/guides/ebean-query-metrics.md +- Query plan capture: https://raw.githubusercontent.com/ebean-orm/ebean/HEAD/docs/guides/ebean-query-plan-capture.md - Test container setup: https://raw.githubusercontent.com/ebean-orm/ebean/HEAD/docs/guides/add-ebean-postgres-test-container.md - DB migration generation: https://raw.githubusercontent.com/ebean-orm/ebean/HEAD/docs/guides/add-ebean-db-migration-generation.md - Lombok with entity beans: https://raw.githubusercontent.com/ebean-orm/ebean/HEAD/docs/guides/lombok-with-ebean-entity-beans.md diff --git a/docs/guides/README.md b/docs/guides/README.md index cf860d413..b85a87b7b 100644 --- a/docs/guides/README.md +++ b/docs/guides/README.md @@ -29,6 +29,8 @@ existing Maven project. Complete the steps in order. | Guide | Description | |-------|-------------| | [Ebean OpenTelemetry tracing](add-ebean-opentelemetry.md) | Add `ebean-opentelemetry`, register `GlobalOpenTelemetry` once before Ebean databases are built, and troubleshoot missing spans or double-registration errors | +| [Ebean query metrics and naming](ebean-query-metrics.md) | How Ebean query metric names are derived from `setLabel(..)` and profile locations; secondary (lazy/query) load naming; inline SQL comments; collecting metrics at runtime; mapping to avaje-metrics tags | +| [Ebean query plan capture](ebean-query-plan-capture.md) | Enable and configure database query plan (`EXPLAIN`) capture for slow queries; bind capture vs plan capture; periodic and on-demand collection; thresholds, load limits, EXPLAIN dialect, and listeners | ## Entity beans @@ -149,6 +151,8 @@ Key guides (fetch and follow these when performing the relevant task): - Write queries with query beans: https://raw.githubusercontent.com/ebean-orm/ebean/HEAD/docs/guides/writing-ebean-query-beans.md - Immutable bean cache for read-only references: https://raw.githubusercontent.com/ebean-orm/ebean/HEAD/docs/guides/immutable-bean-cache.md - Ebean OpenTelemetry tracing: https://raw.githubusercontent.com/ebean-orm/ebean/HEAD/docs/guides/add-ebean-opentelemetry.md +- Query metrics and naming: https://raw.githubusercontent.com/ebean-orm/ebean/HEAD/docs/guides/ebean-query-metrics.md +- Query plan capture: https://raw.githubusercontent.com/ebean-orm/ebean/HEAD/docs/guides/ebean-query-plan-capture.md - Persisting and transactions: https://raw.githubusercontent.com/ebean-orm/ebean/HEAD/docs/guides/persisting-and-transactions-with-ebean.md - Test container setup: https://raw.githubusercontent.com/ebean-orm/ebean/HEAD/docs/guides/add-ebean-postgres-test-container.md - DB migration generation: https://raw.githubusercontent.com/ebean-orm/ebean/HEAD/docs/guides/add-ebean-db-migration-generation.md diff --git a/docs/guides/ebean-query-metrics.md b/docs/guides/ebean-query-metrics.md new file mode 100644 index 000000000..c1fa4710e --- /dev/null +++ b/docs/guides/ebean-query-metrics.md @@ -0,0 +1,258 @@ +# Guide: Ebean query metrics and naming + +## Purpose + +This guide explains the metrics Ebean captures, how the metric **name** for a query +is derived, and how you influence that name with `setLabel(..)` and **profile +locations**. It also covers secondary (lazy / query) load naming, the inline SQL +comment, collecting metrics at runtime, and how the names map to avaje-metrics tags. + +Use this guide when you want to identify a query in metrics/telemetry, when a query +shows up under an unexpected metric name, or when wiring Ebean metrics into a reporter. + +--- + +## Overview + +Ebean records timing and counter metrics for the work it does. Every metric has a +**name** whose leading segment identifies the kind of work: + +| Prefix | What it measures | Example name | +|---|---|---| +| `orm.` | Entity (ORM) query | `orm.Customer.findList`, `orm.CustomerFinder.byName` | +| `dto.` | DTO query | `dto.CustomerDto_byEmail` | +| `sql.query.` | Raw SQL query | `sql.query.