Skip to content

Commit a1cede7

Browse files
committed
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.
1 parent 3d43cdd commit a1cede7

File tree

35 files changed

+3149
-89
lines changed

35 files changed

+3149
-89
lines changed

jooby/src/main/java/io/jooby/Jooby.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -937,7 +937,7 @@ public Jooby start(@NonNull Server server) {
937937

938938
router.initialize();
939939

940-
for (Extension extension : lateExtensions) {
940+
for (var extension : lateExtensions) {
941941
try {
942942
extension.install(this);
943943
} catch (Throwable e) {
@@ -949,7 +949,7 @@ public Jooby start(@NonNull Server server) {
949949

950950
this.startingCallbacks = fire(this.startingCallbacks);
951951

952-
router.start(this, server);
952+
router.start(this);
953953

954954
return this;
955955
}

jooby/src/main/java/io/jooby/internal/RouterImpl.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -548,7 +548,7 @@ public void initialize() {
548548
configureContextAsService(routerOptions.isContextAsService());
549549
}
550550

551-
@NonNull public Router start(@NonNull Jooby app, @NonNull Server server) {
551+
@NonNull public Router start(@NonNull Jooby app) {
552552
started = true;
553553
var globalErrHandler = defineGlobalErrorHandler(app);
554554
if (err == null) {

modules/jooby-bom/pom.xml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,11 @@
275275
<artifactId>jooby-openapi</artifactId>
276276
<version>${project.version}</version>
277277
</dependency>
278+
<dependency>
279+
<groupId>io.jooby</groupId>
280+
<artifactId>jooby-opentelemetry</artifactId>
281+
<version>${project.version}</version>
282+
</dependency>
278283
<dependency>
279284
<groupId>io.jooby</groupId>
280285
<artifactId>jooby-pac4j</artifactId>

modules/jooby-db-scheduler/pom.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
<dependency>
2323
<groupId>com.github.kagkarlsson</groupId>
2424
<artifactId>db-scheduler</artifactId>
25-
<version>16.7.1</version>
25+
<version>${db-scheduler.version}</version>
2626
</dependency>
2727

2828
<!-- Test dependencies -->

modules/jooby-db-scheduler/src/main/java/io/jooby/dbscheduler/DbSchedulerModule.java

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020

2121
import com.github.kagkarlsson.scheduler.Scheduler;
2222
import com.github.kagkarlsson.scheduler.SchedulerName;
23+
import com.github.kagkarlsson.scheduler.event.ExecutionInterceptor;
2324
import com.github.kagkarlsson.scheduler.jdbc.AutodetectJdbcCustomization;
2425
import com.github.kagkarlsson.scheduler.jdbc.JdbcCustomization;
2526
import com.github.kagkarlsson.scheduler.serializer.Serializer;
@@ -73,6 +74,7 @@ public class DbSchedulerModule implements Extension {
7374
private ExecutorService dueExecutor;
7475
private ScheduledExecutorService housekeeperExecutor;
7576
private JdbcCustomization jdbcCustomization;
77+
private final List<ExecutionInterceptor> executionInterceptors = new ArrayList<>();
7678

7779
/**
7880
* Creates a new module.
@@ -126,6 +128,18 @@ public DbSchedulerModule withSchedulerName(@NonNull SchedulerName schedulerName)
126128
return this;
127129
}
128130

131+
/**
132+
* Adds an execution interceptor to the scheduler module. Execution interceptors are used to
133+
* customize the behavior of task execution, such as logging, monitoring, or modifying tasks.
134+
*
135+
* @param interceptor An {@link ExecutionInterceptor} that intercepts task execution.
136+
* @return This {@link DbSchedulerModule} to allow method chaining.
137+
*/
138+
public DbSchedulerModule withExecutionInterceptor(@NonNull ExecutionInterceptor interceptor) {
139+
this.executionInterceptors.add(interceptor);
140+
return this;
141+
}
142+
129143
/**
130144
* Set Task serializer.
131145
*
@@ -280,7 +294,8 @@ public void install(@NonNull Jooby app) throws SQLException {
280294
// schedulerListeners.forEach(builder::addSchedulerListener);
281295

282296
// Register interceptors
283-
// executionInterceptors.forEach(builder::addExecutionInterceptor);
297+
executionInterceptors.forEach(builder::addExecutionInterceptor);
298+
284299
var scheduler = builder.build();
285300

286301
app.getServices().put(Scheduler.class, scheduler);

modules/jooby-hikari/src/main/java/io/jooby/hikari/HikariModule.java

Lines changed: 156 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -213,13 +213,16 @@ public void install(@NonNull Jooby application) {
213213

214214
ServiceRegistry registry = application.getServices();
215215
ServiceKey<DataSource> key = ServiceKey.key(DataSource.class, database);
216-
/** Global default database: */
216+
/* Global default database: */
217217
registry.putIfAbsent(KEY, dataSource);
218218

219-
/** Specific access: */
219+
/* Specific access: */
220220
registry.put(key, dataSource);
221+
/* List access: */
222+
registry.listOf(DataSource.class).add(dataSource);
223+
registry.listOf(HikariDataSource.class).add(dataSource);
221224

222-
application.onStop(dataSource::close);
225+
application.onStop(dataSource);
223226
}
224227

225228
/**
@@ -231,13 +234,11 @@ public void install(@NonNull Jooby application) {
231234
* @param url Jdbc connection string (a.k.a jdbc url)
232235
* @return Database type or given jdbc connection string for unknown or bad urls.
233236
*/
234-
public static @NonNull String databaseType(@NonNull String url) {
235-
String type =
236-
Arrays.stream(url.toLowerCase().split(":"))
237-
.filter(token -> !SKIP_TOKENS.contains(token))
238-
.findFirst()
239-
.orElse(url);
240-
return type;
237+
public static String databaseType(@NonNull String url) {
238+
return Arrays.stream(url.toLowerCase().split(":"))
239+
.filter(token -> !SKIP_TOKENS.contains(token))
240+
.findFirst()
241+
.orElse(url);
241242
}
242243

243244
/**
@@ -288,71 +289,151 @@ private static Map<String, Object> defaults(String database, Environment env) {
288289
defaults.put(
289290
"maximumPoolSize",
290291
Math.max(MINIMUM_SIZE, Runtime.getRuntime().availableProcessors() * WORKER_FACTOR));
291-
if ("derby".equals(database)) {
292-
// url => jdbc:derby:${db};create=true
293-
defaults.put("dataSourceClassName", "org.apache.derby.jdbc.ClientDataSource");
294-
} else if ("db2".equals(database)) {
295-
// url => jdbc:db2://127.0.0.1:50000/SAMPLE
296-
defaults.put("dataSourceClassName", "com.ibm.db2.jcc.DB2SimpleDataSource");
297-
} else if ("h2".equals(database)) {
298-
// url => mem, fs or jdbc:h2:${db}
299-
defaults.put("dataSourceClassName", "org.h2.jdbcx.JdbcDataSource");
300-
defaults.put("dataSource.user", "sa");
301-
defaults.put("dataSource.password", "");
302-
} else if ("hsqldb".equals(database)) {
303-
// url => jdbc:hsqldb:file:${db}
304-
defaults.put("dataSourceClassName", "org.hsqldb.jdbc.JDBCDataSource");
305-
} else if ("mariadb".equals(database)) {
306-
// url jdbc:mariadb://<host>:<port>/<database>?<key1>=<value1>&<key2>=<value2>...
307-
defaults.put("dataSourceClassName", "org.mariadb.jdbc.MySQLDataSource");
308-
} else if ("mysql".equals(database)) {
309-
// url jdbc:mysql://<host>:<port>/<database>?<key1>=<value1>&<key2>=<value2>...
310-
// 6.x
311-
env.loadClass("com.mysql.cj.jdbc.MysqlDataSource")
312-
.ifPresent(klass -> defaults.put("dataSourceClassName", klass.getName()));
313-
// 5.x
314-
if (!defaults.containsKey("dataSourceClassName")) {
315-
env.loadClass("com.mysql.jdbc.jdbc2.optional.MysqlDataSource")
316-
.ifPresent(
317-
klass -> {
318-
defaults.put("dataSourceClassName", klass.getName());
319-
defaults.put(
320-
"dataSource.encoding", env.getConfig().getString(AvailableSettings.CHARSET));
321-
defaults.put("dataSource.cachePrepStmts", true);
322-
defaults.put("dataSource.prepStmtCacheSize", MYSQL5_STT_CACHE_SIZE);
323-
defaults.put("dataSource.prepStmtCacheSqlLimit", MYSQL5_STT_CACHE_SQL_LIMIT);
324-
defaults.put("dataSource.useServerPrepStmts", true);
325-
});
292+
if (database == null) {
293+
return defaults;
294+
}
295+
switch (database) {
296+
case "derby" ->
297+
// url => jdbc:derby:${db};create=true
298+
defaults.put("dataSourceClassName", "org.apache.derby.jdbc.ClientDataSource");
299+
case "db2" ->
300+
// url => jdbc:db2://127.0.0.1:50000/SAMPLE
301+
defaults.put("dataSourceClassName", "com.ibm.db2.jcc.DB2SimpleDataSource");
302+
case "h2" -> {
303+
// url => mem, fs or jdbc:h2:${db}
304+
defaults.put("dataSourceClassName", "org.h2.jdbcx.JdbcDataSource");
305+
defaults.put("dataSource.user", "sa");
306+
defaults.put("dataSource.password", "");
307+
}
308+
case "hsqldb" ->
309+
// url => jdbc:hsqldb:file:${db}
310+
defaults.put("dataSourceClassName", "org.hsqldb.jdbc.JDBCDataSource");
311+
case "mariadb" ->
312+
// url jdbc:mariadb://<host>:<port>/<database>?<key1>=<value1>&<key2>=<value2>...
313+
defaults.put("dataSourceClassName", "org.mariadb.jdbc.MySQLDataSource");
314+
case "mysql" -> {
315+
// url jdbc:mysql://<host>:<port>/<database>?<key1>=<value1>&<key2>=<value2>...
316+
// 6.x
317+
env.loadClass("com.mysql.cj.jdbc.MysqlDataSource")
318+
.ifPresent(klass -> defaults.put("dataSourceClassName", klass.getName()));
319+
// 5.x
320+
if (!defaults.containsKey("dataSourceClassName")) {
321+
env.loadClass("com.mysql.jdbc.jdbc2.optional.MysqlDataSource")
322+
.ifPresent(
323+
klass -> {
324+
defaults.put("dataSourceClassName", klass.getName());
325+
defaults.put(
326+
"dataSource.encoding",
327+
env.getConfig().getString(AvailableSettings.CHARSET));
328+
defaults.put("dataSource.cachePrepStmts", true);
329+
defaults.put("dataSource.prepStmtCacheSize", MYSQL5_STT_CACHE_SIZE);
330+
defaults.put("dataSource.prepStmtCacheSqlLimit", MYSQL5_STT_CACHE_SQL_LIMIT);
331+
defaults.put("dataSource.useServerPrepStmts", true);
332+
});
333+
}
326334
}
327-
} else if ("sqlserver".equals(database)) {
328-
// url =>
329-
// jdbc:sqlserver://[serverName[\instanceName][:portNumber]][;property=value[;property=value]]
330-
defaults.put("dataSourceClassName", "com.microsoft.sqlserver.jdbc.SQLServerDataSource");
331-
} else if ("oracle".equals(database)) {
332-
// url => jdbc:oracle:thin:@//<host>:<port>/<service_name>
333-
defaults.put("dataSourceClassName", "oracle.jdbc.pool.OracleDataSource");
334-
} else if ("pgsql".equals(database)) {
335-
// url => jdbc:pgsql://<server>[:<port>]/<database>
336-
defaults.put("dataSourceClassName", "com.impossibl.postgres.jdbc.PGDataSource");
337-
} else if ("postgresql".equals(database)) {
338-
// url => jdbc:postgresql://host:port/database
339-
defaults.put("dataSourceClassName", "org.postgresql.ds.PGSimpleDataSource");
340-
} else if ("sybase".equals(database)) {
341-
// url => jdbc:jtds:sybase://<host>[:<port>][/<database_name>]
342-
defaults.put("dataSourceClassName", "com.sybase.jdbcx.SybDataSource");
343-
} else if ("firebirdsql".equals(database)) {
344-
// jdbc:firebirdsql:host[/port]:<database>
345-
defaults.put("dataSourceClassName", "org.firebirdsql.pool.FBSimpleDataSource");
346-
} else if ("sqlite".equals(database)) {
347-
// jdbc:sqlite:${db}
348-
defaults.put("dataSourceClassName", "org.sqlite.SQLiteDataSource");
349-
} else if ("log4jdbc".equals(database)) {
350-
// jdbc:log4jdbc:${dbtype}:${db}
351-
defaults.put("driverClassName", "net.sf.log4jdbc.DriverSpy");
335+
case "sqlserver" ->
336+
// url =>
337+
// jdbc:sqlserver://[serverName[\instanceName][:portNumber]][;property=value[;property=value]]
338+
defaults.put("dataSourceClassName", "com.microsoft.sqlserver.jdbc.SQLServerDataSource");
339+
case "oracle" ->
340+
// url => jdbc:oracle:thin:@//<host>:<port>/<service_name>
341+
defaults.put("dataSourceClassName", "oracle.jdbc.pool.OracleDataSource");
342+
case "pgsql" ->
343+
// url => jdbc:pgsql://<server>[:<port>]/<database>
344+
defaults.put("dataSourceClassName", "com.impossibl.postgres.jdbc.PGDataSource");
345+
case "postgresql", "cockroach", "yugabyte" ->
346+
// url => jdbc:postgresql://host:port/database
347+
defaults.put("dataSourceClassName", "org.postgresql.ds.PGSimpleDataSource");
348+
case "sybase" ->
349+
// url => jdbc:jtds:sybase://<host>[:<port>][/<database_name>]
350+
defaults.put("dataSourceClassName", "com.sybase.jdbcx.SybDataSource");
351+
case "firebirdsql" ->
352+
// jdbc:firebirdsql:host[/port]:<database>
353+
defaults.put("dataSourceClassName", "org.firebirdsql.pool.FBSimpleDataSource");
354+
case "sqlite" ->
355+
// jdbc:sqlite:${db}
356+
defaults.put("dataSourceClassName", "org.sqlite.SQLiteDataSource");
357+
// --- OLAP & Analytics ---
358+
case "clickhouse" ->
359+
// jdbc:clickhouse://<host>:<port>/<database>
360+
defaults.put("dataSourceClassName", "com.clickhouse.jdbc.ClickHouseDataSource");
361+
case "snowflake" ->
362+
// jdbc:snowflake://<account>.snowflakecomputing.com/?<options>
363+
defaults.put("driverClassName", "net.snowflake.client.jdbc.SnowflakeDriver");
364+
case "redshift" ->
365+
// jdbc:redshift://<cluster>.<region>.redshift.amazonaws.com:<port>/<database>
366+
defaults.put("driverClassName", "com.amazon.redshift.Driver");
367+
case "trino" ->
368+
// jdbc:trino://<host>:<port>/<catalog>/<schema>
369+
defaults.put("driverClassName", "io.trino.jdbc.TrinoDriver");
370+
// --- Proxies & Wrappers ---
371+
case "log4jdbc" ->
372+
// jdbc:log4jdbc:${dbtype}:${db}
373+
defaults.put("driverClassName", "net.sf.log4jdbc.DriverSpy");
374+
case "otel" ->
375+
// jdbc:otel:${dbtype}:${db}
376+
defaults.put(
377+
"driverClassName", "io.opentelemetry.instrumentation.jdbc.OpenTelemetryDriver");
352378
}
353379
return defaults;
354380
}
355381

382+
/**
383+
* Forces the JVM to load and execute the static initialization block of the underlying JDBC
384+
* Driver. This is specifically required for wrappers like OpenTelemetry that rely on
385+
* java.sql.DriverManager instead of direct DataSource instantiation.
386+
*
387+
* @param database The target database type (e.g., "mysql", "postgresql")
388+
* @param env The Jooby environment providing the classloader
389+
*/
390+
private static void forceLoadDriver(String database, Environment env) {
391+
if (database == null) {
392+
return;
393+
}
394+
395+
// Map the database string to its explicit java.sql.Driver implementation
396+
var driverClassName =
397+
switch (database) {
398+
case "derby" -> "org.apache.derby.jdbc.ClientDriver";
399+
case "db2" -> "com.ibm.db2.jcc.DB2Driver";
400+
case "h2" -> "org.h2.Driver";
401+
case "hsqldb" -> "org.hsqldb.jdbc.JDBCDriver";
402+
case "mariadb" -> "org.mariadb.jdbc.Driver";
403+
case "mysql" -> "com.mysql.cj.jdbc.Driver"; // Modern 6.x/8.x Driver
404+
case "sqlserver" -> "com.microsoft.sqlserver.jdbc.SQLServerDriver";
405+
case "oracle" -> "oracle.jdbc.OracleDriver";
406+
case "pgsql" -> "com.impossibl.postgres.jdbc.PGDriver";
407+
case "postgresql", "cockroach", "yugabyte" -> "org.postgresql.Driver";
408+
case "sybase" -> "com.sybase.jdbc4.jdbc.SybDriver";
409+
case "firebirdsql" -> "org.firebirdsql.jdbc.FBDriver";
410+
case "sqlite" -> "org.sqlite.JDBC";
411+
// --- OLAP & Analytics ---
412+
case "clickhouse" -> "com.clickhouse.jdbc.ClickHouseDriver";
413+
case "snowflake" -> "net.snowflake.client.jdbc.SnowflakeDriver";
414+
case "redshift" -> "com.amazon.redshift.Driver";
415+
case "trino" -> "io.trino.jdbc.TrinoDriver";
416+
default -> null;
417+
};
418+
419+
if (driverClassName != null) {
420+
try {
421+
// The 'true' flag is the magic key: it forces the static {} block to execute,
422+
// registering the driver globally with Java's DriverManager.
423+
Class.forName(driverClassName, true, env.getClassLoader());
424+
} catch (ClassNotFoundException e) {
425+
// Graceful fallback for legacy MySQL 5.x users if the modern driver is missing
426+
if ("mysql".equals(database)) {
427+
try {
428+
Class.forName("com.mysql.jdbc.Driver", true, env.getClassLoader());
429+
} catch (ClassNotFoundException ignore) {
430+
// Ignore missing driver; let the standard JDBC connection handle the failure later
431+
}
432+
}
433+
}
434+
}
435+
}
436+
356437
static HikariConfig build(Environment env, String database) {
357438
Properties properties;
358439
Config config = env.getConfig();
@@ -379,7 +460,7 @@ static HikariConfig build(Environment env, String database) {
379460
dumpProperties(config, dbname, "dataSource.", properties::setProperty);
380461
}
381462

382-
/** *.dataSource AND *.hikari */
463+
/* *.dataSource AND *.hikari */
383464
Stream.of(dbkey, dbname)
384465
.filter(Objects::nonNull)
385466
.distinct()
@@ -403,7 +484,10 @@ static HikariConfig build(Environment env, String database) {
403484
configuration.remove("dataSource.url");
404485
configuration.setProperty("jdbcUrl", dburl);
405486
}
406-
487+
// wake driver for otel
488+
if (dburl != null && dburl.startsWith("jdbc:otel:")) {
489+
forceLoadDriver(databaseType(dburl.replace(":otel:", ":")), env);
490+
}
407491
if (dbtype == null) {
408492
String poolName =
409493
Stream.of(

modules/jooby-jetty/src/main/java/io/jooby/jetty/JettyServer.java

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -134,8 +134,6 @@ public io.jooby.Server start(@NonNull Jooby... application) {
134134
((QueuedThreadPool) threadPool).setName("worker");
135135
}
136136

137-
fireStart(List.of(application), threadPool);
138-
139137
var acceptors = 1;
140138
var selectors = options.getIoThreads();
141139
server = new Server(threadPool);
@@ -272,17 +270,21 @@ public io.jooby.Server start(@NonNull Jooby... application) {
272270
container.setIdleTimeout(Duration.ofMillis(timeout));
273271
}
274272
server.setHandler(context);
275-
server.start();
276273

277-
// --- EXTRACT OS-ASSIGNED PORTS ---
274+
for (var app : applications) {
275+
var services = app.getServices();
276+
services.put(Server.class, server);
277+
}
278+
279+
fireStart(List.of(application), threadPool);
280+
281+
server.start();
278282
if (httpConector != null) {
279283
options.setPort(httpConector.getLocalPort());
280284
}
281285
if (secureConnector != null) {
282286
options.setSecurePort(secureConnector.getLocalPort());
283287
}
284-
// ---------------------------------
285-
286288
fireReady(applications);
287289
} catch (Exception x) {
288290
if (io.jooby.Server.isAddressInUse(x.getCause())) {

0 commit comments

Comments
 (0)