diff --git a/.github/workflows/full-build.yml b/.github/workflows/full-build.yml index 31c1ec5791..0e8ad03e07 100644 --- a/.github/workflows/full-build.yml +++ b/.github/workflows/full-build.yml @@ -4,6 +4,8 @@ on: push: branches: - main # Only run push on main (merges/direct pushes) + tags: + - 'v*' # Triggers on any tag starting with 'v' (e.g., v1.0, v2.1.3) pull_request: branches: - main # Run on any PR targeting main @@ -27,15 +29,13 @@ jobs: java-version: ${{ matrix.java-version }} distribution: 'temurin' cache: maven - - name: Install - run: mvn clean install -DskipTests -q -P gradlePlugin - name: Build - run: mvn -B package -P gradlePlugin + run: mvn -B install -P gradlePlugin --no-transfer-progress env: BUILD_LOG_LEVEL: 'ERROR' - name: Tests uses: mikepenz/action-junit-report@v5 - if: failure() + if: always() with: check_name: Test ${{ matrix.os }} ${{ matrix.java-version }} report_paths: '*/target/*/TEST-*.xml' diff --git a/.github/workflows/maven-central.yml b/.github/workflows/maven-central.yml index 8b9af8dbf9..ace9352c2c 100644 --- a/.github/workflows/maven-central.yml +++ b/.github/workflows/maven-central.yml @@ -51,6 +51,7 @@ jobs: runs-on: ubuntu-latest permissions: contents: write + issues: write # Required to update/close milestones steps: - uses: actions/checkout@v4 @@ -78,31 +79,40 @@ jobs: --search "milestone:\"$VERSION_NUM\" label:break-change" \ --state all \ --limit 100 \ - --json title,url \ - --jq '.[] | "- [\(.title)](\(.url))"' > breaking_issues.md + --json title,number \ + --jq '.[] | "- \(.title) #\(.number)"' > breaking_issues.md - # 2. Fetch NEW features (requires the 'feature' label) + # 2. Fetch NEW features (requires 'feature', excludes 'break-change') gh issue list \ --repo ${{ github.repository }} \ --search "milestone:\"$VERSION_NUM\" label:feature -label:break-change" \ --state all \ --limit 100 \ - --json title,url \ - --jq '.[] | "- [\(.title)](\(.url))"' > new_issues.md + --json title,number \ + --jq '.[] | "- \(.title) #\(.number)"' > new_issues.md - # 3. Fetch OTHER changes (excludes 'feature', 'break-change', and 'dependencies') + # 3. Fetch DEPRECATED changes (requires 'deprecated', excludes 'break-change') gh issue list \ --repo ${{ github.repository }} \ - --search "milestone:\"$VERSION_NUM\" -label:feature -label:break-change -label:dependencies" \ + --search "milestone:\"$VERSION_NUM\" label:deprecated -label:break-change" \ --state all \ --limit 100 \ - --json title,url \ - --jq '.[] | "- [\(.title)](\(.url))"' > other_issues.md + --json title,number \ + --jq '.[] | "- \(.title) #\(.number)"' > deprecated_issues.md - # 4. Initialize the changelog file + # 4. Fetch OTHER changes (excludes 'feature', 'break-change', 'deprecated', and 'dependencies') + gh issue list \ + --repo ${{ github.repository }} \ + --search "milestone:\"$VERSION_NUM\" -label:feature -label:break-change -label:deprecated -label:dependencies" \ + --state all \ + --limit 100 \ + --json title,number \ + --jq '.[] | "- \(.title) #\(.number)"' > other_issues.md + + # 5. Initialize the changelog file > changelog.md - # 5. Conditionally add "Breaking Changes" if breaking_issues.md has content (-s) + # 6. Conditionally add "Breaking Changes" if [ -s breaking_issues.md ]; then echo "## ⚠️ Breaking Changes" >> changelog.md echo "" >> changelog.md @@ -110,7 +120,7 @@ jobs: echo "" >> changelog.md fi - # 6. Conditionally add "What's New" if new_issues.md has content + # 7. Conditionally add "What's New" if [ -s new_issues.md ]; then echo "## 🚀 What's New" >> changelog.md echo "" >> changelog.md @@ -118,7 +128,7 @@ jobs: echo "" >> changelog.md fi - # 7. Conditionally add "Other Changes" if other_issues.md has content + # 8. Conditionally add "Other Changes" if [ -s other_issues.md ]; then echo "## 🛠️ Changes" >> changelog.md echo "" >> changelog.md @@ -126,9 +136,17 @@ jobs: echo "" >> changelog.md fi - # 8. Build the rest of the changelog (Links & Sponsors) + # 9. Conditionally add "Deprecated" + if [ -s deprecated_issues.md ]; then + echo "## 🗑️ Deprecated" >> changelog.md + echo "" >> changelog.md + cat deprecated_issues.md >> changelog.md + echo "" >> changelog.md + fi + + # 10. Build the rest of the changelog (Links & Sponsors) cat << EOF >> changelog.md - ### 🔗 Links & Resources + ## 🔗 Links & Resources - [$CURRENT_TAG](https://github.com/jooby-project/jooby/tree/$CURRENT_TAG) - [Closed Issues](https://github.com/jooby-project/jooby/milestone/$MILESTONE_ID?closed=1) - [Changelog](https://github.com/jooby-project/jooby/compare/$PREV_TAG...$CURRENT_TAG) @@ -145,3 +163,25 @@ jobs: # Overwrite the existing release notes gh release edit $CURRENT_TAG --notes-file changelog.md + + - name: Close Milestone + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + CURRENT_TAG=${{ github.ref_name }} + VERSION_NUM=${CURRENT_TAG#v} + + # Fetch the ID of the milestone only if it is currently open + MILESTONE_ID=$(gh api repos/${{ github.repository }}/milestones -q ".[] | select(.title==\"$VERSION_NUM\" and .state==\"open\") | .number") + + if [ -n "$MILESTONE_ID" ]; then + echo "Closing milestone $VERSION_NUM (ID: $MILESTONE_ID)..." + gh api \ + --method PATCH \ + -H "Accept: application/vnd.github+json" \ + repos/${{ github.repository }}/milestones/$MILESTONE_ID \ + -f state='closed' + echo "Milestone closed successfully." + else + echo "No open milestone found matching version $VERSION_NUM. Skipping." + fi diff --git a/.github/workflows/quick-build.yml b/.github/workflows/quick-build.yml index bb303ed988..944c69a7ca 100644 --- a/.github/workflows/quick-build.yml +++ b/.github/workflows/quick-build.yml @@ -24,19 +24,15 @@ jobs: java-version: ${{ matrix.java_version }} distribution: 'temurin' cache: maven - - name: Install - run: mvn install -DskipTests -q -B - env: - BUILD_LOG_LEVEL: 'ERROR' - name: Build - run: mvn package + run: mvn -B install -P gradlePlugin --no-transfer-progress env: BUILD_PORT: 0 BUILD_SECURE_PORT: 0 BUILD_LOG_LEVEL: 'ERROR' - name: Test Result uses: mikepenz/action-junit-report@v5 - if: failure() + if: always() with: check_name: JUnit ${{ matrix.kind }} ${{ matrix.java_version }} ${{ matrix.os }} report_paths: '*/target/*/TEST-*.xml' 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] 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() + )) +} +---- diff --git a/jooby/pom.xml b/jooby/pom.xml index de1bba9eb4..a670de975f 100644 --- a/jooby/pom.xml +++ b/jooby/pom.xml @@ -6,7 +6,7 @@ io.jooby jooby-project - 4.3.0 + 4.4.0 jooby jooby 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-apt/pom.xml b/modules/jooby-apt/pom.xml index d97b8599e9..3ea6d662de 100644 --- a/modules/jooby-apt/pom.xml +++ b/modules/jooby-apt/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.3.0 + 4.4.0 jooby-apt jooby-apt diff --git a/modules/jooby-avaje-inject/pom.xml b/modules/jooby-avaje-inject/pom.xml index 5e59a9acc9..17ca064c65 100644 --- a/modules/jooby-avaje-inject/pom.xml +++ b/modules/jooby-avaje-inject/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.3.0 + 4.4.0 jooby-avaje-inject jooby-avaje-inject diff --git a/modules/jooby-avaje-jsonb/pom.xml b/modules/jooby-avaje-jsonb/pom.xml index dd6fa118fc..3aafc86fe5 100644 --- a/modules/jooby-avaje-jsonb/pom.xml +++ b/modules/jooby-avaje-jsonb/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.3.0 + 4.4.0 jooby-avaje-jsonb jooby-avaje-jsonb diff --git a/modules/jooby-avaje-validator/pom.xml b/modules/jooby-avaje-validator/pom.xml index 056f71a3c8..e8d749d83b 100644 --- a/modules/jooby-avaje-validator/pom.xml +++ b/modules/jooby-avaje-validator/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.3.0 + 4.4.0 jooby-avaje-validator jooby-avaje-validator diff --git a/modules/jooby-awssdk-v1/pom.xml b/modules/jooby-awssdk-v1/pom.xml index f03f433f5e..53446b8f74 100644 --- a/modules/jooby-awssdk-v1/pom.xml +++ b/modules/jooby-awssdk-v1/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.3.0 + 4.4.0 jooby-awssdk-v1 jooby-awssdk-v1 diff --git a/modules/jooby-awssdk-v2/pom.xml b/modules/jooby-awssdk-v2/pom.xml index 9429768e6c..fec8e8f120 100644 --- a/modules/jooby-awssdk-v2/pom.xml +++ b/modules/jooby-awssdk-v2/pom.xml @@ -6,13 +6,13 @@ io.jooby modules - 4.3.0 + 4.4.0 jooby-awssdk-v2 jooby-awssdk-v2 - 2.42.28 + 2.42.33 diff --git a/modules/jooby-bom/pom.xml b/modules/jooby-bom/pom.xml index 2bf4ccce71..f38e433dc9 100644 --- a/modules/jooby-bom/pom.xml +++ b/modules/jooby-bom/pom.xml @@ -7,14 +7,14 @@ io.jooby modules - 4.3.0 + 4.4.0 io.jooby jooby-bom jooby-bom pom - 4.3.0 + 4.4.0 Jooby (Bill of Materials) https://jooby.io @@ -167,17 +167,32 @@ io.jooby - jooby-javadoc + jooby-jdbi ${project.version} io.jooby - jooby-jdbi + jooby-jetty ${project.version} io.jooby - jooby-jetty + jooby-jsonrpc + ${project.version} + + + io.jooby + jooby-jsonrpc-avaje-jsonb + ${project.version} + + + io.jooby + jooby-jsonrpc-jackson2 + ${project.version} + + + io.jooby + jooby-jsonrpc-jackson3 ${project.version} @@ -260,6 +275,11 @@ jooby-openapi ${project.version} + + io.jooby + jooby-opentelemetry + ${project.version} + io.jooby jooby-pac4j @@ -330,6 +350,26 @@ jooby-trpc ${project.version} + + io.jooby + jooby-trpc-avaje-jsonb + ${project.version} + + + io.jooby + jooby-trpc-generator + ${project.version} + + + io.jooby + jooby-trpc-jackson2 + ${project.version} + + + io.jooby + jooby-trpc-jackson3 + ${project.version} + io.jooby jooby-undertow diff --git a/modules/jooby-caffeine/pom.xml b/modules/jooby-caffeine/pom.xml index f7ed68d5ac..a8d5f4c68e 100644 --- a/modules/jooby-caffeine/pom.xml +++ b/modules/jooby-caffeine/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.3.0 + 4.4.0 jooby-caffeine jooby-caffeine diff --git a/modules/jooby-camel/pom.xml b/modules/jooby-camel/pom.xml index e7eb3b656f..3b820ee15c 100644 --- a/modules/jooby-camel/pom.xml +++ b/modules/jooby-camel/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.3.0 + 4.4.0 jooby-camel jooby-camel diff --git a/modules/jooby-cli/pom.xml b/modules/jooby-cli/pom.xml index 14447122b6..a2260c353a 100644 --- a/modules/jooby-cli/pom.xml +++ b/modules/jooby-cli/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.3.0 + 4.4.0 jooby-cli jooby-cli diff --git a/modules/jooby-commons-email/pom.xml b/modules/jooby-commons-email/pom.xml index d95b472561..7130898da7 100644 --- a/modules/jooby-commons-email/pom.xml +++ b/modules/jooby-commons-email/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.3.0 + 4.4.0 jooby-commons-email jooby-commons-email diff --git a/modules/jooby-conscrypt/pom.xml b/modules/jooby-conscrypt/pom.xml index c2d8352878..ce493773dd 100644 --- a/modules/jooby-conscrypt/pom.xml +++ b/modules/jooby-conscrypt/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.3.0 + 4.4.0 jooby-conscrypt jooby-conscrypt diff --git a/modules/jooby-db-scheduler/pom.xml b/modules/jooby-db-scheduler/pom.xml index 79bee0e642..c19abef552 100644 --- a/modules/jooby-db-scheduler/pom.xml +++ b/modules/jooby-db-scheduler/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.3.0 + 4.4.0 jooby-db-scheduler jooby-db-scheduler @@ -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-distribution/pom.xml b/modules/jooby-distribution/pom.xml index 9f23b9a6ce..55a54ccccb 100644 --- a/modules/jooby-distribution/pom.xml +++ b/modules/jooby-distribution/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.3.0 + 4.4.0 jooby-distribution jooby-distribution diff --git a/modules/jooby-ebean/pom.xml b/modules/jooby-ebean/pom.xml index eb8e2408c8..80c33ca4de 100644 --- a/modules/jooby-ebean/pom.xml +++ b/modules/jooby-ebean/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.3.0 + 4.4.0 jooby-ebean jooby-ebean diff --git a/modules/jooby-flyway/pom.xml b/modules/jooby-flyway/pom.xml index e4468140c4..e7ea145864 100644 --- a/modules/jooby-flyway/pom.xml +++ b/modules/jooby-flyway/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.3.0 + 4.4.0 jooby-flyway jooby-flyway diff --git a/modules/jooby-freemarker/pom.xml b/modules/jooby-freemarker/pom.xml index 7702269521..8c2ea0c49b 100644 --- a/modules/jooby-freemarker/pom.xml +++ b/modules/jooby-freemarker/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.3.0 + 4.4.0 jooby-freemarker jooby-freemarker diff --git a/modules/jooby-gradle-setup/pom.xml b/modules/jooby-gradle-setup/pom.xml index a24be40137..ee4b9781a4 100644 --- a/modules/jooby-gradle-setup/pom.xml +++ b/modules/jooby-gradle-setup/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.3.0 + 4.4.0 jooby-gradle-setup jooby-gradle-setup diff --git a/modules/jooby-graphiql/pom.xml b/modules/jooby-graphiql/pom.xml index c7cbedda46..f551efe117 100644 --- a/modules/jooby-graphiql/pom.xml +++ b/modules/jooby-graphiql/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.3.0 + 4.4.0 jooby-graphiql jooby-graphiql diff --git a/modules/jooby-graphql/pom.xml b/modules/jooby-graphql/pom.xml index 1652e41131..0ce2b12a84 100644 --- a/modules/jooby-graphql/pom.xml +++ b/modules/jooby-graphql/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.3.0 + 4.4.0 jooby-graphql jooby-graphql diff --git a/modules/jooby-grpc/pom.xml b/modules/jooby-grpc/pom.xml index 8c38b0d926..aac6daf692 100644 --- a/modules/jooby-grpc/pom.xml +++ b/modules/jooby-grpc/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.3.0 + 4.4.0 jooby-grpc jooby-grpc diff --git a/modules/jooby-gson/pom.xml b/modules/jooby-gson/pom.xml index f00409eda8..5f07b5c8ed 100644 --- a/modules/jooby-gson/pom.xml +++ b/modules/jooby-gson/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.3.0 + 4.4.0 jooby-gson jooby-gson diff --git a/modules/jooby-guice/pom.xml b/modules/jooby-guice/pom.xml index d6f5b11fe5..343bf65749 100644 --- a/modules/jooby-guice/pom.xml +++ b/modules/jooby-guice/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.3.0 + 4.4.0 jooby-guice jooby-guice diff --git a/modules/jooby-handlebars/pom.xml b/modules/jooby-handlebars/pom.xml index 0c3c442b35..17f9462245 100644 --- a/modules/jooby-handlebars/pom.xml +++ b/modules/jooby-handlebars/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.3.0 + 4.4.0 jooby-handlebars jooby-handlebars diff --git a/modules/jooby-hibernate-validator/pom.xml b/modules/jooby-hibernate-validator/pom.xml index 4e34f44cc9..60a33e0088 100644 --- a/modules/jooby-hibernate-validator/pom.xml +++ b/modules/jooby-hibernate-validator/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.3.0 + 4.4.0 jooby-hibernate-validator jooby-hibernate-validator diff --git a/modules/jooby-hibernate/pom.xml b/modules/jooby-hibernate/pom.xml index 4f82dfc667..3062bb3d87 100644 --- a/modules/jooby-hibernate/pom.xml +++ b/modules/jooby-hibernate/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.3.0 + 4.4.0 jooby-hibernate jooby-hibernate diff --git a/modules/jooby-hikari/pom.xml b/modules/jooby-hikari/pom.xml index 13516e80fb..9669af85d8 100644 --- a/modules/jooby-hikari/pom.xml +++ b/modules/jooby-hikari/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.3.0 + 4.4.0 jooby-hikari jooby-hikari 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..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 @@ -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,11 @@ 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:")) { + dbtype = databaseType(dburl.replace(":otel:", ":")); + forceLoadDriver(dbtype, env); + } if (dbtype == null) { String poolName = Stream.of( diff --git a/modules/jooby-jackson/pom.xml b/modules/jooby-jackson/pom.xml index b173312984..b39d58d22f 100644 --- a/modules/jooby-jackson/pom.xml +++ b/modules/jooby-jackson/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.3.0 + 4.4.0 jooby-jackson jooby-jackson diff --git a/modules/jooby-jackson3/pom.xml b/modules/jooby-jackson3/pom.xml index 266db1b7a1..2c9256eddd 100644 --- a/modules/jooby-jackson3/pom.xml +++ b/modules/jooby-jackson3/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.3.0 + 4.4.0 jooby-jackson3 jooby-jackson3 diff --git a/modules/jooby-jasypt/pom.xml b/modules/jooby-jasypt/pom.xml index ea7174963f..99ad292dcf 100644 --- a/modules/jooby-jasypt/pom.xml +++ b/modules/jooby-jasypt/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.3.0 + 4.4.0 jooby-jasypt jooby-jasypt diff --git a/modules/jooby-javadoc/pom.xml b/modules/jooby-javadoc/pom.xml index 6063eaab96..6461cef604 100644 --- a/modules/jooby-javadoc/pom.xml +++ b/modules/jooby-javadoc/pom.xml @@ -8,7 +8,7 @@ io.jooby modules - 4.3.0 + 4.4.0 jooby-javadoc jooby-javadoc diff --git a/modules/jooby-jdbi/pom.xml b/modules/jooby-jdbi/pom.xml index 8c2d7c2f83..bae8466406 100644 --- a/modules/jooby-jdbi/pom.xml +++ b/modules/jooby-jdbi/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.3.0 + 4.4.0 jooby-jdbi jooby-jdbi diff --git a/modules/jooby-jetty/pom.xml b/modules/jooby-jetty/pom.xml index 3a1f0a43ef..6679963c64 100644 --- a/modules/jooby-jetty/pom.xml +++ b/modules/jooby-jetty/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.3.0 + 4.4.0 jooby-jetty jooby-jetty 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-jsonrpc-avaje-jsonb/pom.xml b/modules/jooby-jsonrpc-avaje-jsonb/pom.xml index 2d4658e568..b20f9700b1 100644 --- a/modules/jooby-jsonrpc-avaje-jsonb/pom.xml +++ b/modules/jooby-jsonrpc-avaje-jsonb/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.3.0 + 4.4.0 jooby-jsonrpc-avaje-jsonb diff --git a/modules/jooby-jsonrpc-jackson2/pom.xml b/modules/jooby-jsonrpc-jackson2/pom.xml index dce31fb33a..86a3742663 100644 --- a/modules/jooby-jsonrpc-jackson2/pom.xml +++ b/modules/jooby-jsonrpc-jackson2/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.3.0 + 4.4.0 jooby-jsonrpc-jackson2 diff --git a/modules/jooby-jsonrpc-jackson3/pom.xml b/modules/jooby-jsonrpc-jackson3/pom.xml index a524f52ddf..9147f675d3 100644 --- a/modules/jooby-jsonrpc-jackson3/pom.xml +++ b/modules/jooby-jsonrpc-jackson3/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.3.0 + 4.4.0 jooby-jsonrpc-jackson3 diff --git a/modules/jooby-jsonrpc/pom.xml b/modules/jooby-jsonrpc/pom.xml index cc9bf1f728..f41886404c 100644 --- a/modules/jooby-jsonrpc/pom.xml +++ b/modules/jooby-jsonrpc/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.3.0 + 4.4.0 jooby-jsonrpc diff --git a/modules/jooby-jstachio/pom.xml b/modules/jooby-jstachio/pom.xml index 1e5b8ed430..55d24a706a 100644 --- a/modules/jooby-jstachio/pom.xml +++ b/modules/jooby-jstachio/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.3.0 + 4.4.0 jooby-jstachio jooby-jstachio diff --git a/modules/jooby-jte/pom.xml b/modules/jooby-jte/pom.xml index b7d1a9f1f1..0e747d20da 100644 --- a/modules/jooby-jte/pom.xml +++ b/modules/jooby-jte/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.3.0 + 4.4.0 jooby-jte jooby-jte diff --git a/modules/jooby-jwt/pom.xml b/modules/jooby-jwt/pom.xml index b7fcccaf11..36fc42344b 100644 --- a/modules/jooby-jwt/pom.xml +++ b/modules/jooby-jwt/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.3.0 + 4.4.0 jooby-jwt jooby-jwt diff --git a/modules/jooby-kafka/pom.xml b/modules/jooby-kafka/pom.xml index e17451a1cb..9c321608de 100644 --- a/modules/jooby-kafka/pom.xml +++ b/modules/jooby-kafka/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.3.0 + 4.4.0 jooby-kafka jooby-kafka diff --git a/modules/jooby-kotlin/pom.xml b/modules/jooby-kotlin/pom.xml index 5ca39d9ac5..a6792185fa 100644 --- a/modules/jooby-kotlin/pom.xml +++ b/modules/jooby-kotlin/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.3.0 + 4.4.0 jooby-kotlin jooby-kotlin diff --git a/modules/jooby-langchain4j/pom.xml b/modules/jooby-langchain4j/pom.xml index bdc2b25791..13d5b59872 100644 --- a/modules/jooby-langchain4j/pom.xml +++ b/modules/jooby-langchain4j/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.3.0 + 4.4.0 jooby-langchain4j jooby-langchain4j @@ -73,7 +73,7 @@ dev.langchain4j langchain4j-bom - 1.12.2 + 1.13.0 pom import diff --git a/modules/jooby-log4j/pom.xml b/modules/jooby-log4j/pom.xml index 761c804174..ece07b0467 100644 --- a/modules/jooby-log4j/pom.xml +++ b/modules/jooby-log4j/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.3.0 + 4.4.0 jooby-log4j jooby-log4j diff --git a/modules/jooby-logback/pom.xml b/modules/jooby-logback/pom.xml index 294aada900..bc8a78e91c 100644 --- a/modules/jooby-logback/pom.xml +++ b/modules/jooby-logback/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.3.0 + 4.4.0 jooby-logback jooby-logback diff --git a/modules/jooby-maven-plugin/pom.xml b/modules/jooby-maven-plugin/pom.xml index 42f5f394fa..49a2394795 100644 --- a/modules/jooby-maven-plugin/pom.xml +++ b/modules/jooby-maven-plugin/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.3.0 + 4.4.0 jooby-maven-plugin jooby-maven-plugin diff --git a/modules/jooby-mcp-jackson2/pom.xml b/modules/jooby-mcp-jackson2/pom.xml index a57ab70a00..203a30b6bb 100644 --- a/modules/jooby-mcp-jackson2/pom.xml +++ b/modules/jooby-mcp-jackson2/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.3.0 + 4.4.0 jooby-mcp-jackson2 diff --git a/modules/jooby-mcp-jackson3/pom.xml b/modules/jooby-mcp-jackson3/pom.xml index 5b38368211..7bb5be69a3 100644 --- a/modules/jooby-mcp-jackson3/pom.xml +++ b/modules/jooby-mcp-jackson3/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.3.0 + 4.4.0 jooby-mcp-jackson3 diff --git a/modules/jooby-mcp/pom.xml b/modules/jooby-mcp/pom.xml index fb64abc89f..a3d26e14ac 100644 --- a/modules/jooby-mcp/pom.xml +++ b/modules/jooby-mcp/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.3.0 + 4.4.0 jooby-mcp diff --git a/modules/jooby-metrics/pom.xml b/modules/jooby-metrics/pom.xml index acf9f62932..7ca95f6122 100644 --- a/modules/jooby-metrics/pom.xml +++ b/modules/jooby-metrics/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.3.0 + 4.4.0 jooby-metrics jooby-metrics diff --git a/modules/jooby-mutiny/pom.xml b/modules/jooby-mutiny/pom.xml index a4fdc72090..3b2f06f22d 100644 --- a/modules/jooby-mutiny/pom.xml +++ b/modules/jooby-mutiny/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.3.0 + 4.4.0 jooby-mutiny jooby-mutiny diff --git a/modules/jooby-netty/pom.xml b/modules/jooby-netty/pom.xml index 727b659e10..ccb13498b5 100644 --- a/modules/jooby-netty/pom.xml +++ b/modules/jooby-netty/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.3.0 + 4.4.0 jooby-netty jooby-netty 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-openapi/pom.xml b/modules/jooby-openapi/pom.xml index e96a59758b..9212a8e488 100644 --- a/modules/jooby-openapi/pom.xml +++ b/modules/jooby-openapi/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.3.0 + 4.4.0 jooby-openapi jooby-openapi @@ -110,7 +110,7 @@ io.avaje avaje-inject - 12.4 + 12.5 test diff --git a/modules/jooby-opentelemetry/pom.xml b/modules/jooby-opentelemetry/pom.xml new file mode 100644 index 0000000000..bcd3ecd8f6 --- /dev/null +++ b/modules/jooby-opentelemetry/pom.xml @@ -0,0 +1,176 @@ + + + + 4.0.0 + + + io.jooby + modules + 4.4.0 + + 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-pac4j/pom.xml b/modules/jooby-pac4j/pom.xml index 6c2ce0a1c5..8d62015fb6 100644 --- a/modules/jooby-pac4j/pom.xml +++ b/modules/jooby-pac4j/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.3.0 + 4.4.0 jooby-pac4j jooby-pac4j diff --git a/modules/jooby-pebble/pom.xml b/modules/jooby-pebble/pom.xml index 4110eeca4a..30dde01216 100644 --- a/modules/jooby-pebble/pom.xml +++ b/modules/jooby-pebble/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.3.0 + 4.4.0 jooby-pebble jooby-pebble diff --git a/modules/jooby-quartz/pom.xml b/modules/jooby-quartz/pom.xml index b8d4002f57..4703b1690e 100644 --- a/modules/jooby-quartz/pom.xml +++ b/modules/jooby-quartz/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.3.0 + 4.4.0 jooby-quartz jooby-quartz diff --git a/modules/jooby-reactor/pom.xml b/modules/jooby-reactor/pom.xml index 5062931c00..bf11f661a2 100644 --- a/modules/jooby-reactor/pom.xml +++ b/modules/jooby-reactor/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.3.0 + 4.4.0 jooby-reactor jooby-reactor diff --git a/modules/jooby-redis/pom.xml b/modules/jooby-redis/pom.xml index 4be8db1bb7..f87991e86d 100644 --- a/modules/jooby-redis/pom.xml +++ b/modules/jooby-redis/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.3.0 + 4.4.0 jooby-redis jooby-redis diff --git a/modules/jooby-redoc/pom.xml b/modules/jooby-redoc/pom.xml index 6a618a4f7d..1126859665 100644 --- a/modules/jooby-redoc/pom.xml +++ b/modules/jooby-redoc/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.3.0 + 4.4.0 jooby-redoc jooby-redoc diff --git a/modules/jooby-rocker/pom.xml b/modules/jooby-rocker/pom.xml index 8807f4c631..bcd6bda982 100644 --- a/modules/jooby-rocker/pom.xml +++ b/modules/jooby-rocker/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.3.0 + 4.4.0 jooby-rocker jooby-rocker diff --git a/modules/jooby-run/pom.xml b/modules/jooby-run/pom.xml index af70d299f3..17151621ae 100644 --- a/modules/jooby-run/pom.xml +++ b/modules/jooby-run/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.3.0 + 4.4.0 jooby-run jooby-run diff --git a/modules/jooby-rxjava3/pom.xml b/modules/jooby-rxjava3/pom.xml index 925aacbb3e..b32e9e0585 100644 --- a/modules/jooby-rxjava3/pom.xml +++ b/modules/jooby-rxjava3/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.3.0 + 4.4.0 jooby-rxjava3 jooby-rxjava3 diff --git a/modules/jooby-stork/pom.xml b/modules/jooby-stork/pom.xml index b51a798935..fda27e73f5 100644 --- a/modules/jooby-stork/pom.xml +++ b/modules/jooby-stork/pom.xml @@ -4,7 +4,7 @@ io.jooby modules - 4.3.0 + 4.4.0 jooby-stork diff --git a/modules/jooby-swagger-ui/package-lock.json b/modules/jooby-swagger-ui/package-lock.json index b4f9a409c1..365638786a 100644 --- a/modules/jooby-swagger-ui/package-lock.json +++ b/modules/jooby-swagger-ui/package-lock.json @@ -9,7 +9,7 @@ "version": "4.0.0", "license": "ASF", "dependencies": { - "swagger-ui-dist": "^5.32.1" + "swagger-ui-dist": "^5.32.2" } }, "node_modules/@scarf/scarf": { @@ -20,9 +20,9 @@ "license": "Apache-2.0" }, "node_modules/swagger-ui-dist": { - "version": "5.32.1", - "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.32.1.tgz", - "integrity": "sha512-6HQoo7+j8PA2QqP5kgAb9dl1uxUjvR0SAoL/WUp1sTEvm0F6D5npgU2OGCLwl++bIInqGlEUQ2mpuZRZYtyCzQ==", + "version": "5.32.2", + "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.32.2.tgz", + "integrity": "sha512-t6Ns52nS8LU2hqi0+rezMjFO1ZrCsCrnommXrU7Nfrg2va2dWahdvM6TuSwzdHpG29v6BHJyU1c/UWFhgVZzVQ==", "license": "Apache-2.0", "dependencies": { "@scarf/scarf": "=1.4.0" diff --git a/modules/jooby-swagger-ui/package.json b/modules/jooby-swagger-ui/package.json index 3b57b4d361..04578b2c3f 100644 --- a/modules/jooby-swagger-ui/package.json +++ b/modules/jooby-swagger-ui/package.json @@ -4,7 +4,7 @@ "private": true, "license": "ASF", "dependencies": { - "swagger-ui-dist": "^5.32.1" + "swagger-ui-dist": "^5.32.2" }, "scarfSettings": { "enabled": false diff --git a/modules/jooby-swagger-ui/pom.xml b/modules/jooby-swagger-ui/pom.xml index 91e14d1f8a..eca336c752 100644 --- a/modules/jooby-swagger-ui/pom.xml +++ b/modules/jooby-swagger-ui/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.3.0 + 4.4.0 jooby-swagger-ui jooby-swagger-ui diff --git a/modules/jooby-test/pom.xml b/modules/jooby-test/pom.xml index 4bce67527f..4ea478701a 100644 --- a/modules/jooby-test/pom.xml +++ b/modules/jooby-test/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.3.0 + 4.4.0 jooby-test jooby-test diff --git a/modules/jooby-thymeleaf/pom.xml b/modules/jooby-thymeleaf/pom.xml index a6b0b57748..0a44abab18 100644 --- a/modules/jooby-thymeleaf/pom.xml +++ b/modules/jooby-thymeleaf/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.3.0 + 4.4.0 jooby-thymeleaf jooby-thymeleaf diff --git a/modules/jooby-trpc-avaje-jsonb/pom.xml b/modules/jooby-trpc-avaje-jsonb/pom.xml index 30c6ec39c8..1f915bec82 100644 --- a/modules/jooby-trpc-avaje-jsonb/pom.xml +++ b/modules/jooby-trpc-avaje-jsonb/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.3.0 + 4.4.0 jooby-trpc-avaje-jsonb diff --git a/modules/jooby-trpc-generator/pom.xml b/modules/jooby-trpc-generator/pom.xml index 57dd67196c..306afcbb95 100644 --- a/modules/jooby-trpc-generator/pom.xml +++ b/modules/jooby-trpc-generator/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.3.0 + 4.4.0 jooby-trpc-generator jooby-trpc-generator @@ -28,7 +28,7 @@ cz.habarta.typescript-generator typescript-generator-core - 3.2.1263 + 4.0.0 diff --git a/modules/jooby-trpc-jackson2/pom.xml b/modules/jooby-trpc-jackson2/pom.xml index 16ef9ce522..7cc0bc0c42 100644 --- a/modules/jooby-trpc-jackson2/pom.xml +++ b/modules/jooby-trpc-jackson2/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.3.0 + 4.4.0 jooby-trpc-jackson2 diff --git a/modules/jooby-trpc-jackson3/pom.xml b/modules/jooby-trpc-jackson3/pom.xml index 2ae4b016b2..3ff20393ea 100644 --- a/modules/jooby-trpc-jackson3/pom.xml +++ b/modules/jooby-trpc-jackson3/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.3.0 + 4.4.0 jooby-trpc-jackson3 diff --git a/modules/jooby-trpc/pom.xml b/modules/jooby-trpc/pom.xml index 2981877869..ec33a6e551 100644 --- a/modules/jooby-trpc/pom.xml +++ b/modules/jooby-trpc/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.3.0 + 4.4.0 jooby-trpc jooby-trpc diff --git a/modules/jooby-undertow/pom.xml b/modules/jooby-undertow/pom.xml index c53ba9e090..49aecaa1ab 100644 --- a/modules/jooby-undertow/pom.xml +++ b/modules/jooby-undertow/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.3.0 + 4.4.0 jooby-undertow jooby-undertow 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/jooby-vertx-mysql-client/pom.xml b/modules/jooby-vertx-mysql-client/pom.xml index 25b508d96f..0fa73f0f48 100644 --- a/modules/jooby-vertx-mysql-client/pom.xml +++ b/modules/jooby-vertx-mysql-client/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.3.0 + 4.4.0 jooby-vertx-mysql-client jooby-vertx-mysql-client diff --git a/modules/jooby-vertx-pg-client/pom.xml b/modules/jooby-vertx-pg-client/pom.xml index 0dc52d225f..61a06bb614 100644 --- a/modules/jooby-vertx-pg-client/pom.xml +++ b/modules/jooby-vertx-pg-client/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.3.0 + 4.4.0 jooby-vertx-pg-client jooby-vertx-pg-client diff --git a/modules/jooby-vertx-sql-client/pom.xml b/modules/jooby-vertx-sql-client/pom.xml index 701e77e841..6efb5d228b 100644 --- a/modules/jooby-vertx-sql-client/pom.xml +++ b/modules/jooby-vertx-sql-client/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.3.0 + 4.4.0 jooby-vertx-sql-client jooby-vertx-sql-client diff --git a/modules/jooby-vertx/pom.xml b/modules/jooby-vertx/pom.xml index 5a431a84c6..fa99dcb396 100644 --- a/modules/jooby-vertx/pom.xml +++ b/modules/jooby-vertx/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.3.0 + 4.4.0 jooby-vertx jooby-vertx diff --git a/modules/jooby-whoops/pom.xml b/modules/jooby-whoops/pom.xml index efe96f17fa..f973b4dabd 100644 --- a/modules/jooby-whoops/pom.xml +++ b/modules/jooby-whoops/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.3.0 + 4.4.0 jooby-whoops jooby-whoops diff --git a/modules/jooby-yasson/pom.xml b/modules/jooby-yasson/pom.xml index 8f7ba189c1..466acfb912 100644 --- a/modules/jooby-yasson/pom.xml +++ b/modules/jooby-yasson/pom.xml @@ -6,7 +6,7 @@ io.jooby modules - 4.3.0 + 4.4.0 jooby-yasson jooby-yasson diff --git a/modules/pom.xml b/modules/pom.xml index 204d443c10..5a83c963d3 100644 --- a/modules/pom.xml +++ b/modules/pom.xml @@ -4,7 +4,7 @@ io.jooby jooby-project - 4.3.0 + 4.4.0 modules @@ -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 edc632352b..96333a13bb 100644 --- a/pom.xml +++ b/pom.xml @@ -4,7 +4,7 @@ 4.0.0 io.jooby jooby-project - 4.3.0 + 4.4.0 pom jooby-project @@ -62,20 +62,20 @@ 1.3.7 4.1.1 2.21.2 - 3.1.1 + 3.1.2 2.13.2 3.0.1 3.0.4 2.4.0 - 3.1.3.RELEASE + 3.1.4.RELEASE 3.2.3 7.0.2 1.2 7.0.4.Final - 17.3.0 - 3.52.0 + 17.5.0 + 3.52.1 11.20.1 25.0 7.5.1.RELEASE @@ -113,7 +113,7 @@ 5.0.10 - 2.2.46 + 2.2.47 2.1.39 2.0.0-rc.20 @@ -121,8 +121,8 @@ 2.5.2 - 12.4 - 3.11 + 12.5 + 3.12 2.17 @@ -136,8 +136,9 @@ 0.13.0 6.4.0 2.5.2 + 16.7.1 9.2.1 - 8.17.0 + 8.18.0 1.12.797 4.18.1 1.9.3 @@ -192,7 +193,7 @@ 3.5.5 2.3.1 4.0.3 - 3.2.0 + 3.3.0 2.21.0 3.6.3 3.1.2 @@ -212,7 +213,7 @@ 21 21 yyyy-MM-dd HH:mm:ssa - 2026-04-08T14:58:58Z + 2026-04-13T23:55:50Z UTF-8 etc${file.separator}source${file.separator}formatter.sh @@ -1342,7 +1343,7 @@ org.apache.ant ant - 1.10.16 + 1.10.17 diff --git a/tests/pom.xml b/tests/pom.xml index 843e04465f..44f81b8216 100644 --- a/tests/pom.xml +++ b/tests/pom.xml @@ -6,7 +6,7 @@ io.jooby jooby-project - 4.3.0 + 4.4.0 tests tests @@ -203,6 +203,12 @@ ${jooby.version} + + io.jooby + jooby-opentelemetry + ${jooby.version} + + io.jooby jooby-test @@ -334,7 +340,7 @@ org.asynchttpclient async-http-client - 3.0.8 + 3.0.9