From a1cede76d189533b6deb0ec8d8659a4ed1f9bba6 Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Mon, 13 Apr 2026 18:39:11 -0300 Subject: [PATCH 1/4] feat(opentelemetry): introduce comprehensive OpenTelemetry module and instrumentations This commit introduces the foundational `OtelModule` and a suite of native instrumentations to seamlessly integrate OpenTelemetry tracing, metrics, and logging into Jooby applications. Core features: - Add `OtelModule` to bootstrap the OpenTelemetry SDK. - Add `OtelHttpTracing` filter for automated HTTP route tracing with W3C propagation. - Add `Trace` utility with fluent API for safe, manual service-layer instrumentation. - Add `OtelServerMetrics` to export native operational metrics for Netty, Jetty, and Undertow. Third-party extensions: - Add `OtelHikari` for database connection pool metrics. - Add `OtelLogback` and `OtelLog4j2` for automatic trace correlation in application logs. - Add `OtelQuartz` and `OtelDbScheduler` for background job observability. --- jooby/src/main/java/io/jooby/Jooby.java | 4 +- .../java/io/jooby/internal/RouterImpl.java | 2 +- modules/jooby-bom/pom.xml | 5 + modules/jooby-db-scheduler/pom.xml | 2 +- .../jooby/dbscheduler/DbSchedulerModule.java | 17 +- .../java/io/jooby/hikari/HikariModule.java | 228 +++++++++----- .../main/java/io/jooby/jetty/JettyServer.java | 14 +- .../netty/NettyEventLoopGroupImpl.java | 2 +- .../main/java/io/jooby/netty/NettyServer.java | 11 +- modules/jooby-opentelemetry/pom.xml | 176 +++++++++++ .../io/jooby/opentelemetry/OtelExtension.java | 36 +++ .../jooby/opentelemetry/OtelHttpTracing.java | 127 ++++++++ .../io/jooby/opentelemetry/OtelModule.java | 231 ++++++++++++++ .../java/io/jooby/opentelemetry/Trace.java | 128 ++++++++ .../instrumentation/OtelDbScheduler.java | 154 +++++++++ .../instrumentation/OtelHikari.java | 70 +++++ .../instrumentation/OtelLog4j2.java | 86 +++++ .../instrumentation/OtelLogback.java | 86 +++++ .../instrumentation/OtelQuartz.java | 68 ++++ .../instrumentation/OtelServerMetrics.java | 296 ++++++++++++++++++ .../io/jooby/opentelemetry/package-info.java | 105 +++++++ .../src/main/java/module-info.java | 152 +++++++++ .../opentelemetry/OtelHttpTracingTest.java | 195 ++++++++++++ .../jooby/opentelemetry/OtelModuleTest.java | 87 +++++ .../io/jooby/opentelemetry/TraceTest.java | 184 +++++++++++ .../instrumentation/OtelDbSchedulerTest.java | 171 ++++++++++ .../instrumentation/OtelHikariTest.java | 74 +++++ .../instrumentation/OtelLog4j2Test.java | 119 +++++++ .../instrumentation/OtelLogbackTest.java | 102 ++++++ .../instrumentation/OtelQuartzTest.java | 64 ++++ .../OtelServerMetricsTest.java | 224 +++++++++++++ .../io/jooby/undertow/UndertowServer.java | 6 +- modules/pom.xml | 5 +- pom.xml | 1 + tests/pom.xml | 6 + 35 files changed, 3149 insertions(+), 89 deletions(-) create mode 100644 modules/jooby-opentelemetry/pom.xml create mode 100644 modules/jooby-opentelemetry/src/main/java/io/jooby/opentelemetry/OtelExtension.java create mode 100644 modules/jooby-opentelemetry/src/main/java/io/jooby/opentelemetry/OtelHttpTracing.java create mode 100644 modules/jooby-opentelemetry/src/main/java/io/jooby/opentelemetry/OtelModule.java create mode 100644 modules/jooby-opentelemetry/src/main/java/io/jooby/opentelemetry/Trace.java create mode 100644 modules/jooby-opentelemetry/src/main/java/io/jooby/opentelemetry/instrumentation/OtelDbScheduler.java create mode 100644 modules/jooby-opentelemetry/src/main/java/io/jooby/opentelemetry/instrumentation/OtelHikari.java create mode 100644 modules/jooby-opentelemetry/src/main/java/io/jooby/opentelemetry/instrumentation/OtelLog4j2.java create mode 100644 modules/jooby-opentelemetry/src/main/java/io/jooby/opentelemetry/instrumentation/OtelLogback.java create mode 100644 modules/jooby-opentelemetry/src/main/java/io/jooby/opentelemetry/instrumentation/OtelQuartz.java create mode 100644 modules/jooby-opentelemetry/src/main/java/io/jooby/opentelemetry/instrumentation/OtelServerMetrics.java create mode 100644 modules/jooby-opentelemetry/src/main/java/io/jooby/opentelemetry/package-info.java create mode 100644 modules/jooby-opentelemetry/src/main/java/module-info.java create mode 100644 modules/jooby-opentelemetry/src/test/java/io/jooby/opentelemetry/OtelHttpTracingTest.java create mode 100644 modules/jooby-opentelemetry/src/test/java/io/jooby/opentelemetry/OtelModuleTest.java create mode 100644 modules/jooby-opentelemetry/src/test/java/io/jooby/opentelemetry/TraceTest.java create mode 100644 modules/jooby-opentelemetry/src/test/java/io/jooby/opentelemetry/instrumentation/OtelDbSchedulerTest.java create mode 100644 modules/jooby-opentelemetry/src/test/java/io/jooby/opentelemetry/instrumentation/OtelHikariTest.java create mode 100644 modules/jooby-opentelemetry/src/test/java/io/jooby/opentelemetry/instrumentation/OtelLog4j2Test.java create mode 100644 modules/jooby-opentelemetry/src/test/java/io/jooby/opentelemetry/instrumentation/OtelLogbackTest.java create mode 100644 modules/jooby-opentelemetry/src/test/java/io/jooby/opentelemetry/instrumentation/OtelQuartzTest.java create mode 100644 modules/jooby-opentelemetry/src/test/java/io/jooby/opentelemetry/instrumentation/OtelServerMetricsTest.java diff --git a/jooby/src/main/java/io/jooby/Jooby.java b/jooby/src/main/java/io/jooby/Jooby.java index c0bcb5ce2b..3b24254776 100644 --- a/jooby/src/main/java/io/jooby/Jooby.java +++ b/jooby/src/main/java/io/jooby/Jooby.java @@ -937,7 +937,7 @@ public Jooby start(@NonNull Server server) { router.initialize(); - for (Extension extension : lateExtensions) { + for (var extension : lateExtensions) { try { extension.install(this); } catch (Throwable e) { @@ -949,7 +949,7 @@ public Jooby start(@NonNull Server server) { this.startingCallbacks = fire(this.startingCallbacks); - router.start(this, server); + router.start(this); return this; } diff --git a/jooby/src/main/java/io/jooby/internal/RouterImpl.java b/jooby/src/main/java/io/jooby/internal/RouterImpl.java index 63c036bacc..ee50a105f9 100644 --- a/jooby/src/main/java/io/jooby/internal/RouterImpl.java +++ b/jooby/src/main/java/io/jooby/internal/RouterImpl.java @@ -548,7 +548,7 @@ public void initialize() { configureContextAsService(routerOptions.isContextAsService()); } - @NonNull public Router start(@NonNull Jooby app, @NonNull Server server) { + @NonNull public Router start(@NonNull Jooby app) { started = true; var globalErrHandler = defineGlobalErrorHandler(app); if (err == null) { diff --git a/modules/jooby-bom/pom.xml b/modules/jooby-bom/pom.xml index d4e797270a..99d5674bd2 100644 --- a/modules/jooby-bom/pom.xml +++ b/modules/jooby-bom/pom.xml @@ -275,6 +275,11 @@ jooby-openapi ${project.version} + + io.jooby + jooby-opentelemetry + ${project.version} + io.jooby jooby-pac4j diff --git a/modules/jooby-db-scheduler/pom.xml b/modules/jooby-db-scheduler/pom.xml index 3e4e531635..bbdb059bf5 100644 --- a/modules/jooby-db-scheduler/pom.xml +++ b/modules/jooby-db-scheduler/pom.xml @@ -22,7 +22,7 @@ com.github.kagkarlsson db-scheduler - 16.7.1 + ${db-scheduler.version} diff --git a/modules/jooby-db-scheduler/src/main/java/io/jooby/dbscheduler/DbSchedulerModule.java b/modules/jooby-db-scheduler/src/main/java/io/jooby/dbscheduler/DbSchedulerModule.java index d83f33c99d..f6dd32b2e8 100644 --- a/modules/jooby-db-scheduler/src/main/java/io/jooby/dbscheduler/DbSchedulerModule.java +++ b/modules/jooby-db-scheduler/src/main/java/io/jooby/dbscheduler/DbSchedulerModule.java @@ -20,6 +20,7 @@ import com.github.kagkarlsson.scheduler.Scheduler; import com.github.kagkarlsson.scheduler.SchedulerName; +import com.github.kagkarlsson.scheduler.event.ExecutionInterceptor; import com.github.kagkarlsson.scheduler.jdbc.AutodetectJdbcCustomization; import com.github.kagkarlsson.scheduler.jdbc.JdbcCustomization; import com.github.kagkarlsson.scheduler.serializer.Serializer; @@ -73,6 +74,7 @@ public class DbSchedulerModule implements Extension { private ExecutorService dueExecutor; private ScheduledExecutorService housekeeperExecutor; private JdbcCustomization jdbcCustomization; + private final List executionInterceptors = new ArrayList<>(); /** * Creates a new module. @@ -126,6 +128,18 @@ public DbSchedulerModule withSchedulerName(@NonNull SchedulerName schedulerName) return this; } + /** + * Adds an execution interceptor to the scheduler module. Execution interceptors are used to + * customize the behavior of task execution, such as logging, monitoring, or modifying tasks. + * + * @param interceptor An {@link ExecutionInterceptor} that intercepts task execution. + * @return This {@link DbSchedulerModule} to allow method chaining. + */ + public DbSchedulerModule withExecutionInterceptor(@NonNull ExecutionInterceptor interceptor) { + this.executionInterceptors.add(interceptor); + return this; + } + /** * Set Task serializer. * @@ -280,7 +294,8 @@ public void install(@NonNull Jooby app) throws SQLException { // schedulerListeners.forEach(builder::addSchedulerListener); // Register interceptors - // executionInterceptors.forEach(builder::addExecutionInterceptor); + executionInterceptors.forEach(builder::addExecutionInterceptor); + var scheduler = builder.build(); app.getServices().put(Scheduler.class, scheduler); diff --git a/modules/jooby-hikari/src/main/java/io/jooby/hikari/HikariModule.java b/modules/jooby-hikari/src/main/java/io/jooby/hikari/HikariModule.java index 3cd694352e..29e851d15f 100644 --- a/modules/jooby-hikari/src/main/java/io/jooby/hikari/HikariModule.java +++ b/modules/jooby-hikari/src/main/java/io/jooby/hikari/HikariModule.java @@ -213,13 +213,16 @@ public void install(@NonNull Jooby application) { ServiceRegistry registry = application.getServices(); ServiceKey key = ServiceKey.key(DataSource.class, database); - /** Global default database: */ + /* Global default database: */ registry.putIfAbsent(KEY, dataSource); - /** Specific access: */ + /* Specific access: */ registry.put(key, dataSource); + /* List access: */ + registry.listOf(DataSource.class).add(dataSource); + registry.listOf(HikariDataSource.class).add(dataSource); - application.onStop(dataSource::close); + application.onStop(dataSource); } /** @@ -231,13 +234,11 @@ public void install(@NonNull Jooby application) { * @param url Jdbc connection string (a.k.a jdbc url) * @return Database type or given jdbc connection string for unknown or bad urls. */ - public static @NonNull String databaseType(@NonNull String url) { - String type = - Arrays.stream(url.toLowerCase().split(":")) - .filter(token -> !SKIP_TOKENS.contains(token)) - .findFirst() - .orElse(url); - return type; + public static String databaseType(@NonNull String url) { + return Arrays.stream(url.toLowerCase().split(":")) + .filter(token -> !SKIP_TOKENS.contains(token)) + .findFirst() + .orElse(url); } /** @@ -288,71 +289,151 @@ private static Map defaults(String database, Environment env) { defaults.put( "maximumPoolSize", Math.max(MINIMUM_SIZE, Runtime.getRuntime().availableProcessors() * WORKER_FACTOR)); - if ("derby".equals(database)) { - // url => jdbc:derby:${db};create=true - defaults.put("dataSourceClassName", "org.apache.derby.jdbc.ClientDataSource"); - } else if ("db2".equals(database)) { - // url => jdbc:db2://127.0.0.1:50000/SAMPLE - defaults.put("dataSourceClassName", "com.ibm.db2.jcc.DB2SimpleDataSource"); - } else if ("h2".equals(database)) { - // url => mem, fs or jdbc:h2:${db} - defaults.put("dataSourceClassName", "org.h2.jdbcx.JdbcDataSource"); - defaults.put("dataSource.user", "sa"); - defaults.put("dataSource.password", ""); - } else if ("hsqldb".equals(database)) { - // url => jdbc:hsqldb:file:${db} - defaults.put("dataSourceClassName", "org.hsqldb.jdbc.JDBCDataSource"); - } else if ("mariadb".equals(database)) { - // url jdbc:mariadb://:/?=&=... - defaults.put("dataSourceClassName", "org.mariadb.jdbc.MySQLDataSource"); - } else if ("mysql".equals(database)) { - // url jdbc:mysql://:/?=&=... - // 6.x - env.loadClass("com.mysql.cj.jdbc.MysqlDataSource") - .ifPresent(klass -> defaults.put("dataSourceClassName", klass.getName())); - // 5.x - if (!defaults.containsKey("dataSourceClassName")) { - env.loadClass("com.mysql.jdbc.jdbc2.optional.MysqlDataSource") - .ifPresent( - klass -> { - defaults.put("dataSourceClassName", klass.getName()); - defaults.put( - "dataSource.encoding", env.getConfig().getString(AvailableSettings.CHARSET)); - defaults.put("dataSource.cachePrepStmts", true); - defaults.put("dataSource.prepStmtCacheSize", MYSQL5_STT_CACHE_SIZE); - defaults.put("dataSource.prepStmtCacheSqlLimit", MYSQL5_STT_CACHE_SQL_LIMIT); - defaults.put("dataSource.useServerPrepStmts", true); - }); + if (database == null) { + return defaults; + } + switch (database) { + case "derby" -> + // url => jdbc:derby:${db};create=true + defaults.put("dataSourceClassName", "org.apache.derby.jdbc.ClientDataSource"); + case "db2" -> + // url => jdbc:db2://127.0.0.1:50000/SAMPLE + defaults.put("dataSourceClassName", "com.ibm.db2.jcc.DB2SimpleDataSource"); + case "h2" -> { + // url => mem, fs or jdbc:h2:${db} + defaults.put("dataSourceClassName", "org.h2.jdbcx.JdbcDataSource"); + defaults.put("dataSource.user", "sa"); + defaults.put("dataSource.password", ""); + } + case "hsqldb" -> + // url => jdbc:hsqldb:file:${db} + defaults.put("dataSourceClassName", "org.hsqldb.jdbc.JDBCDataSource"); + case "mariadb" -> + // url jdbc:mariadb://:/?=&=... + defaults.put("dataSourceClassName", "org.mariadb.jdbc.MySQLDataSource"); + case "mysql" -> { + // url jdbc:mysql://:/?=&=... + // 6.x + env.loadClass("com.mysql.cj.jdbc.MysqlDataSource") + .ifPresent(klass -> defaults.put("dataSourceClassName", klass.getName())); + // 5.x + if (!defaults.containsKey("dataSourceClassName")) { + env.loadClass("com.mysql.jdbc.jdbc2.optional.MysqlDataSource") + .ifPresent( + klass -> { + defaults.put("dataSourceClassName", klass.getName()); + defaults.put( + "dataSource.encoding", + env.getConfig().getString(AvailableSettings.CHARSET)); + defaults.put("dataSource.cachePrepStmts", true); + defaults.put("dataSource.prepStmtCacheSize", MYSQL5_STT_CACHE_SIZE); + defaults.put("dataSource.prepStmtCacheSqlLimit", MYSQL5_STT_CACHE_SQL_LIMIT); + defaults.put("dataSource.useServerPrepStmts", true); + }); + } } - } else if ("sqlserver".equals(database)) { - // url => - // jdbc:sqlserver://[serverName[\instanceName][:portNumber]][;property=value[;property=value]] - defaults.put("dataSourceClassName", "com.microsoft.sqlserver.jdbc.SQLServerDataSource"); - } else if ("oracle".equals(database)) { - // url => jdbc:oracle:thin:@//:/ - defaults.put("dataSourceClassName", "oracle.jdbc.pool.OracleDataSource"); - } else if ("pgsql".equals(database)) { - // url => jdbc:pgsql://[:]/ - defaults.put("dataSourceClassName", "com.impossibl.postgres.jdbc.PGDataSource"); - } else if ("postgresql".equals(database)) { - // url => jdbc:postgresql://host:port/database - defaults.put("dataSourceClassName", "org.postgresql.ds.PGSimpleDataSource"); - } else if ("sybase".equals(database)) { - // url => jdbc:jtds:sybase://[:][/] - defaults.put("dataSourceClassName", "com.sybase.jdbcx.SybDataSource"); - } else if ("firebirdsql".equals(database)) { - // jdbc:firebirdsql:host[/port]: - defaults.put("dataSourceClassName", "org.firebirdsql.pool.FBSimpleDataSource"); - } else if ("sqlite".equals(database)) { - // jdbc:sqlite:${db} - defaults.put("dataSourceClassName", "org.sqlite.SQLiteDataSource"); - } else if ("log4jdbc".equals(database)) { - // jdbc:log4jdbc:${dbtype}:${db} - defaults.put("driverClassName", "net.sf.log4jdbc.DriverSpy"); + case "sqlserver" -> + // url => + // jdbc:sqlserver://[serverName[\instanceName][:portNumber]][;property=value[;property=value]] + defaults.put("dataSourceClassName", "com.microsoft.sqlserver.jdbc.SQLServerDataSource"); + case "oracle" -> + // url => jdbc:oracle:thin:@//:/ + defaults.put("dataSourceClassName", "oracle.jdbc.pool.OracleDataSource"); + case "pgsql" -> + // url => jdbc:pgsql://[:]/ + defaults.put("dataSourceClassName", "com.impossibl.postgres.jdbc.PGDataSource"); + case "postgresql", "cockroach", "yugabyte" -> + // url => jdbc:postgresql://host:port/database + defaults.put("dataSourceClassName", "org.postgresql.ds.PGSimpleDataSource"); + case "sybase" -> + // url => jdbc:jtds:sybase://[:][/] + defaults.put("dataSourceClassName", "com.sybase.jdbcx.SybDataSource"); + case "firebirdsql" -> + // jdbc:firebirdsql:host[/port]: + defaults.put("dataSourceClassName", "org.firebirdsql.pool.FBSimpleDataSource"); + case "sqlite" -> + // jdbc:sqlite:${db} + defaults.put("dataSourceClassName", "org.sqlite.SQLiteDataSource"); + // --- OLAP & Analytics --- + case "clickhouse" -> + // jdbc:clickhouse://:/ + defaults.put("dataSourceClassName", "com.clickhouse.jdbc.ClickHouseDataSource"); + case "snowflake" -> + // jdbc:snowflake://.snowflakecomputing.com/? + defaults.put("driverClassName", "net.snowflake.client.jdbc.SnowflakeDriver"); + case "redshift" -> + // jdbc:redshift://..redshift.amazonaws.com:/ + defaults.put("driverClassName", "com.amazon.redshift.Driver"); + case "trino" -> + // jdbc:trino://:// + defaults.put("driverClassName", "io.trino.jdbc.TrinoDriver"); + // --- Proxies & Wrappers --- + case "log4jdbc" -> + // jdbc:log4jdbc:${dbtype}:${db} + defaults.put("driverClassName", "net.sf.log4jdbc.DriverSpy"); + case "otel" -> + // jdbc:otel:${dbtype}:${db} + defaults.put( + "driverClassName", "io.opentelemetry.instrumentation.jdbc.OpenTelemetryDriver"); } return defaults; } + /** + * Forces the JVM to load and execute the static initialization block of the underlying JDBC + * Driver. This is specifically required for wrappers like OpenTelemetry that rely on + * java.sql.DriverManager instead of direct DataSource instantiation. + * + * @param database The target database type (e.g., "mysql", "postgresql") + * @param env The Jooby environment providing the classloader + */ + private static void forceLoadDriver(String database, Environment env) { + if (database == null) { + return; + } + + // Map the database string to its explicit java.sql.Driver implementation + var driverClassName = + switch (database) { + case "derby" -> "org.apache.derby.jdbc.ClientDriver"; + case "db2" -> "com.ibm.db2.jcc.DB2Driver"; + case "h2" -> "org.h2.Driver"; + case "hsqldb" -> "org.hsqldb.jdbc.JDBCDriver"; + case "mariadb" -> "org.mariadb.jdbc.Driver"; + case "mysql" -> "com.mysql.cj.jdbc.Driver"; // Modern 6.x/8.x Driver + case "sqlserver" -> "com.microsoft.sqlserver.jdbc.SQLServerDriver"; + case "oracle" -> "oracle.jdbc.OracleDriver"; + case "pgsql" -> "com.impossibl.postgres.jdbc.PGDriver"; + case "postgresql", "cockroach", "yugabyte" -> "org.postgresql.Driver"; + case "sybase" -> "com.sybase.jdbc4.jdbc.SybDriver"; + case "firebirdsql" -> "org.firebirdsql.jdbc.FBDriver"; + case "sqlite" -> "org.sqlite.JDBC"; + // --- OLAP & Analytics --- + case "clickhouse" -> "com.clickhouse.jdbc.ClickHouseDriver"; + case "snowflake" -> "net.snowflake.client.jdbc.SnowflakeDriver"; + case "redshift" -> "com.amazon.redshift.Driver"; + case "trino" -> "io.trino.jdbc.TrinoDriver"; + default -> null; + }; + + if (driverClassName != null) { + try { + // The 'true' flag is the magic key: it forces the static {} block to execute, + // registering the driver globally with Java's DriverManager. + Class.forName(driverClassName, true, env.getClassLoader()); + } catch (ClassNotFoundException e) { + // Graceful fallback for legacy MySQL 5.x users if the modern driver is missing + if ("mysql".equals(database)) { + try { + Class.forName("com.mysql.jdbc.Driver", true, env.getClassLoader()); + } catch (ClassNotFoundException ignore) { + // Ignore missing driver; let the standard JDBC connection handle the failure later + } + } + } + } + } + static HikariConfig build(Environment env, String database) { Properties properties; Config config = env.getConfig(); @@ -379,7 +460,7 @@ static HikariConfig build(Environment env, String database) { dumpProperties(config, dbname, "dataSource.", properties::setProperty); } - /** *.dataSource AND *.hikari */ + /* *.dataSource AND *.hikari */ Stream.of(dbkey, dbname) .filter(Objects::nonNull) .distinct() @@ -403,7 +484,10 @@ static HikariConfig build(Environment env, String database) { configuration.remove("dataSource.url"); configuration.setProperty("jdbcUrl", dburl); } - + // wake driver for otel + if (dburl != null && dburl.startsWith("jdbc:otel:")) { + forceLoadDriver(databaseType(dburl.replace(":otel:", ":")), env); + } if (dbtype == null) { String poolName = Stream.of( diff --git a/modules/jooby-jetty/src/main/java/io/jooby/jetty/JettyServer.java b/modules/jooby-jetty/src/main/java/io/jooby/jetty/JettyServer.java index 9dc392a3cd..6ea24cf36d 100644 --- a/modules/jooby-jetty/src/main/java/io/jooby/jetty/JettyServer.java +++ b/modules/jooby-jetty/src/main/java/io/jooby/jetty/JettyServer.java @@ -134,8 +134,6 @@ public io.jooby.Server start(@NonNull Jooby... application) { ((QueuedThreadPool) threadPool).setName("worker"); } - fireStart(List.of(application), threadPool); - var acceptors = 1; var selectors = options.getIoThreads(); server = new Server(threadPool); @@ -272,17 +270,21 @@ public io.jooby.Server start(@NonNull Jooby... application) { container.setIdleTimeout(Duration.ofMillis(timeout)); } server.setHandler(context); - server.start(); - // --- EXTRACT OS-ASSIGNED PORTS --- + for (var app : applications) { + var services = app.getServices(); + services.put(Server.class, server); + } + + fireStart(List.of(application), threadPool); + + server.start(); if (httpConector != null) { options.setPort(httpConector.getLocalPort()); } if (secureConnector != null) { options.setSecurePort(secureConnector.getLocalPort()); } - // --------------------------------- - fireReady(applications); } catch (Exception x) { if (io.jooby.Server.isAddressInUse(x.getCause())) { diff --git a/modules/jooby-netty/src/main/java/io/jooby/internal/netty/NettyEventLoopGroupImpl.java b/modules/jooby-netty/src/main/java/io/jooby/internal/netty/NettyEventLoopGroupImpl.java index cf8e4d8e71..a37600e62a 100644 --- a/modules/jooby-netty/src/main/java/io/jooby/internal/netty/NettyEventLoopGroupImpl.java +++ b/modules/jooby-netty/src/main/java/io/jooby/internal/netty/NettyEventLoopGroupImpl.java @@ -16,7 +16,7 @@ public class NettyEventLoopGroupImpl implements NettyEventLoopGroup { private final EventLoopGroup parent; private final EventLoopGroup child; private boolean closed; - private ExecutorService worker; + private final ExecutorService worker; public NettyEventLoopGroupImpl( NettyTransport transport, boolean single, int ioThreads, ExecutorService worker) { diff --git a/modules/jooby-netty/src/main/java/io/jooby/netty/NettyServer.java b/modules/jooby-netty/src/main/java/io/jooby/netty/NettyServer.java index 20679d1c36..e649539abc 100644 --- a/modules/jooby-netty/src/main/java/io/jooby/netty/NettyServer.java +++ b/modules/jooby-netty/src/main/java/io/jooby/netty/NettyServer.java @@ -150,13 +150,18 @@ public Server start(@NonNull Jooby... application) { transport, singleEventLoopGroup, options.getIoThreads(), worker); } this.dateLoop = new NettyDateService(); - - fireStart(List.of(application), eventLoop.worker()); - var outputFactory = (NettyOutputFactory) getOutputFactory(); var allocator = outputFactory.getAllocator(); var http2 = options.isHttp2() == Boolean.TRUE; + for (var app : applications) { + var services = app.getServices(); + services.put(NettyEventLoopGroup.class, eventLoop); + services.put(ByteBufAllocator.class, allocator); + } + + fireStart(List.of(application), eventLoop.worker()); + // Retrieve the GrpcProcessor from the application's service registry GrpcProcessor grpcProcessor = http2 ? applications.get(0).getServices().getOrNull(GrpcProcessor.class) : null; diff --git a/modules/jooby-opentelemetry/pom.xml b/modules/jooby-opentelemetry/pom.xml new file mode 100644 index 0000000000..f15ee123f1 --- /dev/null +++ b/modules/jooby-opentelemetry/pom.xml @@ -0,0 +1,176 @@ + + + + 4.0.0 + + + io.jooby + modules + 4.3.1-SNAPSHOT + + jooby-opentelemetry + jooby-opentelemetry + + + + io.jooby + jooby + ${jooby.version} + + + + org.slf4j + jul-to-slf4j + + + + io.opentelemetry + opentelemetry-api + + + io.opentelemetry.instrumentation + opentelemetry-runtime-telemetry + + + io.opentelemetry + opentelemetry-sdk-extension-autoconfigure + + + + + com.zaxxer + HikariCP + true + + + io.opentelemetry.instrumentation + opentelemetry-hikaricp-3.0 + true + + + + + ch.qos.logback + logback-classic + true + + + io.opentelemetry.instrumentation + opentelemetry-logback-appender-1.0 + true + + + + org.apache.logging.log4j + log4j-core + ${log4j.version} + true + + + io.opentelemetry.instrumentation + opentelemetry-log4j-appender-2.17 + true + + + + org.eclipse.jetty + jetty-server + true + + + + + io.netty + netty-common + true + + + io.jooby + jooby-netty + true + + + + + io.undertow + undertow-core + true + + + + + org.quartz-scheduler + quartz + true + + + io.opentelemetry.instrumentation + opentelemetry-quartz-2.0 + true + + + + + com.github.kagkarlsson + db-scheduler + ${db-scheduler.version} + true + + + + + org.junit.jupiter + junit-jupiter-engine + test + + + + org.jacoco + org.jacoco.agent + runtime + test + + + + org.mockito + mockito-core + test + + + + org.mockito + mockito-junit-jupiter + test + + + io.opentelemetry + opentelemetry-sdk-testing + test + + + org.assertj + assertj-core + + + + + + + io.opentelemetry + opentelemetry-bom + 1.60.1 + pom + import + + + io.opentelemetry.instrumentation + opentelemetry-instrumentation-bom-alpha + 2.26.1-alpha + pom + import + + + + diff --git a/modules/jooby-opentelemetry/src/main/java/io/jooby/opentelemetry/OtelExtension.java b/modules/jooby-opentelemetry/src/main/java/io/jooby/opentelemetry/OtelExtension.java new file mode 100644 index 0000000000..a586c3ba0b --- /dev/null +++ b/modules/jooby-opentelemetry/src/main/java/io/jooby/opentelemetry/OtelExtension.java @@ -0,0 +1,36 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.opentelemetry; + +import io.jooby.Jooby; +import io.opentelemetry.api.OpenTelemetry; + +/** + * Extension point for OpenTelemetry integrations within a Jooby application. + * + *

While {@link OtelModule} is responsible for bootstrapping the core OpenTelemetry SDK, this + * interface allows developers to seamlessly attach secondary instrumentation modules (such as + * Logback appenders, HikariCP metrics, or Quartz job tracers) to the running SDK. + * + *

Lifecycle: Extensions are not executed immediately when passed to the {@code + * OtelModule} constructor. Instead, their execution is deferred until the Jooby application fires + * its {@code onStarting} event. This guarantees that the primary OpenTelemetry instance is fully + * configured and safely registered before any extensions attempt to use it. + */ +@FunctionalInterface +public interface OtelExtension { + + /** + * Installs and binds the OpenTelemetry extension to the application. + * + * @param application The current Jooby application. Extensions can use this to read application + * configuration, register internal services, or attach additional lifecycle hooks (e.g., + * closing resources during {@code onStop}). + * @param openTelemetry The fully constructed and configured OpenTelemetry instance. + * @throws Exception If the extension fails to initialize or attach its instrumentation. + */ + void install(Jooby application, OpenTelemetry openTelemetry) throws Exception; +} diff --git a/modules/jooby-opentelemetry/src/main/java/io/jooby/opentelemetry/OtelHttpTracing.java b/modules/jooby-opentelemetry/src/main/java/io/jooby/opentelemetry/OtelHttpTracing.java new file mode 100644 index 0000000000..8a57fa5d03 --- /dev/null +++ b/modules/jooby-opentelemetry/src/main/java/io/jooby/opentelemetry/OtelHttpTracing.java @@ -0,0 +1,127 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.opentelemetry; + +import static io.opentelemetry.context.Context.current; + +import io.jooby.Context; +import io.jooby.Route; +import io.opentelemetry.api.OpenTelemetry; +import io.opentelemetry.api.trace.SpanKind; +import io.opentelemetry.api.trace.Tracer; +import io.opentelemetry.context.propagation.TextMapGetter; + +/** + * OpenTelemetry HTTP tracing filter for Jooby routes. + * + *

This filter intercepts incoming HTTP requests and automatically creates an OpenTelemetry + * {@link SpanKind#SERVER} span for the request lifecycle. It acts as the primary entry point for + * distributed tracing in the web layer. + * + *

Features

+ * + *
    + *
  • Distributed Context Extraction: Automatically extracts W3C Trace Context + * headers (e.g., {@code traceparent}) from incoming requests to continue existing traces + * spanning multiple microservices. + *
  • Safe Span Naming: Uses the Jooby route pattern (e.g., {@code GET + * /api/users/{id}}) rather than the raw URI to prevent metric high-cardinality issues. + *
  • Semantic Conventions: Automatically populates standard HTTP attributes + * ({@code http.request.method}, {@code http.response.status_code}, etc.). + *
  • Asynchronous Safety: Ties the span closure to Jooby's {@code onComplete} + * hook, ensuring the span is accurately timed even if the route executes asynchronously. + *
+ * + *

Usage

+ * + *

Register this filter globally in your application using {@code use()} or {@code decorator()}. + * It must be registered after {@link OtelModule} is installed. + * + *

{@code
+ * {
+ * install(new OtelModule());
+ * use(new OtelHttpTracing());
+ * * get("/users/{id}", ctx -> "User " + ctx.path("id").value());
+ * }
+ * }
+ * + * @author edgar + * @since 4.3.1 + */ +public class OtelHttpTracing implements Route.Filter { + + /** + * Intercepts the HTTP request to initialize, populate, and eventually close the OpenTelemetry + * span. + * + * @param next The next handler in the routing chain. + * @return A wrapped route handler containing the tracing logic. + */ + @Override + public Route.Handler apply(Route.Handler next) { + return ctx -> { + // Create a high-cardinality-safe span name: e.g., "GET /api/users/{id}" + var spanName = ctx.getMethod() + " " + ctx.getRoute().getPattern(); + var tracer = ctx.require(Tracer.class); + var otel = ctx.require(OpenTelemetry.class); + var propagator = otel.getPropagators().getTextMapPropagator(); + + var extractedContext = propagator.extract(current(), ctx, JoobyRequestGetter.INSTANCE); + var span = + tracer + .spanBuilder(spanName) + .setParent(extractedContext) + .setSpanKind(SpanKind.SERVER) + .setAttribute("http.request.method", ctx.getMethod()) + .setAttribute("url.path", ctx.getRequestPath()) + .setAttribute("http.route", ctx.getRoute().getPattern()) + .startSpan(); + + // Ensure the span is ended ONLY when the HTTP response is fully complete + ctx.onComplete( + context -> { + int statusCode = context.getResponseCode().value(); + span.setAttribute("http.response.status_code", statusCode); + if (statusCode >= 500) { + // Mark as error based on standard semantic conventions + span.setStatus(io.opentelemetry.api.trace.StatusCode.ERROR); + } + span.end(); + }); + + // Activate the span in the current thread scope + try (var scope = span.makeCurrent()) { + ctx.setAttribute("otel-span", span); + + return next.apply(ctx); + } catch (Throwable t) { + span.recordException(t); + span.setAttribute("http.response.status_code", ctx.getRouter().errorCode(t).value()); + throw t; + } + }; + } + + /** + * A bridge implementation allowing OpenTelemetry to extract distributed tracing headers directly + * from a Jooby {@link Context}. + */ + enum JoobyRequestGetter implements TextMapGetter { + INSTANCE; + + @Override + public Iterable keys(io.jooby.Context ctx) { + // Allows OTel to iterate over all header names if needed + return ctx.headerMap().keySet(); + } + + @Override + public String get(io.jooby.Context ctx, String key) { + // Safely extract the header value, returning null if it doesn't exist + return ctx.header(key).valueOrNull(); + } + } +} diff --git a/modules/jooby-opentelemetry/src/main/java/io/jooby/opentelemetry/OtelModule.java b/modules/jooby-opentelemetry/src/main/java/io/jooby/opentelemetry/OtelModule.java new file mode 100644 index 0000000000..1b6f9436b6 --- /dev/null +++ b/modules/jooby-opentelemetry/src/main/java/io/jooby/opentelemetry/OtelModule.java @@ -0,0 +1,231 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.opentelemetry; + +import static io.jooby.SneakyThrows.throwingConsumer; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Supplier; + +import org.slf4j.bridge.SLF4JBridgeHandler; + +import edu.umd.cs.findbugs.annotations.NonNull; +import io.jooby.Extension; +import io.jooby.Jooby; +import io.opentelemetry.api.GlobalOpenTelemetry; +import io.opentelemetry.api.OpenTelemetry; +import io.opentelemetry.api.trace.Tracer; +import io.opentelemetry.instrumentation.runtimetelemetry.RuntimeTelemetry; +import io.opentelemetry.sdk.OpenTelemetrySdk; +import io.opentelemetry.sdk.autoconfigure.AutoConfiguredOpenTelemetrySdk; +import jakarta.inject.Provider; + +/** + * OpenTelemetry module for Jooby. + * + *

This module integrates OpenTelemetry into your Jooby application, providing the foundational + * engine for distributed tracing, metrics, and log correlation. It handles the lifecycle of the + * {@link OpenTelemetry} SDK and registers the SDK, the default {@link Tracer}, and the fluent + * {@link Trace} utility into the Jooby application services. + * + *

Important: Installation Order

+ * + *

Because this module bootstraps the core telemetry engine and registers the OpenTelemetry + * instance into the application services, it must be installed at the very + * beginning of your application setup. Installing it early ensures that all subsequent + * routes, filters, and extensions have immediate access to the tracer and metric instruments. + * + *

Usage

+ * + *

Install the module into your application, passing any specific OpenTelemetry extensions you + * want to enable. To automatically trace HTTP requests, you must also append the {@code + * OtelHttpTracing} filter to your routing pipeline: + * + *

{@code
+ * {
+ * // 1. Install the core engine FIRST
+ * install(new OtelModule(
+ * new OtelLogback(),       // Injects Trace IDs into application logs
+ * new OtelServerMetrics(), // Exports HTTP server metrics (e.g., Netty, Undertow, Jetty)
+ * new OtelHikari()         // Traces database connection pools
+ * ));
+ *
+ * // 2. Add the tracing filter to the routing pipeline
+ * use(new OtelHttpTracing());
+ *
+ * // 3. Define routes
+ * get("/books", ctx -> "List of books");
+ * }
+ * }
+ * + *

Route Tracing (OtelHttpTracing)

+ * + *

While {@code OtelModule} bootstraps the core OpenTelemetry engine, it does not automatically + * trace web requests. You must explicitly include {@code OtelHttpTracing}. + * + *

Note that {@code OtelHttpTracing} is not an {@link OtelExtension}; it is a + * native Jooby {@code Route.Filter}. It must be installed directly into the application's routing + * pipeline (e.g., via {@code use()}) to intercept, create, and propagate spans for incoming HTTP + * requests. + * + *

Manual Tracing

+ * + *

For tracing specific business logic, database queries, or external API calls, this module + * provides a fluent {@link Trace} utility. You can retrieve it from the route context or inject it + * directly into your service layer to safely create, configure, and execute custom spans without + * risking context leaks. + * + *

{@code
+ * get("/books/{isbn}", ctx -> {
+ *   Trace trace = ctx.require(Trace.class);
+ *   String isbn = ctx.path("isbn").value();
+ *   return trace.span("fetch_book")
+ *        .attribute("isbn", isbn)
+ *        .execute(span -> {
+ *           span.addEvent("Executing database query");
+ *           return repository.findByIsbn(isbn);
+ *         });
+ * });
+ * }
+ * + *

Configuration

+ * + *

The OpenTelemetry SDK is configured directly from your application's {@code application.conf}. + * Any property defined inside the {@code otel} block is automatically extracted and used to + * configure the underlying SDK components, such as exporters, protocols, and service attributes. + * + *

{@code
+ * otel {
+ * service.name = "jooby-api"
+ * traces.exporter = otlp
+ * metrics.exporter = otlp
+ * logs.exporter = otlp
+ * exporter.otlp.protocol = grpc
+ * exporter.otlp.endpoint = "http://localhost:4317"
+ * }
+ * }
+ * + *

If no {@code otel} configuration block is present in the application configuration, the module + * will fall back to a baseline, default SDK. + * + *

Extensions Lifecycle

+ * + *

Additional OpenTelemetry integrations (such as logging appenders or connection pool metrics) + * are provided via {@link OtelExtension} implementations. These extensions are not executed + * immediately upon module installation. + * + *

Instead, the module defers their execution by registering them to the application's {@code + * onStarting} lifecycle hook. This guarantees that the primary OpenTelemetry SDK is fully + * constructed, configured, and registered before any secondary extensions attempt to hook into it + * or emit telemetry data. + * + * @since 4.3.1 + * @author edgar + */ +public class OtelModule implements Extension { + + static { + SLF4JBridgeHandler.install(); + } + + private final OpenTelemetry openTelemetry; + private final List extensions; + + /** + * Creates a new OpenTelemetry module with a pre-configured OpenTelemetry instance. + * + * @param openTelemetry A pre-configured OpenTelemetry instance. + * @param extensions Optional extensions (e.g., OtelLogback, OtelHikari). + */ + public OtelModule(OpenTelemetry openTelemetry, OtelExtension... extensions) { + this.openTelemetry = openTelemetry; + this.extensions = List.of(extensions); + } + + /** + * Creates a new OpenTelemetry module. The SDK will be automatically configured based on the + * application's {@code application.conf}. + * + * @param extensions Optional extensions (e.g., OtelLogback, OtelHikari). + */ + public OtelModule(OtelExtension... extensions) { + this(null, extensions); + } + + @Override + public void install(@NonNull Jooby application) { + var otel = getOrCreate(application); + if (!isRunningInJoobyRun() && otel instanceof AutoCloseable closeableOtel) { + // Close the OpenTelemetry instance when the application is stopped, and we are not running + // in joobyRun. + application.onStop(closeableOtel); + } + var tracer = otel.getTracer("io.jooby.opentelemetry"); + + application.onStop(RuntimeTelemetry.create(otel)); + var services = application.getServices(); + services.put(OpenTelemetry.class, otel); + services.put(Tracer.class, tracer); + services.put(Trace.class, trace(tracer)); + + application.onStarting( + () -> extensions.forEach(throwingConsumer(ext -> ext.install(application, otel)))); + } + + private static Provider trace(Tracer tracer) { + return () -> new Trace(tracer); + } + + private boolean isRunningInJoobyRun() { + return getClass() + .getClassLoader() + .getClass() + .getName() + .equals("org.jboss.modules.ModuleClassLoader"); + } + + private OpenTelemetry getOrCreate(@NonNull Jooby application) { + if (this.openTelemetry == null) { + var appConfig = application.getConfig(); + Map otelProperties = new HashMap<>(); + if (appConfig.hasPath("otel")) { + var otelConfig = appConfig.getConfig("otel"); + otelConfig + .entrySet() + .forEach( + entry -> { + String key = "otel." + entry.getKey(); + String value = entry.getValue().unwrapped().toString(); + otelProperties.put(key, value); + }); + return safeCreateOnJoobyRun( + () -> + AutoConfiguredOpenTelemetrySdk.builder() + .addPropertiesSupplier(() -> otelProperties) + .disableShutdownHook() + .setResultAsGlobal() + .build() + .getOpenTelemetrySdk()); + } else { + return safeCreateOnJoobyRun(() -> OpenTelemetrySdk.builder().buildAndRegisterGlobal()); + } + } + return this.openTelemetry; + } + + private OpenTelemetry safeCreateOnJoobyRun(Supplier supplier) { + try { + return supplier.get(); + } catch (IllegalStateException ex) { + if (isRunningInJoobyRun()) { + return GlobalOpenTelemetry.get(); + } + throw ex; + } + } +} diff --git a/modules/jooby-opentelemetry/src/main/java/io/jooby/opentelemetry/Trace.java b/modules/jooby-opentelemetry/src/main/java/io/jooby/opentelemetry/Trace.java new file mode 100644 index 0000000000..ec0238fe94 --- /dev/null +++ b/modules/jooby-opentelemetry/src/main/java/io/jooby/opentelemetry/Trace.java @@ -0,0 +1,128 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.opentelemetry; + +import java.util.function.Consumer; + +import io.jooby.SneakyThrows; +import io.opentelemetry.api.common.AttributeKey; +import io.opentelemetry.api.trace.Span; +import io.opentelemetry.api.trace.StatusCode; +import io.opentelemetry.api.trace.Tracer; + +/** + * Injectable utility for creating safe OpenTelemetry traces and spans. + * + * @author edgar + * @since 4.3.1 + */ +public class Trace { + + private final Tracer tracer; + + public Trace(Tracer tracer) { + this.tracer = tracer; + } + + /** + * Begins building a new OpenTelemetry span operation. + * + * @param name The name of the operation. + * @return A fluent builder to add attributes and execute logic. + */ + public Operation span(String name) { + return new Operation(tracer, name); + } + + public interface SpanTask { + T execute(Span span) throws Exception; + } + + public interface SpanRunnable { + void run(Span span) throws Exception; + } + + /** Represents an in-flight trace operation. */ + public static class Operation { + private final io.opentelemetry.api.trace.SpanBuilder otelSpanBuilder; + + private Operation(Tracer tracer, String name) { + this.otelSpanBuilder = tracer.spanBuilder(name); + } + + /** Escape hatch: Provides direct access to the native OpenTelemetry SpanBuilder. */ + public Operation configure(Consumer customizer) { + customizer.accept(otelSpanBuilder); + return this; + } + + public Operation attribute(String key, String value) { + otelSpanBuilder.setAttribute(key, value); + return this; + } + + public Operation attribute(String key, long value) { + otelSpanBuilder.setAttribute(key, value); + return this; + } + + public Operation attribute(String key, double value) { + otelSpanBuilder.setAttribute(key, value); + return this; + } + + public Operation attribute(String key, boolean value) { + otelSpanBuilder.setAttribute(key, value); + return this; + } + + /** Supports strongly-typed OpenTelemetry semantic convention keys. */ + public Operation attribute(AttributeKey key, T value) { + otelSpanBuilder.setAttribute(key, value); + return this; + } + + public Operation kind(io.opentelemetry.api.trace.SpanKind kind) { + otelSpanBuilder.setSpanKind(kind); + return this; + } + + public Operation rootContext() { + otelSpanBuilder.setNoParent(); + return this; + } + + /** Executes logic that returns a value within the span context. */ + public T execute(SpanTask block) { + var span = otelSpanBuilder.startSpan(); + try (var scope = span.makeCurrent()) { + return block.execute(span); + } catch (Throwable t) { + span.recordException(t); + span.setStatus( + StatusCode.ERROR, t.getMessage() != null ? t.getMessage() : t.getClass().getName()); + throw SneakyThrows.propagate(t); + } finally { + span.end(); + } + } + + /** Executes void logic within the span context. */ + public void run(SpanRunnable block) { + var span = otelSpanBuilder.startSpan(); + try (var scope = span.makeCurrent()) { + block.run(span); + } catch (Throwable t) { + span.recordException(t); + span.setStatus( + StatusCode.ERROR, t.getMessage() != null ? t.getMessage() : t.getClass().getName()); + throw SneakyThrows.propagate(t); + } finally { + span.end(); + } + } + } +} diff --git a/modules/jooby-opentelemetry/src/main/java/io/jooby/opentelemetry/instrumentation/OtelDbScheduler.java b/modules/jooby-opentelemetry/src/main/java/io/jooby/opentelemetry/instrumentation/OtelDbScheduler.java new file mode 100644 index 0000000000..d806dd88e9 --- /dev/null +++ b/modules/jooby-opentelemetry/src/main/java/io/jooby/opentelemetry/instrumentation/OtelDbScheduler.java @@ -0,0 +1,154 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.opentelemetry.instrumentation; + +import com.github.kagkarlsson.scheduler.event.ExecutionChain; +import com.github.kagkarlsson.scheduler.event.ExecutionInterceptor; +import com.github.kagkarlsson.scheduler.task.CompletionHandler; +import com.github.kagkarlsson.scheduler.task.ExecutionContext; +import com.github.kagkarlsson.scheduler.task.TaskInstance; +import io.opentelemetry.api.OpenTelemetry; +import io.opentelemetry.api.common.AttributeKey; +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.api.metrics.DoubleHistogram; +import io.opentelemetry.api.metrics.LongCounter; +import io.opentelemetry.api.trace.SpanKind; +import io.opentelemetry.api.trace.StatusCode; +import io.opentelemetry.api.trace.Tracer; + +/** + * OpenTelemetry instrumentation for the {@code db-scheduler} library. + * + *

This class implements {@link ExecutionInterceptor} to automatically generate traces and + * metrics for every scheduled task execution. + * + *

Traces

+ * + *

Creates an {@link SpanKind#INTERNAL} span named {@code Job } for each execution. The + * span includes the following attributes: + * + *

    + *
  • {@code job.system}: Always set to {@code "db-scheduler"} + *
  • {@code job.id}: The unique identifier of the task instance + *
+ * + * Exceptions thrown during execution are recorded on the span, and the span status is set to {@link + * StatusCode#ERROR}. + * + *

Metrics

+ * + *

Records the following metrics under the {@code io.jooby.db-scheduler} meter: + * + *

    + *
  • {@code dbscheduler.task.completions} (Counter): Tracks total task executions. + *
  • {@code dbscheduler.task.duration} (Histogram): Tracks execution time in seconds. + *
+ * + * Both metrics include the {@code task} name and the execution {@code result} (either {@code "ok"} + * or {@code "failed"}) as attributes. + * + *

Usage

+ * + *
{@code
+ * install(new OtelModule(...));
+ *
+ * install(new DbSchedulerModule()
+ *    .withExecutionInterceptor(new OtelDbScheduler(require(OpenTelemetry.class)))
+ * )
+ * }
+ * + * @author edgar + * @since 4.3.1 + */ +public class OtelDbScheduler implements ExecutionInterceptor { + private final Tracer tracer; + private final LongCounter completionsCounter; + private final DoubleHistogram durationHistogram; + + /** + * Creates a new OpenTelemetry interceptor for db-scheduler. + * + * @param openTelemetry The fully configured OpenTelemetry instance used to extract the {@link + * Tracer} and {@link io.opentelemetry.api.metrics.Meter}. + */ + public OtelDbScheduler(OpenTelemetry openTelemetry) { + this.tracer = openTelemetry.getTracer("io.jooby.db-scheduler"); + var meter = openTelemetry.getMeter("io.jooby.db-scheduler"); + + this.completionsCounter = + meter + .counterBuilder("dbscheduler.task.completions") + .setDescription("Successes and failures by task") + .setUnit("{completion}") + .build(); + + this.durationHistogram = + meter + .histogramBuilder("dbscheduler.task.duration") + .setDescription("Duration of executions") + .setUnit("s") + .build(); + } + + /** + * Intercepts the task execution to start a span, measure duration, and record metrics. + * + * @param taskInstance The instance of the task being executed. + * @param executionContext The current execution context. + * @param chain The execution chain to proceed. + * @return The completion handler returned by the underlying task or chain. + */ + @Override + public CompletionHandler execute( + TaskInstance taskInstance, ExecutionContext executionContext, ExecutionChain chain) { + + var taskName = taskInstance.getTaskName(); + var startTime = System.nanoTime(); + + var span = + tracer + .spanBuilder("Job " + taskName) + .setSpanKind(SpanKind.INTERNAL) + .setAttribute("job.system", "db-scheduler") + .setAttribute("job.id", taskInstance.getId()) + .startSpan(); + + try (var scope = span.makeCurrent()) { + var result = chain.proceed(taskInstance, executionContext); + + recordMetrics(taskName, startTime, "ok"); + return result; + + } catch (Throwable t) { + span.recordException(t); + span.setStatus(StatusCode.ERROR); + + recordMetrics(taskName, startTime, "failed"); + throw t; + } finally { + span.end(); + } + } + + /** + * Records the completion and duration metrics for the task execution. + * + * @param taskName The name of the executed task. + * @param startTimeNanos The start time of the execution in nanoseconds. + * @param result The outcome of the execution (e.g., "ok" or "failed"). + */ + private void recordMetrics(String taskName, long startTimeNanos, String result) { + var durationSeconds = (System.nanoTime() - startTimeNanos) / 1_000_000_000.0; + + var attributes = + Attributes.of( + AttributeKey.stringKey("task"), taskName, + AttributeKey.stringKey("result"), result); + + completionsCounter.add(1, attributes); + durationHistogram.record(durationSeconds, attributes); + } +} diff --git a/modules/jooby-opentelemetry/src/main/java/io/jooby/opentelemetry/instrumentation/OtelHikari.java b/modules/jooby-opentelemetry/src/main/java/io/jooby/opentelemetry/instrumentation/OtelHikari.java new file mode 100644 index 0000000000..3a53bc94b5 --- /dev/null +++ b/modules/jooby-opentelemetry/src/main/java/io/jooby/opentelemetry/instrumentation/OtelHikari.java @@ -0,0 +1,70 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.opentelemetry.instrumentation; + +import com.zaxxer.hikari.HikariDataSource; +import io.jooby.Jooby; +import io.jooby.Reified; +import io.jooby.opentelemetry.OtelExtension; +import io.opentelemetry.api.OpenTelemetry; +import io.opentelemetry.instrumentation.hikaricp.v3_0.HikariTelemetry; + +/** + * OpenTelemetry extension for HikariCP connection pools. + * + *

This extension automatically instruments all {@link HikariDataSource} instances registered + * within the Jooby application, exporting critical connection pool metrics (such as active + * connections, idle connections, and connection timeouts) to the OpenTelemetry backend. + * + *

Required Dependency

+ * + *

To use this extension, you must add the official OpenTelemetry HikariCP instrumentation + * library to your project's classpath: + * + *

{@code
+ * 
+ * io.opentelemetry.instrumentation
+ * opentelemetry-hikaricp-3.0
+ * 
+ * }
+ * + *

Installation Order

+ * + *

Application installation order is critical. The {@code OtelModule} must be installed + * first, followed by the {@code HikariModule}. + * + *

{@code
+ * {
+ * // 1. Install OpenTelemetry with the Hikari extension FIRST
+ * install(new OtelModule(new OtelHikari()));
+ *
+ * // 2. Install HikariModule NEXT
+ * install(new HikariModule());
+ * }
+ * }
+ * + *

Lifecycle Note: Although {@code OtelModule} is installed first, this extension defers + * its execution to the application's {@code onStarting} lifecycle hook. This ensures that all data + * sources configured by the subsequent {@code HikariModule} are fully initialized and available in + * the service registry before the metrics tracker is applied. + * + * @since 4.3.1 + * @author edgar + */ +public class OtelHikari implements OtelExtension { + + @Override + public void install(Jooby application, OpenTelemetry openTelemetry) { + java.util.List dataSources = + application.require(Reified.list(HikariDataSource.class)); + var hikariTelemetry = HikariTelemetry.create(openTelemetry); + + // Apply the telemetry metrics tracker to every configured Hikari connection pool + for (HikariDataSource dataSource : dataSources) { + dataSource.setMetricsTrackerFactory(hikariTelemetry.createMetricsTrackerFactory()); + } + } +} diff --git a/modules/jooby-opentelemetry/src/main/java/io/jooby/opentelemetry/instrumentation/OtelLog4j2.java b/modules/jooby-opentelemetry/src/main/java/io/jooby/opentelemetry/instrumentation/OtelLog4j2.java new file mode 100644 index 0000000000..d45226e50d --- /dev/null +++ b/modules/jooby-opentelemetry/src/main/java/io/jooby/opentelemetry/instrumentation/OtelLog4j2.java @@ -0,0 +1,86 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.opentelemetry.instrumentation; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.core.LoggerContext; + +import io.jooby.Jooby; +import io.jooby.opentelemetry.OtelExtension; +import io.opentelemetry.api.OpenTelemetry; +import io.opentelemetry.instrumentation.log4j.appender.v2_17.OpenTelemetryAppender; + +/** + * OpenTelemetry extension for Log4j2. + * + *

This extension automatically instruments the Log4j2 logging framework by dynamically attaching + * an {@link OpenTelemetryAppender} to the root logger. This ensures that all application logs are + * seamlessly exported to your OpenTelemetry backend, automatically correlated with active trace and + * span IDs. + * + *

Required Dependency

+ * + *

To use this extension, you must add the official OpenTelemetry Log4j2 appender instrumentation + * library to your project's classpath: + * + *

{@code
+ * 
+ * io.opentelemetry.instrumentation
+ * opentelemetry-log4j-appender-2.17
+ * 
+ * }
+ * + *

Usage

+ * + *

Register this extension inside the core {@code OtelModule} during application setup: + * + *

{@code
+ * {
+ * install(new OtelModule(
+ * new OtelLog4j2()
+ * ));
+ * }
+ * }
+ * + *

Runtime Requirements

+ * + *

This extension requires {@code log4j-core} to be present at runtime to function correctly. It + * accesses the underlying {@link LoggerContext} to dynamically inject the appender. If the + * application is routing logs through a different backend (e.g., Logback or SimpleLogger), this + * extension will gracefully fail and log a warning without crashing the application. + * + * @since 4.3.1 + * @author edgar + */ +public class OtelLog4j2 implements OtelExtension { + + @Override + public void install(Jooby application, OpenTelemetry openTelemetry) { + var currentContext = LogManager.getContext(application.getClassLoader(), false); + + if (currentContext instanceof LoggerContext loggerContext) { + var config = loggerContext.getConfiguration(); + + var otelAppender = + OpenTelemetryAppender.builder() + .setName("OpenTelemetry") + .setOpenTelemetry(openTelemetry) + .build(); + + otelAppender.start(); + config.addAppender(otelAppender); + + config.getRootLogger().addAppender(otelAppender, null, null); + loggerContext.updateLoggers(); + } else { + application + .getLog() + .warn( + "Log4j2OpenTelemetry requires log4j-core. Current context is: {}", + currentContext.getClass().getName()); + } + } +} diff --git a/modules/jooby-opentelemetry/src/main/java/io/jooby/opentelemetry/instrumentation/OtelLogback.java b/modules/jooby-opentelemetry/src/main/java/io/jooby/opentelemetry/instrumentation/OtelLogback.java new file mode 100644 index 0000000000..69b2abf183 --- /dev/null +++ b/modules/jooby-opentelemetry/src/main/java/io/jooby/opentelemetry/instrumentation/OtelLogback.java @@ -0,0 +1,86 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.opentelemetry.instrumentation; + +import org.slf4j.LoggerFactory; + +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.LoggerContext; +import io.jooby.Jooby; +import io.jooby.opentelemetry.OtelExtension; +import io.opentelemetry.api.OpenTelemetry; +import io.opentelemetry.instrumentation.logback.appender.v1_0.OpenTelemetryAppender; + +/** + * OpenTelemetry extension for Logback. + * + *

This extension automatically instruments the Logback logging framework by dynamically + * attaching an {@link OpenTelemetryAppender} to the root logger. This ensures that all application + * logs are seamlessly exported to your OpenTelemetry backend, automatically correlated with active + * trace and span IDs. + * + *

Required Dependency

+ * + *

To use this extension, you must add the official OpenTelemetry Logback appender + * instrumentation library to your project's classpath: + * + *

{@code
+ * 
+ * io.opentelemetry.instrumentation
+ * opentelemetry-logback-appender-1.0
+ * 
+ * }
+ * + *

Usage

+ * + *

Register this extension inside the core {@code OtelModule} during application setup: + * + *

{@code
+ * {
+ * install(new OtelModule(
+ * new OtelLogback()
+ * ));
+ * }
+ * }
+ * + *

Runtime Requirements

+ * + *

This extension requires Logback to be the active SLF4J binding at runtime. It verifies that + * the underlying factory is a {@link LoggerContext} before injecting the appender. If the + * application routes logs through a different backend (e.g., SimpleLogger, Log4j2), this extension + * will safely bypass installation and log a warning. + * + * @since 4.3.1 + * @author edgar + */ +public class OtelLogback implements OtelExtension { + + @Override + public void install(Jooby application, OpenTelemetry openTelemetry) { + var loggerFactory = LoggerFactory.getILoggerFactory(); + + // Ensure we are actually running Logback before casting + if (loggerFactory instanceof LoggerContext loggerContext) { + var otelAppender = new OpenTelemetryAppender(); + otelAppender.setName("OpenTelemetry"); + otelAppender.setContext(loggerContext); + otelAppender.setOpenTelemetry(openTelemetry); + + // Start the appender + otelAppender.start(); + + // Attach it to the Root Logger so it catches everything + var rootLogger = loggerContext.getLogger(Logger.ROOT_LOGGER_NAME); + rootLogger.addAppender(otelAppender); + } else { + application + .getLog() + .warn( + "LogbackOpenTelemetry requires Logback. Current factory: {}", + loggerFactory.getClass().getName()); + } + } +} diff --git a/modules/jooby-opentelemetry/src/main/java/io/jooby/opentelemetry/instrumentation/OtelQuartz.java b/modules/jooby-opentelemetry/src/main/java/io/jooby/opentelemetry/instrumentation/OtelQuartz.java new file mode 100644 index 0000000000..761a3f2158 --- /dev/null +++ b/modules/jooby-opentelemetry/src/main/java/io/jooby/opentelemetry/instrumentation/OtelQuartz.java @@ -0,0 +1,68 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.opentelemetry.instrumentation; + +import org.quartz.Scheduler; + +import io.jooby.Jooby; +import io.jooby.opentelemetry.OtelExtension; +import io.opentelemetry.api.OpenTelemetry; +import io.opentelemetry.instrumentation.quartz.v2_0.QuartzTelemetry; + +/** + * OpenTelemetry extension for the Quartz scheduler. + * + *

This extension automatically instruments the Quartz {@link Scheduler} registered within the + * Jooby application. It tracks the execution of all Quartz jobs, creating individual spans for each + * execution to monitor scheduling delays, execution durations, and potential failures. + * + *

Required Dependency

+ * + *

To use this extension, you must add the official OpenTelemetry Quartz instrumentation library + * to your project's classpath: + * + *

{@code
+ * 
+ * io.opentelemetry.instrumentation
+ * opentelemetry-quartz-2.0
+ * 
+ * }
+ * + *

Installation Order

+ * + *

The {@code OtelModule} should be installed alongside the Jooby {@code QuartzModule}. + * + *

{@code
+ * {
+ * // 1. Install OpenTelemetry with the Quartz extension FIRST
+ * install(new OtelModule(new OtelQuartz()));
+ *
+ * // 2. Install QuartzModule NEXT
+ * install(new QuartzModule(MyJobs.class));
+ * }
+ * }
+ * + *

Lifecycle Note: Although {@code OtelModule} is installed first, this extension defers + * its execution to the application's {@code onStarting} lifecycle hook. This ensures that the + * {@link Scheduler} configured by the {@code QuartzModule} is fully initialized and available in + * the service registry before the OpenTelemetry listener is attached to it. + * + * @since 4.3.1 + * @author edgar + */ +public class OtelQuartz implements OtelExtension { + + @Override + public void install(Jooby application, OpenTelemetry openTelemetry) throws Exception { + var scheduler = application.require(Scheduler.class); + + // Build the official OTel listener + var quartzTelemetry = QuartzTelemetry.builder(openTelemetry).build(); + quartzTelemetry.configure(scheduler); + + application.getLog().debug("OpenTelemetry Quartz JobListener installed."); + } +} diff --git a/modules/jooby-opentelemetry/src/main/java/io/jooby/opentelemetry/instrumentation/OtelServerMetrics.java b/modules/jooby-opentelemetry/src/main/java/io/jooby/opentelemetry/instrumentation/OtelServerMetrics.java new file mode 100644 index 0000000000..1afd8653c8 --- /dev/null +++ b/modules/jooby-opentelemetry/src/main/java/io/jooby/opentelemetry/instrumentation/OtelServerMetrics.java @@ -0,0 +1,296 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.opentelemetry.instrumentation; + +import org.eclipse.jetty.server.ServerConnector; +import org.eclipse.jetty.util.thread.QueuedThreadPool; + +import io.jooby.Jooby; +import io.jooby.Server; +import io.jooby.netty.NettyEventLoopGroup; +import io.jooby.opentelemetry.OtelExtension; +import io.opentelemetry.api.OpenTelemetry; +import io.opentelemetry.api.metrics.Meter; + +/** + * OpenTelemetry extension for Jooby HTTP servers. + * + *

This extension automatically detects the underlying HTTP server running your Jooby application + * (Jetty, Netty, or Undertow) and exports native, server-specific operational metrics to your + * OpenTelemetry backend under the {@code io.jooby.server} meter. + * + *

Supported Servers & Metrics

+ * + *

Netty/Vert.x

+ * + *
    + *
  • {@code server.netty.eventloop.pending_tasks} / {@code count}: Tracks IO event loop threads + * and pending tasks. High pending tasks often indicate blocking code on the event loop. + *
  • {@code server.netty.acceptor.count}: Tracks dedicated TCP acceptor threads. + *
  • {@code server.netty.worker.*}: Tracks active threads, queue sizes, and pending tasks in the + * worker executor. + *
  • {@code server.netty.memory.direct_used} / {@code heap_used}: Tracks ByteBufAllocator memory + * consumption. + *
+ * + *

Jetty

+ * + *
    + *
  • {@code server.jetty.threads.active} / {@code idle}: Tracks the state of the underlying + * {@link QueuedThreadPool}. + *
  • {@code server.jetty.queue.size}: Tracks jobs queued waiting for an available Jetty thread. + *
  • {@code server.jetty.connections.active}: Tracks active TCP connections across all server + * connectors. + *
+ * + *

Undertow

+ * + *
    + *
  • {@code server.undertow.worker.threads.active} / {@code queue.size}: Tracks the XNIO worker + * pool capacity and backlog. + *
  • {@code server.undertow.eventloop.count}: Tracks active IO (Event Loop) threads managed by + * XNIO. + *
  • {@code server.undertow.connections.active}: Tracks active connections across all Undertow + * listeners. + *
+ * + *

Usage

+ * + *

Register this extension inside the core {@code OtelModule} during application setup: + * + *

{@code
+ * {
+ * install(new OtelModule(
+ * new OtelServerMetrics()
+ * ));
+ * }
+ * }
+ * + * @since 4.3.1 + * @author edgar + */ +public class OtelServerMetrics implements OtelExtension { + + @Override + public void install(Jooby application, OpenTelemetry openTelemetry) { + + var server = application.require(Server.class); + var meter = openTelemetry.getMeter("io.jooby.server"); + + // Route the instrumentation based on the active server + switch (server.getName().toLowerCase()) { + case "jetty": + instrumentJetty(application, meter); + break; + case "netty", "vertx": + instrumentNetty(application, meter); + break; + case "undertow": + instrumentUndertow(application, meter); + break; + default: + application + .getLog() + .debug("No specific OTel metrics mapped for server: {}", server.getName()); + } + } + + private void instrumentJetty(Jooby application, Meter meter) { + var jettyServer = application.require(org.eclipse.jetty.server.Server.class); + + if (jettyServer.getThreadPool() instanceof QueuedThreadPool threadPool) { + meter + .gaugeBuilder("server.jetty.threads.active") + .setDescription("Number of active (busy) threads in Jetty pool") + .setUnit("{thread}") + .buildWithCallback(m -> m.record(threadPool.getBusyThreads())); + + meter + .gaugeBuilder("server.jetty.threads.idle") + .setDescription("Number of idle threads in Jetty pool") + .setUnit("{thread}") + .buildWithCallback(m -> m.record(threadPool.getIdleThreads())); + + meter + .gaugeBuilder("server.jetty.queue.size") + .setDescription("Number of jobs queued waiting for a Jetty thread") + .setUnit("{job}") + .buildWithCallback(m -> m.record(threadPool.getQueueSize())); + } + + meter + .gaugeBuilder("server.jetty.connections.active") + .setDescription("Number of active TCP connections to Jetty") + .setUnit("{connection}") + .buildWithCallback( + m -> { + long totalConnections = 0; + for (var connector : jettyServer.getConnectors()) { + if (connector instanceof ServerConnector serverConnector) { + totalConnections += serverConnector.getConnectedEndPoints().size(); + } + } + m.record(totalConnections); + }); + } + + private void instrumentNetty(Jooby application, Meter meter) { + var nettyGroups = application.require(NettyEventLoopGroup.class); + // --- 1. EVENT LOOP (IO / CHILD) METRICS --- + meter + .gaugeBuilder("server.netty.eventloop.pending_tasks") + .setDescription( + "Number of pending tasks in Netty IO event loops. High numbers indicate blocking code.") + .setUnit("{task}") + .buildWithCallback( + m -> { + long totalPending = 0; + for (var eventExecutor : nettyGroups.eventLoop()) { + if (eventExecutor + instanceof io.netty.util.concurrent.SingleThreadEventExecutor stee) { + totalPending += stee.pendingTasks(); + } + } + m.record(totalPending); + }); + + meter + .gaugeBuilder("server.netty.eventloop.count") + .setDescription("Number of active Netty IO event loop threads") + .setUnit("{thread}") + .buildWithCallback( + m -> { + long count = 0; + for (var ignored : nettyGroups.eventLoop()) { + count++; + } + m.record(count); + }); + + // --- 2. ACCEPTOR METRICS --- + // Safely verify the acceptor exists AND is a distinct pool from the EventLoop + if (nettyGroups.acceptor() != nettyGroups.eventLoop()) { + meter + .gaugeBuilder("server.netty.acceptor.count") + .setDescription("Number of active acceptor threads handling TCP connections") + .setUnit("{thread}") + .buildWithCallback( + m -> { + long count = 0; + for (var ignored : nettyGroups.acceptor()) count++; + m.record(count); + }); + } + + // --- 3. WORKER EXECUTOR METRICS --- + var worker = nettyGroups.worker(); + + if (worker instanceof java.util.concurrent.ThreadPoolExecutor threadPool) { + meter + .gaugeBuilder("server.netty.worker.threads.active") + .setDescription("Number of active threads in the Java worker pool") + .setUnit("{thread}") + .buildWithCallback(m -> m.record(threadPool.getActiveCount())); + + meter + .gaugeBuilder("server.netty.worker.queue.size") + .setDescription("Number of tasks queued waiting for a Java worker thread") + .setUnit("{task}") + .buildWithCallback(m -> m.record(threadPool.getQueue().size())); + + // Scenario B: Worker is a native Netty DefaultEventExecutorGroup + } else if (worker instanceof io.netty.util.concurrent.EventExecutorGroup nettyExecutor) { + meter + .gaugeBuilder("server.netty.worker.pending_tasks") + .setDescription("Number of pending tasks in the Netty EventExecutorGroup") + .setUnit("{task}") + .buildWithCallback( + m -> { + long totalPending = 0; + for (var executor : nettyExecutor) { + if (executor instanceof io.netty.util.concurrent.SingleThreadEventExecutor stee) { + totalPending += stee.pendingTasks(); + } + } + m.record(totalPending); + }); + + meter + .gaugeBuilder("server.netty.worker.threads.count") + .setDescription("Number of active Netty worker threads") + .setUnit("{thread}") + .buildWithCallback( + m -> { + long count = 0; + for (var ignored : nettyExecutor) count++; + m.record(count); + }); + } + + // --- 4. GLOBAL MEMORY METRICS --- + var allocator = application.require(io.netty.buffer.ByteBufAllocator.class); + + if (allocator instanceof io.netty.buffer.ByteBufAllocatorMetricProvider metricProvider) { + var metric = metricProvider.metric(); + meter + .gaugeBuilder("server.netty.memory.direct_used") + .setDescription("Used direct memory by Netty ByteBufAllocator") + .setUnit("By") + .buildWithCallback(m -> m.record(metric.usedDirectMemory())); + + meter + .gaugeBuilder("server.netty.memory.heap_used") + .setDescription("Used heap memory by Netty ByteBufAllocator") + .setUnit("By") + .buildWithCallback(m -> m.record(metric.usedHeapMemory())); + } + } + + private void instrumentUndertow(Jooby application, Meter meter) { + var undertow = application.require(io.undertow.Undertow.class); + var worker = undertow.getWorker(); + + // Extract the public management bean to read the thread states safely + var mxBean = worker.getMXBean(); + + // 1. Worker Pool Metrics + meter + .gaugeBuilder("server.undertow.worker.threads.active") + .setDescription("Number of active task threads in the XNIO worker pool") + .setUnit("{thread}") + .buildWithCallback(m -> m.record(mxBean.getBusyWorkerThreadCount())); + + meter + .gaugeBuilder("server.undertow.worker.queue.size") + .setDescription("Number of tasks queued in the XNIO worker") + .setUnit("{task}") + .buildWithCallback(m -> m.record(mxBean.getWorkerQueueSize())); + + // 2. Event Loop (IO Thread) Count + meter + .gaugeBuilder("server.undertow.eventloop.count") + .setDescription("Number of active IO (Event Loop) threads managed by XNIO") + .setUnit("{thread}") + .buildWithCallback(m -> m.record(mxBean.getIoThreadCount())); + + // 3. Event Loop Load (Via Connector Statistics) + meter + .gaugeBuilder("server.undertow.connections.active") + .setDescription("Active connections being managed by the Undertow event loops") + .setUnit("{connection}") + .buildWithCallback( + m -> { + long activeConnections = 0; + for (var listener : undertow.getListenerInfo()) { + var stats = listener.getConnectorStatistics(); + if (stats != null) { + activeConnections += stats.getActiveConnections(); + } + } + m.record(activeConnections); + }); + } +} diff --git a/modules/jooby-opentelemetry/src/main/java/io/jooby/opentelemetry/package-info.java b/modules/jooby-opentelemetry/src/main/java/io/jooby/opentelemetry/package-info.java new file mode 100644 index 0000000000..6b67e54c73 --- /dev/null +++ b/modules/jooby-opentelemetry/src/main/java/io/jooby/opentelemetry/package-info.java @@ -0,0 +1,105 @@ +/** + * OpenTelemetry module for Jooby. + * + *

This module integrates OpenTelemetry into your Jooby application, providing the foundational + * engine for distributed tracing, metrics, and log correlation. It handles the lifecycle of the + * {@link io.opentelemetry.api.OpenTelemetry} SDK and registers the SDK, the default {@link + * io.opentelemetry.api.trace.Tracer}, and the fluent {@link io.jooby.opentelemetry.Trace} utility + * into the Jooby application services. + * + *

Important: Installation Order

+ * + *

Because this module bootstraps the core telemetry engine and registers the OpenTelemetry + * instance into the application services, it must be installed at the very + * beginning of your application setup. Installing it early ensures that all subsequent + * routes, filters, and extensions have immediate access to the tracer and metric instruments. + * + *

Usage

+ * + *

Install the module into your application, passing any specific OpenTelemetry extensions you + * want to enable. To automatically trace HTTP requests, you must also append the {@code + * OtelHttpTracing} filter to your routing pipeline: + * + *

{@code
+ * {
+ * // 1. Install the core engine FIRST
+ * install(new OtelModule(
+ * new OtelLogback(),       // Injects Trace IDs into application logs
+ * new OtelServerMetrics(), // Exports HTTP server metrics (e.g., Netty, Undertow, Jetty)
+ * new OtelHikari()         // Traces database connection pools
+ * ));
+ *
+ * // 2. Add the tracing filter to the routing pipeline
+ * use(new OtelHttpTracing());
+ *
+ * // 3. Define routes
+ * get("/books", ctx -> "List of books");
+ * }
+ * }
+ * + *

Route Tracing (OtelHttpTracing)

+ * + *

While {@code OtelModule} bootstraps the core OpenTelemetry engine, it does not automatically + * trace web requests. You must explicitly include {@code OtelHttpTracing}. + * + *

Note that {@code OtelHttpTracing} is not an {@link + * io.jooby.opentelemetry.OtelExtension}; it is a native Jooby {@code Route.Filter}. It must be + * installed directly into the application's routing pipeline (e.g., via {@code use()}) to + * intercept, create, and propagate spans for incoming HTTP requests. + * + *

Manual Tracing

+ * + *

For tracing specific business logic, database queries, or external API calls, this module + * provides a fluent {@link io.jooby.opentelemetry.Trace} utility. You can retrieve it from the + * route context or inject it directly into your service layer to safely create, configure, and + * execute custom spans without risking context leaks. + * + *

{@code
+ * get("/books/{isbn}", ctx -> {
+ *   Trace trace = ctx.require(Trace.class);
+ *   String isbn = ctx.path("isbn").value();
+ *   return trace.span("fetch_book")
+ *        .attribute("isbn", isbn)
+ *        .execute(span -> {
+ *           span.addEvent("Executing database query");
+ *           return repository.findByIsbn(isbn);
+ *         });
+ * });
+ * }
+ * + *

Configuration

+ * + *

The OpenTelemetry SDK is configured directly from your application's {@code application.conf}. + * Any property defined inside the {@code otel} block is automatically extracted and used to + * configure the underlying SDK components, such as exporters, protocols, and service attributes. + * + *

{@code
+ * otel {
+ * service.name = "jooby-api"
+ * traces.exporter = otlp
+ * metrics.exporter = otlp
+ * logs.exporter = otlp
+ * exporter.otlp.protocol = grpc
+ * exporter.otlp.endpoint = "http://localhost:4317"
+ * }
+ * }
+ * + *

If no {@code otel} configuration block is present in the application configuration, the module + * will fall back to a baseline, default SDK. + * + *

Extensions Lifecycle

+ * + *

Additional OpenTelemetry integrations (such as logging appenders or connection pool metrics) + * are provided via {@link io.jooby.opentelemetry.OtelExtension} implementations. These extensions + * are not executed immediately upon module installation. + * + *

Instead, the module defers their execution by registering them to the application's {@code + * onStarting} lifecycle hook. This guarantees that the primary OpenTelemetry SDK is fully + * constructed, configured, and registered before any secondary extensions attempt to hook into it + * or emit telemetry data. + * + * @since 4.3.1 + * @author edgar + */ +@edu.umd.cs.findbugs.annotations.ReturnValuesAreNonnullByDefault +package io.jooby.opentelemetry; diff --git a/modules/jooby-opentelemetry/src/main/java/module-info.java b/modules/jooby-opentelemetry/src/main/java/module-info.java new file mode 100644 index 0000000000..75c94b0b06 --- /dev/null +++ b/modules/jooby-opentelemetry/src/main/java/module-info.java @@ -0,0 +1,152 @@ +/** + * OpenTelemetry module for Jooby. + * + *

This module integrates OpenTelemetry into your Jooby application, providing the foundational + * engine for distributed tracing, metrics, and log correlation. It handles the lifecycle of the + * {@link io.opentelemetry.api.OpenTelemetry} SDK and registers the SDK, the default {@link + * io.opentelemetry.api.trace.Tracer}, and the fluent {@link io.jooby.opentelemetry.Trace} utility + * into the Jooby application services. + * + *

Important: Installation Order

+ * + *

Because this module bootstraps the core telemetry engine and registers the OpenTelemetry + * instance into the application services, it must be installed at the very + * beginning of your application setup. Installing it early ensures that all subsequent + * routes, filters, and extensions have immediate access to the tracer and metric instruments. + * + *

Usage

+ * + *

Install the module into your application, passing any specific OpenTelemetry extensions you + * want to enable. To automatically trace HTTP requests, you must also append the {@code + * OtelHttpTracing} filter to your routing pipeline: + * + *

{@code
+ * {
+ * // 1. Install the core engine FIRST
+ * install(new OtelModule(
+ * new OtelLogback(),       // Injects Trace IDs into application logs
+ * new OtelServerMetrics(), // Exports HTTP server metrics (e.g., Netty, Undertow, Jetty)
+ * new OtelHikari()         // Traces database connection pools
+ * ));
+ *
+ * // 2. Add the tracing filter to the routing pipeline
+ * use(new OtelHttpTracing());
+ *
+ * // 3. Define routes
+ * get("/books", ctx -> "List of books");
+ * }
+ * }
+ * + *

Route Tracing (OtelHttpTracing)

+ * + *

While {@code OtelModule} bootstraps the core OpenTelemetry engine, it does not automatically + * trace web requests. You must explicitly include {@code OtelHttpTracing}. + * + *

Note that {@code OtelHttpTracing} is not an {@link + * io.jooby.opentelemetry.OtelExtension}; it is a native Jooby {@code Route.Filter}. It must be + * installed directly into the application's routing pipeline (e.g., via {@code use()}) to + * intercept, create, and propagate spans for incoming HTTP requests. + * + *

Manual Tracing

+ * + *

For tracing specific business logic, database queries, or external API calls, this module + * provides a fluent {@link io.jooby.opentelemetry.Trace} utility. You can retrieve it from the + * route context or inject it directly into your service layer to safely create, configure, and + * execute custom spans without risking context leaks. + * + *

{@code
+ * get("/books/{isbn}", ctx -> {
+ *   Trace trace = ctx.require(Trace.class);
+ *   String isbn = ctx.path("isbn").value();
+ *   return trace.span("fetch_book")
+ *        .attribute("isbn", isbn)
+ *        .execute(span -> {
+ *           span.addEvent("Executing database query");
+ *           return repository.findByIsbn(isbn);
+ *         });
+ * });
+ * }
+ * + *

Configuration

+ * + *

The OpenTelemetry SDK is configured directly from your application's {@code application.conf}. + * Any property defined inside the {@code otel} block is automatically extracted and used to + * configure the underlying SDK components, such as exporters, protocols, and service attributes. + * + *

{@code
+ * otel {
+ * service.name = "jooby-api"
+ * traces.exporter = otlp
+ * metrics.exporter = otlp
+ * logs.exporter = otlp
+ * exporter.otlp.protocol = grpc
+ * exporter.otlp.endpoint = "http://localhost:4317"
+ * }
+ * }
+ * + *

If no {@code otel} configuration block is present in the application configuration, the module + * will fall back to a baseline, default SDK. + * + *

Extensions Lifecycle

+ * + *

Additional OpenTelemetry integrations (such as logging appenders or connection pool metrics) + * are provided via {@link io.jooby.opentelemetry.OtelExtension} implementations. These extensions + * are not executed immediately upon module installation. + * + *

Instead, the module defers their execution by registering them to the application's {@code + * onStarting} lifecycle hook. This guarantees that the primary OpenTelemetry SDK is fully + * constructed, configured, and registered before any secondary extensions attempt to hook into it + * or emit telemetry data. + * + * @since 4.3.1 + * @author edgar + */ +module io.jooby.opentelemetry { + exports io.jooby.opentelemetry; + exports io.jooby.opentelemetry.instrumentation; + + requires io.jooby; + requires static com.github.spotbugs.annotations; + requires typesafe.config; + requires org.slf4j; + requires jul.to.slf4j; + requires io.opentelemetry.api; + requires io.opentelemetry.context; + requires io.opentelemetry.instrumentation.runtime_telemetry; + requires io.opentelemetry.sdk; + requires io.opentelemetry.sdk.autoconfigure; + + /* Hikari */ + requires static com.zaxxer.hikari; + requires static io.opentelemetry.instrumentation.hikaricp_3_0; + requires static java.sql; + + /* Logback */ + requires static ch.qos.logback.classic; + requires static io.opentelemetry.instrumentation.logback_appender_1_0; + /* Log4j */ + requires static io.opentelemetry.instrumentation.log4j_appender_2_17; + requires static org.apache.logging.log4j; + requires static org.apache.logging.log4j.core; + + /* Jetty */ + requires org.eclipse.jetty.server; + + /* Netty */ + requires static io.jooby.netty; + requires static io.netty.common; + requires static io.netty.buffer; + requires static io.netty.transport; + + /* Undertow */ + requires static undertow.core; + requires static xnio.api; + + /* Quartz */ + requires static org.quartz; + requires static io.opentelemetry.instrumentation.quartz_2_0; + + /* Db-Scheduler */ + requires static com.github.kagkarlsson.scheduler; + requires jakarta.inject; +} diff --git a/modules/jooby-opentelemetry/src/test/java/io/jooby/opentelemetry/OtelHttpTracingTest.java b/modules/jooby-opentelemetry/src/test/java/io/jooby/opentelemetry/OtelHttpTracingTest.java new file mode 100644 index 0000000000..1d34b687e5 --- /dev/null +++ b/modules/jooby-opentelemetry/src/test/java/io/jooby/opentelemetry/OtelHttpTracingTest.java @@ -0,0 +1,195 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.opentelemetry; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.Map; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; +import org.mockito.ArgumentCaptor; + +import io.jooby.Context; +import io.jooby.Route; +import io.jooby.Router; +import io.jooby.StatusCode; +import io.jooby.value.Value; +import io.opentelemetry.api.OpenTelemetry; +import io.opentelemetry.api.trace.SpanKind; +import io.opentelemetry.api.trace.Tracer; +import io.opentelemetry.sdk.testing.junit5.OpenTelemetryExtension; +import io.opentelemetry.sdk.trace.data.SpanData; +import io.opentelemetry.sdk.trace.data.StatusData; + +public class OtelHttpTracingTest { + + @RegisterExtension + static final OpenTelemetryExtension otelTesting = OpenTelemetryExtension.create(); + + private Context ctx; + private Route route; + private Route.Handler next; + private Router router; + + @BeforeEach + void setUp() { + ctx = mock(Context.class); + route = mock(Route.class); + next = mock(Route.Handler.class); + router = mock(Router.class); + + // Core HTTP routing mocks + when(ctx.getMethod()).thenReturn("GET"); + when(ctx.getRequestPath()).thenReturn("/api/users/123"); + when(ctx.getRoute()).thenReturn(route); + when(route.getPattern()).thenReturn("/api/users/{id}"); + when(ctx.getRouter()).thenReturn(router); + + // OpenTelemetry DI mocks (injecting the in-memory SDK) + Tracer tracer = otelTesting.getOpenTelemetry().getTracer("test-tracer"); + when(ctx.require(Tracer.class)).thenReturn(tracer); + when(ctx.require(OpenTelemetry.class)).thenReturn(otelTesting.getOpenTelemetry()); + + // Header extraction mocks + Value missingHeader = mock(Value.class); + when(missingHeader.valueOrNull()).thenReturn(null); + when(ctx.header(anyString())).thenReturn(missingHeader); + } + + @Test + void shouldTraceSuccessfulRequest() throws Throwable { + // Arrange + when(next.apply(ctx)).thenReturn("Success"); + when(ctx.getResponseCode()).thenReturn(StatusCode.OK); + + OtelHttpTracing filter = new OtelHttpTracing(); + Route.Handler wrapped = filter.apply(next); + + // Act + Object result = wrapped.apply(ctx); + + // Trigger Jooby's onComplete callback + ArgumentCaptor onCompleteCaptor = ArgumentCaptor.forClass(Route.Complete.class); + verify(ctx).onComplete(onCompleteCaptor.capture()); + onCompleteCaptor.getValue().apply(ctx); + + // Assert + assertEquals("Success", result); + verify(ctx).setAttribute(any(String.class), any()); // Verifies span was put in context + + java.util.List spans = otelTesting.getSpans(); + assertEquals(1, spans.size()); + + SpanData span = spans.get(0); + assertEquals("GET /api/users/{id}", span.getName()); + assertEquals(SpanKind.SERVER, span.getKind()); + assertEquals(StatusData.unset(), span.getStatus()); + + assertThat(span.getAttributes().asMap()) + .containsEntry( + io.opentelemetry.api.common.AttributeKey.stringKey("http.request.method"), "GET") + .containsEntry( + io.opentelemetry.api.common.AttributeKey.stringKey("url.path"), "/api/users/123") + .containsEntry( + io.opentelemetry.api.common.AttributeKey.stringKey("http.route"), "/api/users/{id}") + .containsEntry( + io.opentelemetry.api.common.AttributeKey.longKey("http.response.status_code"), 200L); + } + + @Test + void shouldRecordExceptionAndFailSpan() throws Throwable { + // Arrange + RuntimeException exception = new RuntimeException("Database timeout"); + when(next.apply(ctx)).thenThrow(exception); + when(router.errorCode(exception)).thenReturn(StatusCode.SERVER_ERROR); + + OtelHttpTracing filter = new OtelHttpTracing(); + Route.Handler wrapped = filter.apply(next); + + // Act & Assert Exception + assertThrows(RuntimeException.class, () -> wrapped.apply(ctx)); + + // Notice we do NOT trigger onComplete here because Jooby handles exception propagation, + // but the catch block in the filter records the exception immediately. + // Span.end() relies on the container eventually triggering onComplete. For the sake of the + // test, + // we manually trigger it to finalize the span state as Jooby would. + when(ctx.getResponseCode()).thenReturn(StatusCode.SERVER_ERROR); + ArgumentCaptor onCompleteCaptor = ArgumentCaptor.forClass(Route.Complete.class); + verify(ctx).onComplete(onCompleteCaptor.capture()); + onCompleteCaptor.getValue().apply(ctx); + + // Assert Span + java.util.List spans = otelTesting.getSpans(); + assertEquals(1, spans.size()); + + SpanData span = spans.get(0); + assertEquals(StatusData.error(), span.getStatus()); + assertEquals(1, span.getEvents().size()); + assertEquals("exception", span.getEvents().get(0).getName()); + + assertThat(span.getAttributes().asMap()) + .containsEntry( + io.opentelemetry.api.common.AttributeKey.longKey("http.response.status_code"), 500L); + } + + @Test + void shouldMarkSpanAsErrorOn500StatusCode() throws Throwable { + // Arrange (Code executes fine, but sets a 500 status internally) + when(next.apply(ctx)).thenReturn("Internal Failure"); + when(ctx.getResponseCode()).thenReturn(StatusCode.SERVER_ERROR); + + OtelHttpTracing filter = new OtelHttpTracing(); + Route.Handler wrapped = filter.apply(next); + + // Act + wrapped.apply(ctx); + + ArgumentCaptor onCompleteCaptor = ArgumentCaptor.forClass(Route.Complete.class); + verify(ctx).onComplete(onCompleteCaptor.capture()); + onCompleteCaptor.getValue().apply(ctx); + + // Assert + java.util.List spans = otelTesting.getSpans(); + assertEquals(1, spans.size()); + + SpanData span = spans.get(0); + assertEquals(StatusData.error(), span.getStatus()); + assertThat(span.getAttributes().asMap()) + .containsEntry( + io.opentelemetry.api.common.AttributeKey.longKey("http.response.status_code"), 500L); + } + + @Test + void joobyRequestGetterExtractsHeaders() { + // Arrange + when(ctx.headerMap()) + .thenReturn( + Map.of("traceparent", "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01")); + + Value mockHeaderValue = mock(Value.class); + when(mockHeaderValue.valueOrNull()) + .thenReturn("00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01"); + when(ctx.header("traceparent")).thenReturn(mockHeaderValue); + + // Act + Iterable keys = OtelHttpTracing.JoobyRequestGetter.INSTANCE.keys(ctx); + String headerVal = OtelHttpTracing.JoobyRequestGetter.INSTANCE.get(ctx, "traceparent"); + + // Assert + assertThat(keys).containsExactly("traceparent"); + assertEquals("00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01", headerVal); + } +} diff --git a/modules/jooby-opentelemetry/src/test/java/io/jooby/opentelemetry/OtelModuleTest.java b/modules/jooby-opentelemetry/src/test/java/io/jooby/opentelemetry/OtelModuleTest.java new file mode 100644 index 0000000000..9bc43699a0 --- /dev/null +++ b/modules/jooby-opentelemetry/src/test/java/io/jooby/opentelemetry/OtelModuleTest.java @@ -0,0 +1,87 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.opentelemetry; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import io.jooby.Jooby; +import io.jooby.ServiceRegistry; +import io.jooby.SneakyThrows; +import io.opentelemetry.api.OpenTelemetry; +import io.opentelemetry.api.trace.Tracer; + +@ExtendWith(MockitoExtension.class) +class OtelModuleTest { + + @Mock private Jooby application; + + @Mock private ServiceRegistry services; + + // 1. DO NOT use @Mock here. Use the official Noop implementation! + private final OpenTelemetry openTelemetry = OpenTelemetry.noop(); + + // 2. Extract the noop tracer so we can verify it gets registered + private final Tracer tracer = openTelemetry.getTracer("io.jooby.opentelemetry"); + + @BeforeEach + void setUp() { + // 3. We no longer need any MeterBuilder or Metric mocks. + // The Noop implementation handles all of that safely under the hood. + when(application.getServices()).thenReturn(services); + } + + @Test + @DisplayName("Should register OpenTelemetry and Tracer into Jooby services") + void shouldRegisterServices() { + OtelModule module = new OtelModule(openTelemetry); + module.install(application); + + verify(services).put(OpenTelemetry.class, openTelemetry); + verify(services).put(Tracer.class, tracer); + } + + @Test + @DisplayName("Should register RuntimeTelemetry onStop hook") + void shouldRegisterOnStopHooks() { + OtelModule module = new OtelModule(openTelemetry); + module.install(application); + + // Verify that application.onStop is called with the RuntimeTelemetry auto-closeable + verify(application).onStop(any(AutoCloseable.class)); + } + + @Test + @DisplayName("Should trigger nested extensions on application start") + void shouldTriggerExtensionsOnStarting() throws Exception { + OtelExtension mockExtension = mock(OtelExtension.class); + OtelModule module = new OtelModule(openTelemetry, mockExtension); + + // Capture the Runnable passed to application.onStarting + ArgumentCaptor runnableCaptor = + ArgumentCaptor.forClass(SneakyThrows.Runnable.class); + when(application.onStarting(runnableCaptor.capture())).thenReturn(application); + + module.install(application); + + // Execute the captured Runnable (simulating Jooby starting) + SneakyThrows.Runnable startingTask = runnableCaptor.getValue(); + startingTask.run(); + + // Verify the nested extension was executed with the correct application and OTel instance + verify(mockExtension).install(application, openTelemetry); + } +} diff --git a/modules/jooby-opentelemetry/src/test/java/io/jooby/opentelemetry/TraceTest.java b/modules/jooby-opentelemetry/src/test/java/io/jooby/opentelemetry/TraceTest.java new file mode 100644 index 0000000000..9fffe54692 --- /dev/null +++ b/modules/jooby-opentelemetry/src/test/java/io/jooby/opentelemetry/TraceTest.java @@ -0,0 +1,184 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.opentelemetry; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.opentelemetry.api.common.AttributeKey; +import io.opentelemetry.api.trace.SpanKind; +import io.opentelemetry.api.trace.StatusCode; +import io.opentelemetry.api.trace.Tracer; +import io.opentelemetry.sdk.testing.junit5.OpenTelemetryExtension; +import io.opentelemetry.sdk.trace.data.SpanData; +import io.opentelemetry.sdk.trace.data.StatusData; + +public class TraceTest { + + @RegisterExtension + static final OpenTelemetryExtension otelTesting = OpenTelemetryExtension.create(); + + private Trace trace; + + @BeforeEach + void setUp() { + // Clear any spans from previous tests + otelTesting.clearSpans(); + + // Inject the in-memory tracer + Tracer tracer = otelTesting.getOpenTelemetry().getTracer("test-tracer"); + trace = new Trace(tracer); + } + + @Test + void shouldExecuteSpanTaskAndReturnResult() throws Exception { + // Arrange + AttributeKey customKey = AttributeKey.stringKey("custom.typed"); + + // Act + String result = + trace + .span("db_query") + .attribute("str.key", "value") + .attribute("long.key", 42L) + .attribute("double.key", 3.14) + .attribute("bool.key", true) + .attribute(customKey, "typed-value") + .kind(SpanKind.CLIENT) + .rootContext() + .execute( + span -> { + span.addEvent("executing statement"); + return "success"; + }); + + // Assert Result + assertEquals("success", result); + + // Assert Span Data + java.util.List spans = otelTesting.getSpans(); + assertEquals(1, spans.size()); + SpanData spanData = spans.get(0); + + assertEquals("db_query", spanData.getName()); + assertEquals(SpanKind.CLIENT, spanData.getKind()); + assertFalse( + spanData.getParentSpanContext().isValid(), "Span should have no parent (rootContext)"); + assertEquals(StatusData.unset(), spanData.getStatus()); + + assertEquals(1, spanData.getEvents().size()); + assertEquals("executing statement", spanData.getEvents().get(0).getName()); + + assertThat(spanData.getAttributes().asMap()) + .containsEntry(AttributeKey.stringKey("str.key"), "value") + .containsEntry(AttributeKey.longKey("long.key"), 42L) + .containsEntry(AttributeKey.doubleKey("double.key"), 3.14) + .containsEntry(AttributeKey.booleanKey("bool.key"), true) + .containsEntry(customKey, "typed-value"); + } + + @Test + void shouldExecuteSpanRunnableAndCloseSafely() throws Exception { + // Act + trace + .span("background_job") + .run( + span -> { + span.addEvent("job started"); + }); + + // Assert + java.util.List spans = otelTesting.getSpans(); + assertEquals(1, spans.size()); + SpanData spanData = spans.get(0); + + assertEquals("background_job", spanData.getName()); + assertEquals(SpanKind.INTERNAL, spanData.getKind()); // Default OTel kind + assertEquals(StatusData.unset(), spanData.getStatus()); + } + + @Test + void shouldRecordExceptionAndFailSpanInTask() { + // Act & Assert Exception Thrown + assertThatThrownBy( + () -> { + trace + .span("failing_task") + .execute( + span -> { + throw new IllegalStateException("Database connection failed"); + }); + }) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Database connection failed"); + + // Assert Span Data + java.util.List spans = otelTesting.getSpans(); + assertEquals(1, spans.size()); + SpanData spanData = spans.get(0); + + assertEquals("failing_task", spanData.getName()); + // Verifies status code and message were set correctly + assertEquals( + StatusData.create(StatusCode.ERROR, "Database connection failed"), spanData.getStatus()); + + // Verifies recordException(t) was called + assertEquals(1, spanData.getEvents().size()); + assertEquals("exception", spanData.getEvents().get(0).getName()); + } + + @Test + void shouldRecordExceptionWithNullMessage() { + // Act & Assert Exception Thrown + assertThatThrownBy( + () -> { + trace + .span("npe_task") + .run( + (Trace.SpanRunnable) + span -> { + throw new NullPointerException(); // NPEs typically have a null message + }); + }) + .isInstanceOf(NullPointerException.class); + + // Assert Span Data + java.util.List spans = otelTesting.getSpans(); + assertEquals(1, spans.size()); + SpanData spanData = spans.get(0); + + // Verifies fallback to class name when exception message is null + assertEquals( + StatusData.create(StatusCode.ERROR, "java.lang.NullPointerException"), + spanData.getStatus()); + } + + @Test + void shouldAllowUnderlyingConfigurationViaEscapeHatch() throws Exception { + // Act + trace + .span("configured_task") + .configure(builder -> builder.setAttribute("hatch.attr", "opened")) + .run( + span -> { + // Do nothing + }); + + // Assert + java.util.List spans = otelTesting.getSpans(); + assertEquals(1, spans.size()); + SpanData spanData = spans.get(0); + + assertThat(spanData.getAttributes().asMap()) + .containsEntry(AttributeKey.stringKey("hatch.attr"), "opened"); + } +} diff --git a/modules/jooby-opentelemetry/src/test/java/io/jooby/opentelemetry/instrumentation/OtelDbSchedulerTest.java b/modules/jooby-opentelemetry/src/test/java/io/jooby/opentelemetry/instrumentation/OtelDbSchedulerTest.java new file mode 100644 index 0000000000..0641318cd3 --- /dev/null +++ b/modules/jooby-opentelemetry/src/test/java/io/jooby/opentelemetry/instrumentation/OtelDbSchedulerTest.java @@ -0,0 +1,171 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.opentelemetry.instrumentation; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import com.github.kagkarlsson.scheduler.event.ExecutionChain; +import com.github.kagkarlsson.scheduler.task.CompletionHandler; +import com.github.kagkarlsson.scheduler.task.ExecutionContext; +import com.github.kagkarlsson.scheduler.task.TaskInstance; +import io.opentelemetry.api.common.AttributeKey; +import io.opentelemetry.api.trace.SpanKind; +import io.opentelemetry.api.trace.StatusCode; +import io.opentelemetry.sdk.testing.junit5.OpenTelemetryExtension; +import io.opentelemetry.sdk.trace.data.SpanData; +import io.opentelemetry.sdk.trace.data.StatusData; + +public class OtelDbSchedulerTest { + + @RegisterExtension + static final OpenTelemetryExtension otelTesting = OpenTelemetryExtension.create(); + + private TaskInstance taskInstance; + private ExecutionContext executionContext; + private ExecutionChain chain; + private CompletionHandler completionHandler; + + private OtelDbScheduler interceptor; + + @BeforeEach + void setUp() { + otelTesting.clearSpans(); + otelTesting.clearMetrics(); + + taskInstance = mock(TaskInstance.class); + executionContext = mock(ExecutionContext.class); + chain = mock(ExecutionChain.class); + completionHandler = mock(CompletionHandler.class); + + when(taskInstance.getTaskName()).thenReturn("nightly-sync"); + when(taskInstance.getId()).thenReturn("sync-id-1234"); + + // Initialize the interceptor using the in-memory OpenTelemetry SDK + interceptor = new OtelDbScheduler(otelTesting.getOpenTelemetry()); + } + + @Test + void shouldTraceAndRecordMetricsOnSuccess() { + // Arrange + when(chain.proceed(taskInstance, executionContext)).thenAnswer(invocation -> completionHandler); + + // Act + Object result = interceptor.execute(taskInstance, executionContext, chain); + + // Assert Execution + assertEquals(completionHandler, result); + verify(chain).proceed(taskInstance, executionContext); + + // Assert Span + List spans = otelTesting.getSpans(); + assertEquals(1, spans.size()); + SpanData span = spans.get(0); + + assertEquals("Job nightly-sync", span.getName()); + assertEquals(SpanKind.INTERNAL, span.getKind()); + assertEquals(StatusData.unset(), span.getStatus()); + assertThat(span.getAttributes().asMap()) + .containsEntry(AttributeKey.stringKey("job.system"), "db-scheduler") + .containsEntry(AttributeKey.stringKey("job.id"), "sync-id-1234"); + + // Assert Metrics (Counter) + assertThat(otelTesting.getMetrics()) + .anySatisfy( + metric -> { + assertThat(metric.getName()).isEqualTo("dbscheduler.task.completions"); + assertThat(metric.getLongSumData().getPoints()) + .anySatisfy( + point -> { + assertThat(point.getValue()).isEqualTo(1L); + assertThat(point.getAttributes().asMap()) + .containsEntry(AttributeKey.stringKey("task"), "nightly-sync") + .containsEntry(AttributeKey.stringKey("result"), "ok"); + }); + }); + + // Assert Metrics (Histogram) + assertThat(otelTesting.getMetrics()) + .anySatisfy( + metric -> { + assertThat(metric.getName()).isEqualTo("dbscheduler.task.duration"); + assertThat(metric.getHistogramData().getPoints()) + .anySatisfy( + point -> { + assertThat(point.getCount()).isEqualTo(1L); // 1 recorded event + assertThat(point.getSum()) + .isGreaterThanOrEqualTo(0.0); // duration in seconds + assertThat(point.getAttributes().asMap()) + .containsEntry(AttributeKey.stringKey("task"), "nightly-sync") + .containsEntry(AttributeKey.stringKey("result"), "ok"); + }); + }); + } + + @Test + void shouldTraceAndRecordMetricsOnFailure() { + // Arrange + RuntimeException expectedException = new RuntimeException("Database timeout"); + when(chain.proceed(taskInstance, executionContext)).thenThrow(expectedException); + + // Act & Assert Exception + assertThatThrownBy(() -> interceptor.execute(taskInstance, executionContext, chain)) + .isInstanceOf(RuntimeException.class) + .hasMessage("Database timeout"); + + // Assert Span + List spans = otelTesting.getSpans(); + assertEquals(1, spans.size()); + SpanData span = spans.get(0); + + assertEquals("Job nightly-sync", span.getName()); + assertEquals(StatusData.create(StatusCode.ERROR, ""), span.getStatus()); + + // Ensure exception was recorded as a span event + assertEquals(1, span.getEvents().size()); + assertEquals("exception", span.getEvents().get(0).getName()); + + // Assert Metrics (Counter marked as failed) + assertThat(otelTesting.getMetrics()) + .anySatisfy( + metric -> { + assertThat(metric.getName()).isEqualTo("dbscheduler.task.completions"); + assertThat(metric.getLongSumData().getPoints()) + .anySatisfy( + point -> { + assertThat(point.getValue()).isEqualTo(1L); + assertThat(point.getAttributes().asMap()) + .containsEntry(AttributeKey.stringKey("task"), "nightly-sync") + .containsEntry(AttributeKey.stringKey("result"), "failed"); + }); + }); + + // Assert Metrics (Histogram recorded despite failure) + assertThat(otelTesting.getMetrics()) + .anySatisfy( + metric -> { + assertThat(metric.getName()).isEqualTo("dbscheduler.task.duration"); + assertThat(metric.getHistogramData().getPoints()) + .anySatisfy( + point -> { + assertThat(point.getCount()).isEqualTo(1L); + assertThat(point.getAttributes().asMap()) + .containsEntry(AttributeKey.stringKey("task"), "nightly-sync") + .containsEntry(AttributeKey.stringKey("result"), "failed"); + }); + }); + } +} diff --git a/modules/jooby-opentelemetry/src/test/java/io/jooby/opentelemetry/instrumentation/OtelHikariTest.java b/modules/jooby-opentelemetry/src/test/java/io/jooby/opentelemetry/instrumentation/OtelHikariTest.java new file mode 100644 index 0000000000..51b02eeee1 --- /dev/null +++ b/modules/jooby-opentelemetry/src/test/java/io/jooby/opentelemetry/instrumentation/OtelHikariTest.java @@ -0,0 +1,74 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.opentelemetry.instrumentation; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.Arrays; +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; +import org.mockito.ArgumentCaptor; +import org.mockito.stubbing.OngoingStubbing; + +import com.zaxxer.hikari.HikariDataSource; +import com.zaxxer.hikari.metrics.MetricsTrackerFactory; +import io.jooby.Jooby; +import io.jooby.Reified; +import io.opentelemetry.sdk.testing.junit5.OpenTelemetryExtension; + +public class OtelHikariTest { + + @RegisterExtension + static final OpenTelemetryExtension otelTesting = OpenTelemetryExtension.create(); + + private Jooby application; + private HikariDataSource primaryDataSource; + private HikariDataSource secondaryDataSource; + + @BeforeEach + void setUp() { + application = mock(Jooby.class); + primaryDataSource = mock(HikariDataSource.class); + secondaryDataSource = mock(HikariDataSource.class); + } + + @Test + void shouldInstrumentAllConfiguredDataSources() { + // Arrange + // Simulate a Jooby application with two separate database connections + List dataSources = Arrays.asList(primaryDataSource, secondaryDataSource); + + // Mock Jooby's Reified list resolution + OngoingStubbing> when = + when(application.require(Reified.list(HikariDataSource.class))); + when.thenReturn(dataSources); + + OtelHikari extension = new OtelHikari(); + + // Act + extension.install(application, otelTesting.getOpenTelemetry()); + + // Assert primary data source was instrumented + ArgumentCaptor captor1 = + ArgumentCaptor.forClass(MetricsTrackerFactory.class); + verify(primaryDataSource).setMetricsTrackerFactory(captor1.capture()); + assertNotNull( + captor1.getValue(), "MetricsTrackerFactory should be applied to primary data source"); + + // Assert secondary data source was instrumented + ArgumentCaptor captor2 = + ArgumentCaptor.forClass(MetricsTrackerFactory.class); + verify(secondaryDataSource).setMetricsTrackerFactory(captor2.capture()); + assertNotNull( + captor2.getValue(), "MetricsTrackerFactory should be applied to secondary data source"); + } +} diff --git a/modules/jooby-opentelemetry/src/test/java/io/jooby/opentelemetry/instrumentation/OtelLog4j2Test.java b/modules/jooby-opentelemetry/src/test/java/io/jooby/opentelemetry/instrumentation/OtelLog4j2Test.java new file mode 100644 index 0000000000..d405298555 --- /dev/null +++ b/modules/jooby-opentelemetry/src/test/java/io/jooby/opentelemetry/instrumentation/OtelLog4j2Test.java @@ -0,0 +1,119 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.opentelemetry.instrumentation; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.core.LoggerContext; +import org.apache.logging.log4j.core.config.Configuration; +import org.apache.logging.log4j.core.config.LoggerConfig; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.MockedStatic; +import org.slf4j.Logger; + +import io.jooby.Jooby; +import io.opentelemetry.api.OpenTelemetry; +import io.opentelemetry.instrumentation.log4j.appender.v2_17.OpenTelemetryAppender; + +public class OtelLog4j2Test { + + private Jooby application; + private OpenTelemetry openTelemetry; + private Logger appLogger; + private MockedStatic mockedLogManager; + + @BeforeEach + void setUp() { + application = mock(Jooby.class); + openTelemetry = mock(OpenTelemetry.class); + appLogger = mock(Logger.class); + + when(application.getClassLoader()).thenReturn(Thread.currentThread().getContextClassLoader()); + when(application.getLog()).thenReturn(appLogger); + + // Intercept the static LogManager factory for this test thread + mockedLogManager = mockStatic(LogManager.class); + } + + @AfterEach + void tearDown() { + // Crucial: Always close static mocks to prevent them from leaking into other tests + mockedLogManager.close(); + } + + @Test + void shouldInstallAppenderWhenLog4jCoreIsPresent() { + // Arrange + LoggerContext loggerContext = mock(LoggerContext.class); + Configuration configuration = mock(Configuration.class); + LoggerConfig rootLoggerConfig = mock(LoggerConfig.class); + + when(loggerContext.getConfiguration()).thenReturn(configuration); + when(configuration.getRootLogger()).thenReturn(rootLoggerConfig); + + // Force LogManager to return our mocked core context + mockedLogManager + .when(() -> LogManager.getContext(any(ClassLoader.class), anyBoolean())) + .thenReturn(loggerContext); + + OtelLog4j2 extension = new OtelLog4j2(); + + // Act + extension.install(application, openTelemetry); + + // Assert Appender Registration + ArgumentCaptor appenderCaptor = + ArgumentCaptor.forClass(OpenTelemetryAppender.class); + + // 1. Verify appender was added to the global config + verify(configuration).addAppender(appenderCaptor.capture()); + OpenTelemetryAppender appender = appenderCaptor.getValue(); + assertNotNull(appender); + assertEquals("OpenTelemetry", appender.getName()); + + // 2. Verify appender was specifically attached to the Root Logger + verify(rootLoggerConfig).addAppender(eq(appender), eq(null), eq(null)); + + // 3. Verify Log4j2 was instructed to apply the changes + verify(loggerContext).updateLoggers(); + } + + @Test + void shouldLogWarningWhenLog4jCoreIsNotPresent() { + // Arrange + // Simulate a runtime where log4j-api is present, but routing to SimpleLogger instead of + // log4j-core + org.apache.logging.log4j.spi.LoggerContext simpleContext = + mock(org.apache.logging.log4j.spi.LoggerContext.class); + + mockedLogManager + .when(() -> LogManager.getContext(any(ClassLoader.class), anyBoolean())) + .thenReturn(simpleContext); + + OtelLog4j2 extension = new OtelLog4j2(); + + // Act + extension.install(application, openTelemetry); + + // Assert + verify(appLogger) + .warn( + "Log4j2OpenTelemetry requires log4j-core. Current context is: {}", + simpleContext.getClass().getName()); + } +} diff --git a/modules/jooby-opentelemetry/src/test/java/io/jooby/opentelemetry/instrumentation/OtelLogbackTest.java b/modules/jooby-opentelemetry/src/test/java/io/jooby/opentelemetry/instrumentation/OtelLogbackTest.java new file mode 100644 index 0000000000..e63a6518c3 --- /dev/null +++ b/modules/jooby-opentelemetry/src/test/java/io/jooby/opentelemetry/instrumentation/OtelLogbackTest.java @@ -0,0 +1,102 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.opentelemetry.instrumentation; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.MockedStatic; +import org.slf4j.ILoggerFactory; +import org.slf4j.LoggerFactory; + +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.LoggerContext; +import io.jooby.Jooby; +import io.opentelemetry.api.OpenTelemetry; +import io.opentelemetry.instrumentation.logback.appender.v1_0.OpenTelemetryAppender; + +public class OtelLogbackTest { + + private Jooby application; + private OpenTelemetry openTelemetry; + private org.slf4j.Logger appLogger; + private MockedStatic mockedLoggerFactory; + + @BeforeEach + void setUp() { + application = mock(Jooby.class); + openTelemetry = mock(OpenTelemetry.class); + appLogger = mock(org.slf4j.Logger.class); + + when(application.getLog()).thenReturn(appLogger); + + // Intercept the static SLF4J LoggerFactory for this test thread + mockedLoggerFactory = mockStatic(LoggerFactory.class); + } + + @AfterEach + void tearDown() { + // Crucial: Always close static mocks to prevent them from breaking the test runner's own + // logging + mockedLoggerFactory.close(); + } + + @Test + void shouldInstallAppenderWhenLogbackIsPresent() { + // Arrange + LoggerContext loggerContext = mock(LoggerContext.class); + Logger rootLogger = mock(Logger.class); + + // Make the factory return our Logback context + mockedLoggerFactory.when(LoggerFactory::getILoggerFactory).thenReturn(loggerContext); + + // Wire up the root logger retrieval + when(loggerContext.getLogger(Logger.ROOT_LOGGER_NAME)).thenReturn(rootLogger); + + OtelLogback extension = new OtelLogback(); + + // Act + extension.install(application, openTelemetry); + + // Assert Appender Registration + ArgumentCaptor appenderCaptor = + ArgumentCaptor.forClass(OpenTelemetryAppender.class); + + verify(rootLogger).addAppender(appenderCaptor.capture()); + + OpenTelemetryAppender appender = appenderCaptor.getValue(); + assertEquals("OpenTelemetry", appender.getName()); + assertTrue( + appender.isStarted(), "The OpenTelemetryAppender should be started before being attached"); + } + + @Test + void shouldLogWarningWhenLogbackIsNotPresent() { + // Arrange + // Simulate an environment using a different SLF4J binding (like slf4j-simple) + ILoggerFactory simpleFactory = mock(ILoggerFactory.class); + mockedLoggerFactory.when(LoggerFactory::getILoggerFactory).thenReturn(simpleFactory); + + OtelLogback extension = new OtelLogback(); + + // Act + extension.install(application, openTelemetry); + + // Assert + verify(appLogger) + .warn( + "LogbackOpenTelemetry requires Logback. Current factory: {}", + simpleFactory.getClass().getName()); + } +} diff --git a/modules/jooby-opentelemetry/src/test/java/io/jooby/opentelemetry/instrumentation/OtelQuartzTest.java b/modules/jooby-opentelemetry/src/test/java/io/jooby/opentelemetry/instrumentation/OtelQuartzTest.java new file mode 100644 index 0000000000..5f3c7d1f72 --- /dev/null +++ b/modules/jooby-opentelemetry/src/test/java/io/jooby/opentelemetry/instrumentation/OtelQuartzTest.java @@ -0,0 +1,64 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.opentelemetry.instrumentation; + +import static org.mockito.Mockito.*; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; +import org.quartz.ListenerManager; +import org.quartz.Scheduler; +import org.slf4j.Logger; + +import io.jooby.Jooby; +import io.opentelemetry.sdk.testing.junit5.OpenTelemetryExtension; + +public class OtelQuartzTest { + + @RegisterExtension + static final OpenTelemetryExtension otelTesting = OpenTelemetryExtension.create(); + + private Jooby application; + private Scheduler scheduler; + private ListenerManager listenerManager; + private Logger appLogger; + + @BeforeEach + void setUp() throws Exception { + application = mock(Jooby.class); + scheduler = mock(Scheduler.class); + listenerManager = mock(ListenerManager.class); + appLogger = mock(Logger.class); + + // Mock Jooby's registry lookup + when(application.require(Scheduler.class)).thenReturn(scheduler); + when(application.getLog()).thenReturn(appLogger); + + // OTel's QuartzTelemetry requires the ListenerManager to attach its JobListener. + // If we don't mock this, quartzTelemetry.configure(scheduler) will throw an NPE. + when(scheduler.getListenerManager()).thenReturn(listenerManager); + } + + @Test + void shouldInstallQuartzTelemetryListener() throws Exception { + // Arrange + OtelQuartz extension = new OtelQuartz(); + + // Act + extension.install(application, otelTesting.getOpenTelemetry()); + + // Assert + // 1. Verify we requested the Scheduler from Jooby + verify(application).require(Scheduler.class); + + // 2. Verify OpenTelemetry actually interacted with the Quartz Scheduler to hook its listener + verify(scheduler, times(2)).getListenerManager(); + + // 3. Verify our success debug log was fired + verify(appLogger).debug("OpenTelemetry Quartz JobListener installed."); + } +} diff --git a/modules/jooby-opentelemetry/src/test/java/io/jooby/opentelemetry/instrumentation/OtelServerMetricsTest.java b/modules/jooby-opentelemetry/src/test/java/io/jooby/opentelemetry/instrumentation/OtelServerMetricsTest.java new file mode 100644 index 0000000000..fe5bc17849 --- /dev/null +++ b/modules/jooby-opentelemetry/src/test/java/io/jooby/opentelemetry/instrumentation/OtelServerMetricsTest.java @@ -0,0 +1,224 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.opentelemetry.instrumentation; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.mockito.Mockito.withSettings; + +import java.util.Collections; +import java.util.List; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.ThreadPoolExecutor; + +import org.eclipse.jetty.server.ServerConnector; +import org.eclipse.jetty.util.thread.QueuedThreadPool; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; +import org.slf4j.Logger; +import org.xnio.XnioWorker; +import org.xnio.management.XnioWorkerMXBean; + +import io.jooby.Jooby; +import io.jooby.Server; +import io.jooby.netty.NettyEventLoopGroup; +import io.netty.buffer.ByteBufAllocatorMetric; +import io.netty.buffer.ByteBufAllocatorMetricProvider; +import io.netty.channel.EventLoopGroup; +import io.netty.util.concurrent.EventExecutor; +import io.netty.util.concurrent.SingleThreadEventExecutor; +import io.opentelemetry.sdk.testing.junit5.OpenTelemetryExtension; + +public class OtelServerMetricsTest { + + @RegisterExtension + static final OpenTelemetryExtension otelTesting = OpenTelemetryExtension.create(); + + private Jooby application; + private Server server; + private Logger appLogger; + + @BeforeEach + void setUp() { + otelTesting.clearMetrics(); + + application = mock(Jooby.class); + server = mock(Server.class); + appLogger = mock(Logger.class); + + when(application.require(Server.class)).thenReturn(server); + when(application.getLog()).thenReturn(appLogger); + } + + @Test + void shouldLogDebugWhenServerIsUnknown() { + // Arrange + when(server.getName()).thenReturn("tomcat"); + OtelServerMetrics extension = new OtelServerMetrics(); + + // Act + extension.install(application, otelTesting.getOpenTelemetry()); + + // Assert + verify(appLogger).debug("No specific OTel metrics mapped for server: {}", "tomcat"); + assertThat(otelTesting.getMetrics()).isEmpty(); + } + + @Test + void shouldInstrumentJetty() { + // Arrange + when(server.getName()).thenReturn("jetty"); + + org.eclipse.jetty.server.Server jettyServer = mock(org.eclipse.jetty.server.Server.class); + QueuedThreadPool threadPool = mock(QueuedThreadPool.class); + ServerConnector connector = mock(ServerConnector.class); + + when(application.require(org.eclipse.jetty.server.Server.class)).thenReturn(jettyServer); + when(jettyServer.getThreadPool()).thenReturn(threadPool); + when(jettyServer.getConnectors()) + .thenReturn(new org.eclipse.jetty.server.Connector[] {connector}); + + // Mock Jetty Stats + when(threadPool.getBusyThreads()).thenReturn(42); + when(threadPool.getIdleThreads()).thenReturn(10); + when(threadPool.getQueueSize()).thenReturn(5); + when(connector.getConnectedEndPoints()) + .thenReturn(Collections.nCopies(100, null)); // Simulates 100 connections + + OtelServerMetrics extension = new OtelServerMetrics(); + + // Act + extension.install(application, otelTesting.getOpenTelemetry()); + + // Assert (Fetching metrics triggers the async callbacks) + assertGaugeValue("server.jetty.threads.active", 42.0); + assertGaugeValue("server.jetty.threads.idle", 10.0); + assertGaugeValue("server.jetty.queue.size", 5.0); + assertGaugeValue("server.jetty.connections.active", 100.0); + } + + @Test + @SuppressWarnings({"unchecked", "rawtypes"}) + void shouldInstrumentNetty() { + // Arrange + when(server.getName()).thenReturn("netty"); + + NettyEventLoopGroup nettyGroups = mock(NettyEventLoopGroup.class); + when(application.require(NettyEventLoopGroup.class)).thenReturn(nettyGroups); + + // --- 1. Mock Event Loop Group --- + EventLoopGroup eventLoopGroup = mock(EventLoopGroup.class); + when(nettyGroups.eventLoop()).thenReturn(eventLoopGroup); + + SingleThreadEventExecutor eventLoopExecutor = mock(SingleThreadEventExecutor.class); + when(eventLoopExecutor.pendingTasks()).thenReturn(15); + // EventLoopGroup implements Iterable + when(eventLoopGroup.iterator()) + .thenAnswer(i -> List.of(eventLoopExecutor).iterator()); + + // --- 2. Mock Acceptor Group (Different from Event Loop) --- + EventLoopGroup acceptorGroup = mock(EventLoopGroup.class); + when(nettyGroups.acceptor()).thenReturn(acceptorGroup); + + SingleThreadEventExecutor acceptorExecutor = mock(SingleThreadEventExecutor.class); + when(acceptorGroup.iterator()) + .thenAnswer(i -> List.of(acceptorExecutor).iterator()); + + // --- 3. Mock Worker (Using ThreadPoolExecutor scenario) --- + ThreadPoolExecutor workerPool = mock(ThreadPoolExecutor.class); + when(workerPool.getActiveCount()).thenReturn(30); + + // Mock the queue directly instead of trying to instantiate it with generic classes + BlockingQueue queue = mock(BlockingQueue.class); + when(queue.size()).thenReturn(7); + when(workerPool.getQueue()).thenReturn(queue); + + when(nettyGroups.worker()).thenReturn(workerPool); + + // --- 4. Mock ByteBufAllocator --- + // It must implement both ByteBufAllocator and ByteBufAllocatorMetricProvider + io.netty.buffer.ByteBufAllocator allocator = + mock( + io.netty.buffer.ByteBufAllocator.class, + withSettings().extraInterfaces(ByteBufAllocatorMetricProvider.class)); + ByteBufAllocatorMetric allocatorMetric = mock(ByteBufAllocatorMetric.class); + + when(((ByteBufAllocatorMetricProvider) allocator).metric()).thenReturn(allocatorMetric); + when(allocatorMetric.usedDirectMemory()).thenReturn(1024L); + when(allocatorMetric.usedHeapMemory()).thenReturn(2048L); + when(application.require(io.netty.buffer.ByteBufAllocator.class)).thenReturn(allocator); + + OtelServerMetrics extension = new OtelServerMetrics(); + + // Act + extension.install(application, otelTesting.getOpenTelemetry()); + + // Assert + assertGaugeValue("server.netty.eventloop.pending_tasks", 15.0); + assertGaugeValue("server.netty.eventloop.count", 1.0); + assertGaugeValue("server.netty.acceptor.count", 1.0); + assertGaugeValue("server.netty.worker.threads.active", 30.0); + assertGaugeValue("server.netty.worker.queue.size", 7.0); + assertGaugeValue("server.netty.memory.direct_used", 1024.0); + assertGaugeValue("server.netty.memory.heap_used", 2048.0); + } + + @Test + void shouldInstrumentUndertow() { + // Arrange + when(server.getName()).thenReturn("undertow"); + + io.undertow.Undertow undertow = mock(io.undertow.Undertow.class); + XnioWorker worker = mock(XnioWorker.class); + XnioWorkerMXBean mxBean = mock(XnioWorkerMXBean.class); + io.undertow.Undertow.ListenerInfo listenerInfo = mock(io.undertow.Undertow.ListenerInfo.class); + io.undertow.server.ConnectorStatistics stats = + mock(io.undertow.server.ConnectorStatistics.class); + + when(application.require(io.undertow.Undertow.class)).thenReturn(undertow); + when(undertow.getWorker()).thenReturn(worker); + when(worker.getMXBean()).thenReturn(mxBean); + when(undertow.getListenerInfo()).thenReturn(List.of(listenerInfo)); + when(listenerInfo.getConnectorStatistics()).thenReturn(stats); + + // Mock Undertow Stats + when(mxBean.getBusyWorkerThreadCount()).thenReturn(64); + when(mxBean.getWorkerQueueSize()).thenReturn(12); + when(mxBean.getIoThreadCount()).thenReturn(4); + when(stats.getActiveConnections()).thenReturn(250L); + + OtelServerMetrics extension = new OtelServerMetrics(); + + // Act + extension.install(application, otelTesting.getOpenTelemetry()); + + // Assert + assertGaugeValue("server.undertow.worker.threads.active", 64.0); + assertGaugeValue("server.undertow.worker.queue.size", 12.0); + assertGaugeValue("server.undertow.eventloop.count", 4.0); + assertGaugeValue("server.undertow.connections.active", 250.0); + } + + /** + * Helper method to locate a specific metric by name and assert its single DoubleGauge value. + * OpenTelemetry builds metrics as Doubles by default unless ofLongs() is explicitly called. + */ + private void assertGaugeValue(String metricName, double expectedValue) { + assertThat(otelTesting.getMetrics()) + .anySatisfy( + metric -> { + assertThat(metric.getName()).isEqualTo(metricName); + assertThat(metric.getDoubleGaugeData().getPoints()) + .anySatisfy( + point -> { + assertThat(point.getValue()).isEqualTo(expectedValue); + }); + }); + } +} diff --git a/modules/jooby-undertow/src/main/java/io/jooby/undertow/UndertowServer.java b/modules/jooby-undertow/src/main/java/io/jooby/undertow/UndertowServer.java index 26d1840595..fcae0a8667 100644 --- a/modules/jooby-undertow/src/main/java/io/jooby/undertow/UndertowServer.java +++ b/modules/jooby-undertow/src/main/java/io/jooby/undertow/UndertowServer.java @@ -180,8 +180,12 @@ public Server start(@NonNull Jooby... application) { } else if (options.isHttpsOnly()) { throw new StartupException("Server configured for httpsOnly, but ssl options are not set"); } - fireStart(applications, worker); server = builder.build(); + for (var app : applications) { + app.getServices().put(Undertow.class, server); + } + fireStart(applications, worker); + server.start(); // --- EXTRACT OS-ASSIGNED PORTS --- diff --git a/modules/pom.xml b/modules/pom.xml index 407359fb36..fd2c073013 100644 --- a/modules/pom.xml +++ b/modules/pom.xml @@ -111,9 +111,12 @@ jooby-rxjava3 jooby-mutiny + + jooby-metrics + jooby-opentelemetry + jooby-whoops - jooby-metrics jooby-jasypt diff --git a/pom.xml b/pom.xml index 3e0a8abcfd..7c999bfb69 100644 --- a/pom.xml +++ b/pom.xml @@ -136,6 +136,7 @@ 0.13.0 6.4.0 2.5.2 + 16.7.1 9.2.1 8.17.0 1.12.797 diff --git a/tests/pom.xml b/tests/pom.xml index 042f426878..0a09c7384a 100644 --- a/tests/pom.xml +++ b/tests/pom.xml @@ -203,6 +203,12 @@ ${jooby.version} + + io.jooby + jooby-opentelemetry + ${jooby.version} + + io.jooby jooby-test From 8eaaef3867f62754921765d1aa09130bacacb430 Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Mon, 13 Apr 2026 20:31:46 -0300 Subject: [PATCH 2/4] opentelemetry: add javadoc ref #3900 --- docs/asciidoc/modules/modules.adoc | 1 + docs/asciidoc/modules/opentelemetry.adoc | 362 +++++++++++++++++++++++ 2 files changed, 363 insertions(+) create mode 100644 docs/asciidoc/modules/opentelemetry.adoc diff --git a/docs/asciidoc/modules/modules.adoc b/docs/asciidoc/modules/modules.adoc index ba9e6ab9fe..425bcf448f 100644 --- a/docs/asciidoc/modules/modules.adoc +++ b/docs/asciidoc/modules/modules.adoc @@ -38,6 +38,7 @@ Modules are distributed as separate dependencies. Below is the catalog of offici * link:{uiVersion}/#tooling-and-operations-development[Jooby Run]: Run and hot reload your application. * link:{uiVersion}/modules/whoops[Whoops]: Pretty page stacktrace reporter. * link:{uiVersion}/modules/metrics[Metrics]: Application metrics from the excellent metrics library. + * link:{uiVersion}/modules/opentelemetry[Open Telemetry]: Application metrics using Open Telemetry library. ==== Event Bus * link:{uiVersion}/modules/camel[Camel]: Camel module for Jooby. diff --git a/docs/asciidoc/modules/opentelemetry.adoc b/docs/asciidoc/modules/opentelemetry.adoc new file mode 100644 index 0000000000..d06e6fc9b4 --- /dev/null +++ b/docs/asciidoc/modules/opentelemetry.adoc @@ -0,0 +1,362 @@ +== OpenTelemetry + +The module provides the foundational engine for distributed tracing, metrics, and log correlation in your Jooby application. Its goal is to give you deep, vendor-neutral observability into your system. By integrating the https://opentelemetry.io/[OpenTelemetry] SDK, it automatically captures and exports telemetry data from HTTP requests, database connection pools, background jobs, and application logs. + +Because https://opentelemetry.io/[OpenTelemetry] is an open standard, you are not locked into a specific vendor. You can seamlessly route your telemetry data to any compatible APM, backend, or collector (such as SigNoz, DataDog, Jaeger, or Grafana) simply by changing your configuration properties. + +=== Usage + +1) Add the dependency: + +[dependency, artifactId="jooby-opentelemetry:OpenTelemetry Module"] +. + +2) Install and use OpenTelemetry: + +.Java +[source, java, role="primary"] +---- +import io.jooby.opentelemetry.OtelModule; +import io.jooby.opentelemetry.OtelHttpTracing; + +{ + install(new OtelModule()); <1> + + use(new OtelHttpTracing()); <2> + + get("/", ctx -> { + return "Hello OTel"; + }); +} +---- + +.Kotlin +[source, kt, role="secondary"] +---- +import io.jooby.opentelemetry.OtelModule +import io.jooby.opentelemetry.OtelHttpTracing + +{ + install(OtelModule()) <1> + + use(OtelHttpTracing()) <2> + + get("/") { ctx -> + "Hello OTel" + } +} +---- + +<1> Installs the core OpenTelemetry SDK engine. It **must be installed at the very beginning** of your application setup. +<2> Adds the `OtelHttpTracing` filter to automatically intercept, create, and propagate spans for incoming HTTP requests. + +[NOTE] +==== +**JVM Metrics:** Basic JVM operational metrics (such as memory usage, garbage collection times, and active thread counts) are automatically bound and exported by default the moment `OtelModule` is installed. +==== + +=== Exporters Configuration + +The OpenTelemetry SDK is completely driven by your application's configuration properties. Any property defined inside the `otel` block in your `application.conf` is automatically picked up by the SDK's auto-configuration engine. + +Here is how you can configure the exporters to send your data to various popular backends: + +==== SigNoz (or generic OTLP) +SigNoz natively accepts the standard OTLP (OpenTelemetry Protocol) format over gRPC. + +.application.conf +[source, properties] +---- +otel { + service.name = "jooby-api" + traces.exporter = otlp + metrics.exporter = otlp + logs.exporter = otlp + exporter.otlp.protocol = grpc + exporter.otlp.endpoint = "http://localhost:4317" +} +---- + +==== DataDog +To send data to DataDog, you typically use the OTLP HTTP protocol pointing to the DataDog Agent running on your infrastructure, or directly to their intake API. + +.application.conf +[source, properties] +---- +otel { + service.name = "jooby-api" + traces.exporter = otlp + metrics.exporter = otlp + logs.exporter = otlp + exporter.otlp.protocol = http/protobuf + exporter.otlp.endpoint = "http://localhost:4318" # Assuming local DataDog Agent + # If sending directly to DataDog, you would include the API key in headers: + # exporter.otlp.headers = "DD-API-KEY=your_api_key_here" +} +---- + +==== Jaeger +Jaeger also natively supports accepting OTLP data. + +.application.conf +[source, properties] +---- +otel { + service.name = "jooby-api" + traces.exporter = otlp + metrics.exporter = none # Jaeger is for traces only + logs.exporter = none # Jaeger is for traces only + exporter.otlp.protocol = grpc + exporter.otlp.endpoint = "http://localhost:4317" +} +---- + +=== Manual Tracing + +For tracing specific business logic, database queries, or external API calls deep within your service layer, this module provides an injectable `Trace` utility. + +You can retrieve it from the route context or inject it directly via DI to safely create and execute custom spans: + +.Manual Tracing +[source, java, role = "primary"] +---- +import io.jooby.opentelemetry.Trace; + +{ + get("/books/{isbn}", ctx -> { + Trace trace = require(Trace.class); + String isbn = ctx.path("isbn").value(); + + return trace.span("fetch_book") + .attribute("isbn", isbn) + .execute(span -> { + span.addEvent("Executing database query"); + return repository.findByIsbn(isbn); + }); + }); +} +---- + +.Kotlin +[source, kt, role="secondary"] +---- +import io.jooby.opentelemetry.Trace + +{ + get("/books/{isbn}") { ctx -> + val trace = require(Trace::class) + val isbn = ctx.path("isbn").value() + + trace.span("fetch_book") + .attribute("isbn", isbn) + .execute { span -> + span.addEvent("Executing database query") + repository.findByIsbn(isbn) + } + } +} +---- + +The `execute` and `run` blocks automatically handle the span context lifecycle, error recording, and finalization, ensuring no spans are leaked even if exceptions are thrown. + +=== Extensions + +Additional integrations are provided via `OtelExtension` implementations. Many of these rely on official OpenTelemetry instrumentation libraries, which you must add to your project's classpath. + +[NOTE] +==== +**Lifecycle & Lazy Initialization:** Although `OtelModule` must be installed at the very beginning of your application, its extensions are **lazily initialized**. They defer their execution to the application's `onStarting` lifecycle hook. This ensures that all target components provided by other modules (like database connection pools or background schedulers) are fully configured and available in the service registry before the OpenTelemetry extensions attempt to instrument them. +==== + +==== db-scheduler + +Automatically instruments the `db-scheduler` library. It tracks background task executions, measuring execution durations and recording successes and failures. + +.db-scheduler Integration +[source, java, role = "primary"] +---- +import io.jooby.opentelemetry.instrumentation.OtelDbScheduler; + +{ + install(new DbSchedulerModule() + .withExecutionInterceptor(new OtelDbScheduler(require(OpenTelemetry.class))) + ); +} +---- + +.Kotlin +[source, kt, role="secondary"] +---- +import io.jooby.opentelemetry.instrumentation.OtelDbScheduler + +{ + install(DbSchedulerModule() + .withExecutionInterceptor(OtelDbScheduler(require(OpenTelemetry::class))) + ) +} +---- + +==== HikariCP + +Instruments all registered `HikariDataSource` instances to export critical pool metrics (active/idle connections, timeouts). + +Required dependency: +[dependency, groupId="io.opentelemetry.instrumentation", artifactId="opentelemetry-hikaricp-3.0", version="${otel-instrumentation.version}"] +. + +[NOTE] +==== +Installation order is critical. `OtelModule` must be installed **before** `HikariModule`. +==== + +.HikariCP Metrics +[source, java, role = "primary"] +---- +import io.jooby.hikari.HikariModule; +import io.jooby.opentelemetry.instrumentation.OtelHikari; + +{ + install(new OtelModule(new OtelHikari())); + + install(new HikariModule()); +} +---- + +.Kotlin +[source, kt, role="secondary"] +---- +import io.jooby.hikari.HikariModule +import io.jooby.opentelemetry.instrumentation.OtelHikari + +{ + install(OtelModule(OtelHikari())) + + install(HikariModule()) +} +---- + +==== Log4j2 + +Seamlessly exports all application logs to your OpenTelemetry backend, automatically correlated with active trace and span IDs using a dynamic appender. + +Required dependency: +[dependency, groupId="io.opentelemetry.instrumentation", artifactId="opentelemetry-log4j-appender-2.17", version="${otel-instrumentation.version}"] +. + +.Log4j2 Integration +[source, java, role = "primary"] +---- +import io.jooby.opentelemetry.instrumentation.OtelLog4j2; + +{ + install(new OtelModule( + new OtelLog4j2() + )); +} +---- + +.Kotlin +[source, kt, role="secondary"] +---- +import io.jooby.opentelemetry.instrumentation.OtelLog4j2 + +{ + install(OtelModule( + OtelLog4j2() + )) +} +---- + +==== Logback + +Seamlessly exports all application logs to your OpenTelemetry backend, automatically correlated with active trace and span IDs using a dynamic appender. + +Required dependency: +[dependency, groupId="io.opentelemetry.instrumentation", artifactId="opentelemetry-logback-appender-1.0", version="${otel-instrumentation.version}"] +. + +.Logback Integration +[source, java, role = "primary"] +---- +import io.jooby.opentelemetry.instrumentation.OtelLogback; + +{ + install(new OtelModule( + new OtelLogback() + )); +} +---- + +.Kotlin +[source, kt, role="secondary"] +---- +import io.jooby.opentelemetry.instrumentation.OtelLogback + +{ + install(OtelModule( + OtelLogback() + )) +} +---- + +==== Quartz + +Tracks background task executions handled by the Quartz scheduler, creating individual spans for each execution to monitor scheduling delays and execution durations. + +Required dependency: +[dependency, groupId="io.opentelemetry.instrumentation", artifactId="opentelemetry-quartz-2.0", version="${otel-instrumentation.version}"] +. + +.Quartz Integration +[source, java, role = "primary"] +---- +import io.jooby.quartz.QuartzModule; +import io.jooby.opentelemetry.instrumentation.OtelQuartz; + +{ + install(new OtelModule(new OtelQuartz())); + + install(new QuartzModule(MyJobs.class)); +} +---- + +.Kotlin +[source, kt, role="secondary"] +---- +import io.jooby.quartz.QuartzModule +import io.jooby.opentelemetry.instrumentation.OtelQuartz + +{ + install(OtelModule(OtelQuartz())) + + install(QuartzModule(MyJobs::class.java)) +} +---- + +==== Server Metrics + +Exports native, server-specific operational metrics. It automatically detects your underlying HTTP server (Jetty, Netty, or Undertow) and exports deep metrics like event loop pending tasks, thread pool sizes, and memory usage. + +.Server Metrics +[source, java, role = "primary"] +---- +import io.jooby.opentelemetry.instrumentation.OtelServerMetrics; + +{ + install(new OtelModule( + new OtelServerMetrics() + )); +} +---- + +.Kotlin +[source, kt, role="secondary"] +---- +import io.jooby.opentelemetry.instrumentation.OtelServerMetrics + +{ + install(OtelModule( + OtelServerMetrics() + )) +} +---- From 6546b781488ed6b61d6346ad62485e6d925eeca0 Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Mon, 13 Apr 2026 20:32:19 -0300 Subject: [PATCH 3/4] hikari: better display name for otel: connections --- .../src/main/java/io/jooby/hikari/HikariModule.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/modules/jooby-hikari/src/main/java/io/jooby/hikari/HikariModule.java b/modules/jooby-hikari/src/main/java/io/jooby/hikari/HikariModule.java index 29e851d15f..f82d232dd6 100644 --- a/modules/jooby-hikari/src/main/java/io/jooby/hikari/HikariModule.java +++ b/modules/jooby-hikari/src/main/java/io/jooby/hikari/HikariModule.java @@ -486,7 +486,8 @@ static HikariConfig build(Environment env, String database) { } // wake driver for otel if (dburl != null && dburl.startsWith("jdbc:otel:")) { - forceLoadDriver(databaseType(dburl.replace(":otel:", ":")), env); + dbtype = databaseType(dburl.replace(":otel:", ":")); + forceLoadDriver(dbtype, env); } if (dbtype == null) { String poolName = From 301962ebece54d88cda74d24f1c77f4032558a41 Mon Sep 17 00:00:00 2001 From: Edgar Espina Date: Mon, 13 Apr 2026 20:39:36 -0300 Subject: [PATCH 4/4] doc: add opentelemetry to main features --- docs/asciidoc/index.adoc | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/asciidoc/index.adoc b/docs/asciidoc/index.adoc index d2d91d82ab..8498d1f2b3 100644 --- a/docs/asciidoc/index.adoc +++ b/docs/asciidoc/index.adoc @@ -60,6 +60,7 @@ fun main(args: Array) { * **Reactive Ready:** Support for <> (CompletableFuture, RxJava, Reactor, Mutiny, and Kotlin Coroutines). * **Server Choice:** Run on https://www.eclipse.org/jetty[Jetty], https://netty.io[Netty], https://vertx.io[Vert.x], or http://undertow.io[Undertow]. * **AI Ready:** Seamlessly expose your application's data and functions to Large Language Models (LLMs) using the first-class link:modules/mcp[Model Context Protocol (MCP)] module. +* **Deep Observability:** Native, vendor-neutral distributed tracing, server metrics, and log correlation via link:modules/opentelemetry[OpenTelemetry] module. * **Extensible:** Scale to a full-stack framework using extensions and link:modules[modules]. [TIP]