This is an automated email from the ASF dual-hosted git repository.
uranusjr pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git
The following commit(s) were added to refs/heads/main by this push:
new f24c5b0bc79 Implement SLF4Jm JUL, and Log4J2 providers for Java SDK
(#68725)
f24c5b0bc79 is described below
commit f24c5b0bc7933cc4856bebf7e3bc78af4db9e84e
Author: Tzu-ping Chung <[email protected]>
AuthorDate: Mon Jun 22 20:02:49 2026 +0800
Implement SLF4Jm JUL, and Log4J2 providers for Java SDK (#68725)
---
.rat-excludes | 5 +-
.../language-sdks/java.rst | 153 ++++++++++++++++++
.../java_sdk_tests/test_java_sdk_dag.py | 75 +++++++++
java-sdk/bom/build.gradle.kts | 5 +-
java-sdk/example/build.gradle | 2 +-
.../apache/airflow/example/AnnotationExample.java | 26 +--
.../airflow/example/InterfaceExampleBuilder.java | 25 +--
java-sdk/gradle.properties | 2 +
java-sdk/{bom => jpl}/build.gradle.kts | 35 +++--
.../airflow/sdk/jpl/AirflowSystemLoggerFinder.kt | 109 +++++++++++++
.../services/java.lang.System$LoggerFinder} | 8 +-
.../airflow/sdk/jpl/AirflowSystemLoggerTest.kt | 141 +++++++++++++++++
java-sdk/{bom => jul}/build.gradle.kts | 34 ++--
.../apache/airflow/sdk/jul/AirflowJulHandler.kt | 95 +++++++++++
.../airflow/sdk/jul/AirflowJulHandlerTest.kt | 124 +++++++++++++++
java-sdk/log4j2/build.gradle.kts | 73 +++++++++
.../airflow/sdk/log4j/AirflowLog4jAppender.java | 99 ++++++++++++
.../airflow/sdk/log4j/AirflowLog4jAppenderTest.kt | 121 ++++++++++++++
java-sdk/sdk/build.gradle.kts | 4 +
.../main/kotlin/org/apache/airflow/sdk/Server.kt | 22 ++-
.../org/apache/airflow/sdk/execution/Logger.kt | 174 ++++++++++++++++++---
.../org/apache/airflow/sdk/execution/LogTest.kt | 89 +++++++++++
.../airflow/sdk/execution/NamespaceLevelsTest.kt | 134 ++++++++++++++++
.../apache/airflow/sdk/execution/LogCapture.kt} | 50 +++---
java-sdk/settings.gradle.kts | 2 +-
java-sdk/slf4j/build.gradle.kts | 87 +++++++++++
java-sdk/{ => slf4j}/gradle.properties | 6 +-
.../airflow/sdk/slf4j/AirflowSlf4jProvider.kt | 110 +++++++++++++
.../services/org.slf4j.spi.SLF4JServiceProvider} | 8 +-
.../airflow/sdk/slf4j/AirflowSlf4jLoggerTest.kt | 121 ++++++++++++++
30 files changed, 1814 insertions(+), 125 deletions(-)
diff --git a/.rat-excludes b/.rat-excludes
index 01f7a3acb8b..f0cd2910f5b 100644
--- a/.rat-excludes
+++ b/.rat-excludes
@@ -328,9 +328,10 @@ www-hash.txt
/src/airflow/providers/google/_vendor/*
# Java SDK build outputs
+/java-sdk/bin/*
/java-sdk/build/*
-/java-sdk/sdk/build/*
-/java-sdk/example/build/*
+/java-sdk/*/bin/*
+/java-sdk/*/build/*
# Git ignore file
.gitignore
diff --git a/airflow-core/docs/authoring-and-scheduling/language-sdks/java.rst
b/airflow-core/docs/authoring-and-scheduling/language-sdks/java.rst
index 380ec763733..25dac2e2e10 100644
--- a/airflow-core/docs/authoring-and-scheduling/language-sdks/java.rst
+++ b/airflow-core/docs/authoring-and-scheduling/language-sdks/java.rst
@@ -230,6 +230,159 @@ See the Java SDK's published JavaDoc for more details.
.. TODO: (AIP-108) Put a link here once we publish the JavaDoc.
+.. _java-sdk/logging:
+
+Logging
+-------
+
+Task code can emit log records through any common Java logging framework. The
SDK ships optional
+integration libraries that forward those records to Airflow's task log store,
where they appear
+alongside the standard task output in the Airflow UI.
+
+Declare a logger as a static field on the task class, using the class's own
type as the name. This
+is the conventional pattern regardless of which logging framework you choose:
+
+.. code-block:: java
+
+ private static final System.Logger log =
+ System.getLogger(SalesPipeline.class.getName());
+
+ @Builder.Task(id = "extract")
+ public long extract(Client client) {
+ log.log(System.Logger.Level.INFO, "Starting extraction");
+ return recordCount;
+ }
+
+The Gradle snippets below show the dependency declarations; all Airflow
artifact versions are managed
+by ``airflow-sdk-bom``. Maven users apply the same artifact IDs following the
pattern in
+:ref:`java-sdk/build/maven`.
+
+.. _java-sdk/logging/jpl:
+
+``System.Logger`` (Java Platform Logging)
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+Java 9's new logging facade ``java.lang.System.Logger`` (JEP 264), commonly
abbreviated *JPL*, can be
+used by libraries without pulling in any third-party API. The
``airflow-sdk-jpl`` artifact registers an
+``AirflowSystemLoggerFinder`` via ``ServiceLoader``, which routes all
``System.Logger`` calls directly
+to Airflow's task log store.
+
+.. code-block:: groovy
+
+ implementation("org.apache.airflow:airflow-sdk-jpl:${version}")
+
+No configuration file or startup call is required. The ``ServiceLoader``
mechanism discovers the
+provider automatically as long as the JAR is on the classpath.
+
+.. note::
+
+ Do not add a second ``System.LoggerFinder`` implementation alongside
+ ``airflow-sdk-jpl``. The JVM selects one finder via ``ServiceLoader``;
having
+ multiple providers on the classpath leads to unpredictable behaviour.
+
+.. _java-sdk/logging/slf4j:
+
+SLF4J 2.x
+~~~~~~~~~
+
+The SLF4J binding is discovered automatically via ``ServiceLoader``; no
configuration file or
+startup call is required.
+
+.. code-block:: groovy
+
+ implementation("org.apache.airflow:airflow-sdk-slf4j:${version}")
+
+The above automatically pulls in the SLF4J API, so you don't need to add
``slf4j-api`` yourself.
+
+.. note::
+
+ Do not add a second SLF4J binding (such as ``logback-classic`` or
``slf4j-simple``) alongside
+ ``airflow-sdk-slf4j``. SLF4J 2.x warns about multiple bindings and selects
one unpredictably.
+
+.. _java-sdk/logging/log4j2:
+
+Log4j 2
+~~~~~~~
+
+``airflow-sdk-log4j2`` declares ``log4j-api`` as a transitive dependency, so
you do not need to add the latter
+separately. You must also place ``log4j-core`` on the runtime classpath to
host the plugin loader that
+discovers the custom ``AirflowAppender`` supplied by ``airflow-sdk-log4j2`` at
startup:
+
+.. code-block:: groovy
+
+ implementation("org.apache.airflow:airflow-sdk-log4j2:${version}")
+ runtimeOnly("org.apache.logging.log4j:log4j-core:${log4jVersion}")
+
+Declare ``AirflowAppender`` in your ``log4j2.xml``:
+
+.. code-block:: xml
+
+ <?xml version="1.0" encoding="UTF-8"?>
+ <Configuration>
+ <Appenders>
+ <AirflowAppender name="Airflow"/>
+ </Appenders>
+ <Loggers>
+ <Root level="info">
+ <AppenderRef ref="Airflow"/>
+ </Root>
+ </Loggers>
+ </Configuration>
+
+.. _java-sdk/logging/jul:
+
+``java.util.logging``
+~~~~~~~~~~~~~~~~~~~~~
+
+Add the artifact:
+
+.. code-block:: groovy
+
+ implementation("org.apache.airflow:airflow-sdk-jul:${version}")
+
+and call ``AirflowJulHandler.install()`` on startup to attach the handler to
the
+JUL root logger before any task runs:
+
+.. code-block:: java
+
+ public static void main(String[] args) {
+ AirflowJulHandler.install();
+ Server.create(args).serve(new MyBundle());
+ }
+
+Alternatively, declare the handler in a ``logging.properties`` file and point
JUL at it with the
+``java.util.logging.config.file`` system property (set via ``jvm_args`` in the
coordinator
+configuration):
+
+.. code-block:: properties
+
+ handlers = org.apache.airflow.sdk.jul.AirflowJulHandler
+
+.. code-block:: ini
+
+ [sdk]
+ coordinators = {
+ "java-jdk17": {
+ "classpath": "airflow.sdk.coordinators.java.JavaCoordinator",
+ "kwargs": {
+ "jars_root": ["/opt/airflow/jars"],
+ "jvm_args":
["-Djava.util.logging.config.file=/opt/airflow/logging.properties"]
+ }
+ }
+ }
+
+.. _java-sdk/logging/other:
+
+Other frameworks
+~~~~~~~~~~~~~~~~
+
+Several commonly used logging APIs are covered without a dedicated Airflow
artifact:
+
+* **Logback** is itself an SLF4J binding. Replace ``logback-classic`` with
``airflow-sdk-slf4j``
+ and no changes are needed in your task code.
+* **Apache Commons Logging (JCL)** can be bridged to SLF4J via
``org.slf4j:jcl-over-slf4j`` or
+ to Log4j 2 via ``org.apache.logging.log4j:log4j-jcl``.
+
.. _java-sdk/types:
XCom type mapping
diff --git
a/airflow-e2e-tests/tests/airflow_e2e_tests/java_sdk_tests/test_java_sdk_dag.py
b/airflow-e2e-tests/tests/airflow_e2e_tests/java_sdk_tests/test_java_sdk_dag.py
index b1e04fb2dde..709987cb2c7 100644
---
a/airflow-e2e-tests/tests/airflow_e2e_tests/java_sdk_tests/test_java_sdk_dag.py
+++
b/airflow-e2e-tests/tests/airflow_e2e_tests/java_sdk_tests/test_java_sdk_dag.py
@@ -54,14 +54,21 @@ confirms:
from __future__ import annotations
+import time
from datetime import datetime, timezone
+from typing import TYPE_CHECKING
import pytest
from airflow_e2e_tests.e2e_test_utils.clients import AirflowClient
+if TYPE_CHECKING:
+ from collections.abc import Callable
+
# The Java extract task sleeps 6 s + coordinator startup; allow plenty of room.
_JAVA_TASK_TIMEOUT = 600
+# Logs can lag slightly behind the task reaching a terminal state.
+_LOG_FETCH_TIMEOUT = 60
class TestJavaSDKAnnotationExample:
@@ -166,3 +173,71 @@ class TestJavaSDKAnnotationExample:
f"Java 'load' task should have run twice (fail then retry); "
f"try_number={load_ti.get('try_number')!r}, ti: {load_ti}"
)
+
+ def _wait_for_transform_log_record(
+ self, run_id: str, try_number: int, match: Callable[[dict], bool]
+ ) -> tuple[dict | None, list[dict]]:
+ """Poll the ``transform`` task logs until a record matching *match*
appears.
+
+ Logs can lag behind the terminal task state, and earlier records (e.g.
the
+ first transform line) arrive before the one under test, so returning
on any
+ record would race. Keep polling until the target record shows up or the
+ deadline passes. Returns the matching record (or ``None``) and the last
+ batch of records seen for diagnostics.
+ """
+ deadline = time.monotonic() + _LOG_FETCH_TIMEOUT
+ records: list[dict] = []
+ while True:
+ resp = self.airflow_client.get_task_logs(
+ dag_id="java_annotation_example", run_id=run_id,
task_id="transform", try_number=try_number
+ )
+ records = [entry for entry in resp.get("content", []) if
isinstance(entry, dict)]
+ record = next((r for r in records if match(r)), None)
+ if record is not None or time.monotonic() > deadline:
+ return record, records
+ time.sleep(3)
+
+ def test_application_logs_preserve_their_level(self):
+ """A Java task's SLF4J ``logger.info`` must reach the UI as INFO, not
ERROR.
+
+ Without the SDK's SLF4J binding the application's logs fall through to
+ stderr and the supervisor tags every line ERROR. The binding routes
them
+ over the logs socket carrying the real level instead.
+ """
+ resp = self.airflow_client.trigger_dag(
+ "java_annotation_example",
+ json={"logical_date": datetime.now(timezone.utc).isoformat()},
+ )
+ run_id = resp["dag_run_id"]
+ dag_state = self.airflow_client.wait_for_dag_run(
+ dag_id="java_annotation_example",
+ run_id=run_id,
+ timeout=_JAVA_TASK_TIMEOUT,
+ )
+
+ # The log under test is emitted only if transform actually ran; assert
it
+ # succeeded and fetch the attempt that produced the logs (transform
does
+ # not retry, but read try_number rather than assuming attempt 1).
+ ti_resp =
self.airflow_client.get_task_instances(dag_id="java_annotation_example",
run_id=run_id)
+ ti_map = {ti["task_id"]: ti for ti in ti_resp.get("task_instances",
[])}
+ transform_ti = ti_map.get("transform", {})
+ assert transform_ti.get("state") == "success", (
+ f"Java 'transform' task must succeed to emit the log under test.\n"
+ f" task state : {transform_ti.get('state')!r}\n"
+ f" dag state : {dag_state!r}\n"
+ f" all tasks : { {k: v.get('state') for k, v in ti_map.items()}
}"
+ )
+
+ # transform logs `logger.info("Got variable {}", variable)` -> "Got
variable 123".
+ record, records = self._wait_for_transform_log_record(
+ run_id,
+ transform_ti.get("try_number", 1),
+ lambda r: str(r.get("event", "")).startswith("Got variable"),
+ )
+ assert record is not None, (
+ f"transform should emit a 'Got variable' INFO record; "
+ f"events seen: {[r.get('event') for r in records]}"
+ )
+ assert str(record.get("level", "")).lower() == "info", (
+ f"application INFO log should keep its level, got
{record.get('level')!r}; record: {record}"
+ )
diff --git a/java-sdk/bom/build.gradle.kts b/java-sdk/bom/build.gradle.kts
index 479cbdde27f..1656dc3ca97 100644
--- a/java-sdk/bom/build.gradle.kts
+++ b/java-sdk/bom/build.gradle.kts
@@ -28,7 +28,10 @@ val airflowSupervisorSchemaVersion: String by project
dependencies {
constraints {
api("org.apache.airflow:airflow-sdk:$projectVersion")
- api("org.apache.airflow:airflow-sdk-processor:$projectVersion")
+ api("org.apache.airflow:airflow-sdk-jul:$projectVersion")
+ api("org.apache.airflow:airflow-sdk-log4j2:$projectVersion")
+ api("org.apache.airflow:airflow-sdk-processor:${projectVersion}")
+ api("org.apache.airflow:airflow-sdk-slf4j:$projectVersion")
}
}
diff --git a/java-sdk/example/build.gradle b/java-sdk/example/build.gradle
index 497ea8f5103..5275b7fae54 100644
--- a/java-sdk/example/build.gradle
+++ b/java-sdk/example/build.gradle
@@ -29,7 +29,7 @@ repositories {
dependencies {
annotationProcessor("org.apache.airflow:airflow-sdk-processor:${projectVersion}")
implementation("org.apache.airflow:airflow-sdk:${projectVersion}")
- implementation("org.slf4j:slf4j-simple:2.0.17")
+ implementation("org.apache.airflow:airflow-sdk-jpl:${projectVersion}")
}
java {
diff --git
a/java-sdk/example/src/java/org/apache/airflow/example/AnnotationExample.java
b/java-sdk/example/src/java/org/apache/airflow/example/AnnotationExample.java
index 3915d8e5c38..85c4162f8ca 100644
---
a/java-sdk/example/src/java/org/apache/airflow/example/AnnotationExample.java
+++
b/java-sdk/example/src/java/org/apache/airflow/example/AnnotationExample.java
@@ -19,43 +19,43 @@
package org.apache.airflow.example;
+import static java.lang.System.Logger.Level.INFO;
+
import java.util.Date;
import org.apache.airflow.sdk.*;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
@SuppressWarnings("DuplicatedCode")
@Builder.Dag(id = "java_annotation_example")
public class AnnotationExample {
- private static final Logger logger =
LoggerFactory.getLogger(AnnotationExample.class);
+ private static final System.Logger log =
System.getLogger(AnnotationExample.class.getName());
@Builder.Task(id = "extract")
public long extractValue(Client client) throws InterruptedException {
- logger.info("Hello from task");
+ log.log(INFO, "Hello from task");
var pythonXcom = client.getXCom("python_task_1");
- logger.info("Got XCom from Python Task 'python_task_1' {}", pythonXcom);
+ log.log(INFO, "Got XCom from python_task_1: {0}", pythonXcom);
var connection = client.getConnection("test_http");
- logger.info("Got con {}", connection);
+ log.log(INFO, "Got connection: {0}", connection);
for (var i = 0; i < 3; i++) {
- logger.info("Beep {}, next time will be {}", i, new Date());
+ log.log(INFO, "Beep {0}, next time will be {1}", i, new Date());
Thread.sleep(2 * 1000);
}
- logger.info("Goodbye from task");
+ log.log(INFO, "Goodbye from task");
return new Date().getTime();
}
@Builder.Task(id = "transform")
public long transformValue(Client client, @Builder.XCom(task = "extract")
long extracted) {
- logger.info("Got XCom from 'extract' {}", extracted);
+ log.log(INFO, "Got XCom from extract: {0}", extracted);
var variable = client.getVariable("my_variable");
- logger.info("Got variable {}", variable);
+ log.log(INFO, "Got variable: {0}", variable);
- logger.info("Push XCom to python task 2");
+ log.log(INFO, "Push XCom to python task 2");
return new Date().getTime();
}
@@ -66,10 +66,10 @@ public class AnnotationExample {
// set. The retry then runs this task again and it returns normally.
@Builder.Task
public void load(Context context, @Builder.XCom(task = "transform") long
transformed) {
- logger.info("Got XCom from 'transform' {}", transformed);
+ log.log(INFO, "Got XCom from transform: {0}", transformed);
if (context.ti.tryNumber == 1) {
throw new RuntimeException("I failed");
}
- logger.info("Recovered on retry, try number {}", context.ti.tryNumber);
+ log.log(INFO, "Recovered on retry, try number {0}", context.ti.tryNumber);
}
}
diff --git
a/java-sdk/example/src/java/org/apache/airflow/example/InterfaceExampleBuilder.java
b/java-sdk/example/src/java/org/apache/airflow/example/InterfaceExampleBuilder.java
index 7853927b66a..1c536c3cbf2 100644
---
a/java-sdk/example/src/java/org/apache/airflow/example/InterfaceExampleBuilder.java
+++
b/java-sdk/example/src/java/org/apache/airflow/example/InterfaceExampleBuilder.java
@@ -19,45 +19,46 @@
package org.apache.airflow.example;
+import static java.lang.System.Logger.Level.INFO;
+
import java.util.Date;
import org.apache.airflow.sdk.*;
import org.jetbrains.annotations.NotNull;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
@SuppressWarnings("DuplicatedCode")
public class InterfaceExampleBuilder {
- private static final Logger logger =
LoggerFactory.getLogger(InterfaceExampleBuilder.class);
+ private static final System.Logger log =
+ System.getLogger(InterfaceExampleBuilder.class.getName());
public static class Extract implements Task {
public void execute(@NotNull Context context, Client client) throws
Exception {
- logger.info("Hello from task");
+ log.log(INFO, "Hello from task");
var pythonInput = client.getXCom("python_task_1");
- logger.info("Got XCom from Python Task 'python_task_1' {}", pythonInput);
+ log.log(INFO, "Got XCom from python_task_1: {0}", pythonInput);
var connection = client.getConnection("test_http");
- logger.info("Got con {}", connection);
+ log.log(INFO, "Got connection: {0}", connection);
for (var i = 0; i < 3; i++) {
- logger.info("Beep {}, next time will be {}", i, new Date());
+ log.log(INFO, "Beep {0}, next time will be {1}", i, new Date());
Thread.sleep(2 * 1000);
}
client.setXCom(new Date().getTime());
- logger.info("Goodbye from task");
+ log.log(INFO, "Goodbye from task");
}
}
public static class Transform implements Task {
public void execute(@NotNull Context context, Client client) {
var extracted = client.getXCom("extract");
- logger.info("Got XCom from 'extract' {}", extracted);
+ log.log(INFO, "Got XCom from extract: {0}", extracted);
var variable = client.getVariable("my_variable");
- logger.info("Got variable {}", variable);
+ log.log(INFO, "Got variable: {0}", variable);
- logger.info("Push XCom to python task 2");
+ log.log(INFO, "Push XCom to python task 2");
client.setXCom(new Date().getTime());
}
}
@@ -65,7 +66,7 @@ public class InterfaceExampleBuilder {
public static class Load implements Task {
public void execute(@NotNull Context context, Client client) {
var transformed = client.getXCom("transform");
- logger.info("Got XCom from 'transform' {}", transformed);
+ log.log(INFO, "Got XCom from transform: {0}", transformed);
throw new RuntimeException("I failed");
}
}
diff --git a/java-sdk/gradle.properties b/java-sdk/gradle.properties
index 47099646bfb..9438ba64355 100644
--- a/java-sdk/gradle.properties
+++ b/java-sdk/gradle.properties
@@ -20,3 +20,5 @@ org.gradle.configuration-cache=true
airflowSupervisorSchemaVersion=2026-06-16
projectVersion=1.0.0-SNAPSHOT
+
+mockkVersion=1.13.12
diff --git a/java-sdk/bom/build.gradle.kts b/java-sdk/jpl/build.gradle.kts
similarity index 54%
copy from java-sdk/bom/build.gradle.kts
copy to java-sdk/jpl/build.gradle.kts
index 479cbdde27f..ecc351beefd 100644
--- a/java-sdk/bom/build.gradle.kts
+++ b/java-sdk/jpl/build.gradle.kts
@@ -18,29 +18,36 @@
*/
plugins {
- `java-platform`
+ `java-library`
+ id("airflow-jvm-conventions")
id("airflow-publish")
}
-val projectVersion: String by project
-val airflowSupervisorSchemaVersion: String by project
-
dependencies {
- constraints {
- api("org.apache.airflow:airflow-sdk:$projectVersion")
- api("org.apache.airflow:airflow-sdk-processor:$projectVersion")
- }
+ implementation(project(":sdk"))
+ testImplementation(kotlin("test"))
+ testImplementation(testFixtures(project(":sdk")))
+}
+
+java {
+ withSourcesJar() // Required by Maven Central.
+}
+
+tasks.withType<Test> {
+ useJUnitPlatform()
}
publishing {
publications {
- create<MavenPublication>("mavenBom") {
- artifactId = "airflow-sdk-bom"
- from(components["javaPlatform"])
+ create<MavenPublication>("mavenJava") {
+ artifactId = "airflow-sdk-jpl"
+ from(components["java"])
pom {
- name = "Apache Airflow Java SDK BOM"
- description = "Bill of Materials for the Apache Airflow Java
SDK."
- properties.put("airflow.supervisor.schema.version",
airflowSupervisorSchemaVersion)
+ name = "Apache Airflow Java SDK Java Platform Logging Provider"
+ description =
+ "Java Platform Logging (System.Logger) provider for the
Apache Airflow Java SDK. " +
+ "Routes java.lang.System.Logger calls from task code
through the SDK " +
+ "to Airflow's task log store."
}
}
}
diff --git
a/java-sdk/jpl/src/main/kotlin/org/apache/airflow/sdk/jpl/AirflowSystemLoggerFinder.kt
b/java-sdk/jpl/src/main/kotlin/org/apache/airflow/sdk/jpl/AirflowSystemLoggerFinder.kt
new file mode 100644
index 00000000000..74069982961
--- /dev/null
+++
b/java-sdk/jpl/src/main/kotlin/org/apache/airflow/sdk/jpl/AirflowSystemLoggerFinder.kt
@@ -0,0 +1,109 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.airflow.sdk.jpl
+
+import org.apache.airflow.sdk.execution.Level
+import org.apache.airflow.sdk.execution.Log
+import java.text.MessageFormat
+import java.util.MissingResourceException
+import java.util.ResourceBundle
+
+/**
+ * [System.LoggerFinder] that routes [System.Logger] calls to the Airflow task
log store.
+ *
+ * Registered via
[META-INF/services/java.lang.System$LoggerFinder][System.LoggerFinder].
+ * All loggers share a single implementation regardless of the requesting
module.
+ */
+class AirflowSystemLoggerFinder : System.LoggerFinder() {
+ override fun getLogger(
+ name: String,
+ module: Module,
+ ): System.Logger = AirflowSystemLogger(name)
+}
+
+internal class AirflowSystemLogger(
+ private val name: String,
+) : System.Logger {
+ override fun getName(): String = name
+
+ override fun isLoggable(level: System.Logger.Level): Boolean =
+ level != System.Logger.Level.OFF && Log.isEnabledForLevel(level.convert(),
name)
+
+ override fun log(
+ level: System.Logger.Level,
+ bundle: ResourceBundle?,
+ msg: String?,
+ thrown: Throwable?,
+ ) {
+ if (!isLoggable(level)) return
+ Log.send(level.convert(), name, bundle.resolve(msg)) {
+ thrown?.let { put("exception", it.stackTraceToString()) }
+ }
+ }
+
+ override fun log(
+ level: System.Logger.Level,
+ bundle: ResourceBundle?,
+ format: String?,
+ params: Array<out Any?>?,
+ ) {
+ if (!isLoggable(level)) return
+
+ var message = bundle.resolve(format)
+
+ fun renderEvent(): Pair<String, Map<String, Any?>> {
+ if (params.isNullOrEmpty()) return message to emptyMap()
+ val arguments =
+ buildMap {
+ message =
+ try {
+ MessageFormat.format(message, *params)
+ } catch (e: IllegalArgumentException) {
+ params.forEachIndexed { i, v -> put(i.toString(), v) }
+ put("exception", e.stackTraceToString())
+ message
+ }
+ }
+ return message to arguments
+ }
+
+ val (event, arguments) = renderEvent()
+ Log.send(level.convert(), name, event, arguments)
+ }
+}
+
+private fun System.Logger.Level.convert(): Level =
+ when (this) {
+ System.Logger.Level.OFF, System.Logger.Level.ERROR -> Level.ERROR
+ System.Logger.Level.WARNING -> Level.WARNING
+ System.Logger.Level.INFO -> Level.INFO
+ System.Logger.Level.DEBUG -> Level.DEBUG
+ System.Logger.Level.TRACE, System.Logger.Level.ALL -> Level.NOTSET
+ }
+
+private fun ResourceBundle?.resolve(key: String?): String {
+ if (key == null) return ""
+ if (this == null) return key
+ return try {
+ getString(key)
+ } catch (_: MissingResourceException) {
+ key
+ }
+}
diff --git a/java-sdk/gradle.properties
b/java-sdk/jpl/src/main/resources/META-INF/services/java.lang.System$LoggerFinder
similarity index 87%
copy from java-sdk/gradle.properties
copy to
java-sdk/jpl/src/main/resources/META-INF/services/java.lang.System$LoggerFinder
index 47099646bfb..fdae8c246f7 100644
--- a/java-sdk/gradle.properties
+++
b/java-sdk/jpl/src/main/resources/META-INF/services/java.lang.System$LoggerFinder
@@ -1,3 +1,4 @@
+#
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
@@ -14,9 +15,4 @@
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
-
-org.gradle.configuration-cache=true
-
-airflowSupervisorSchemaVersion=2026-06-16
-
-projectVersion=1.0.0-SNAPSHOT
+org.apache.airflow.sdk.jpl.AirflowSystemLoggerFinder
diff --git
a/java-sdk/jpl/src/test/kotlin/org/apache/airflow/sdk/jpl/AirflowSystemLoggerTest.kt
b/java-sdk/jpl/src/test/kotlin/org/apache/airflow/sdk/jpl/AirflowSystemLoggerTest.kt
new file mode 100644
index 00000000000..7e36d50f90f
--- /dev/null
+++
b/java-sdk/jpl/src/test/kotlin/org/apache/airflow/sdk/jpl/AirflowSystemLoggerTest.kt
@@ -0,0 +1,141 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.airflow.sdk.jpl
+
+import org.apache.airflow.sdk.execution.Level
+import org.apache.airflow.sdk.execution.LogCapture
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Assertions.assertTrue
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import java.util.ResourceBundle
+
+class AirflowSystemLoggerTest {
+ private lateinit var logger: AirflowSystemLogger
+
+ @BeforeEach
+ fun setUp() {
+ logger = AirflowSystemLogger("com.example.Task")
+ LogCapture.resetThresholds()
+ LogCapture.drain()
+ }
+
+ @Test
+ fun `level conversions`() {
+ val cases =
+ listOf(
+ System.Logger.Level.TRACE to Level.NOTSET,
+ System.Logger.Level.DEBUG to Level.DEBUG,
+ System.Logger.Level.INFO to Level.INFO,
+ System.Logger.Level.WARNING to Level.WARNING,
+ System.Logger.Level.ERROR to Level.ERROR,
+ )
+ cases.forEach { (sysLevel, expected) ->
+ LogCapture.drain()
+ logger.log(sysLevel, null as ResourceBundle?, "m", null as Array<out
Any?>?)
+ val messages = LogCapture.drain().filter { it.logger ==
"com.example.Task" }
+ assertEquals(1, messages.size, "Expected exactly one message for
System.Logger $sysLevel")
+ assertEquals(expected, messages.single().level, "System.Logger $sysLevel
should map to SDK $expected")
+ }
+ }
+
+ @Test
+ fun `OFF level is not forwarded`() {
+ logger.log(System.Logger.Level.OFF, null as ResourceBundle?, "should not
appear", null as Array<out Any?>?)
+ assertTrue(LogCapture.drain().none { it.logger == "com.example.Task" })
+ }
+
+ @Test
+ fun `message and logger name are forwarded`() {
+ logger.log(System.Logger.Level.INFO, null as ResourceBundle?, "hello",
null as Array<out Any?>?)
+ val msg = LogCapture.drain().single { it.logger == "com.example.Task" }
+ assertEquals(Level.INFO, msg.level)
+ assertEquals("com.example.Task", msg.logger)
+ assertEquals("hello", msg.event)
+ }
+
+ @Test
+ fun `null params array is tolerated (matches JDK default method
delegation)`() {
+ logger.log(System.Logger.Level.INFO, null as ResourceBundle?, "hello",
null as Array<out Any?>?)
+ val msg = LogCapture.drain().single { it.logger == "com.example.Task" }
+ assertEquals("hello", msg.event)
+ assertEquals(emptyMap<String, Any?>(), msg.arguments)
+ }
+
+ @Test
+ fun `null throwable is tolerated (matches JDK contract)`() {
+ logger.log(System.Logger.Level.INFO, null as ResourceBundle?, "hello",
null as Throwable?)
+ val msg = LogCapture.drain().single { it.logger == "com.example.Task" }
+ assertEquals("hello", msg.event)
+ assertEquals(emptyMap<String, Any?>(), msg.arguments)
+ }
+
+ @Test
+ fun `message parameters are rendered into the message`() {
+ logger.log(System.Logger.Level.INFO, null as ResourceBundle?, "{0} {1}",
arrayOf<Any?>("alpha", 42))
+ val msg = LogCapture.drain().single { it.logger == "com.example.Task" }
+ assertEquals("alpha 42", msg.event)
+ assertEquals(emptyMap<String, Any?>(), msg.arguments)
+ }
+
+ @Test
+ fun `malformed pattern keeps the template and preserves parameters as
metadata`() {
+ // "{0" is an unterminated MessageFormat element, so rendering throws and
we fall back.
+ logger.log(System.Logger.Level.INFO, null as ResourceBundle?, "{0",
arrayOf<Any?>("alpha", 42))
+ val msg = LogCapture.drain().single { it.logger == "com.example.Task" }
+ assertEquals("{0", msg.event)
+ assertEquals("alpha", msg.arguments["0"])
+ assertEquals(42, msg.arguments["1"])
+ }
+
+ @Test
+ fun `throwable is stored under the exception key`() {
+ val ex = RuntimeException("boom")
+ logger.log(System.Logger.Level.ERROR, null as ResourceBundle?, "oops", ex)
+ val msg = LogCapture.drain().single { it.logger == "com.example.Task" }
+ assertTrue(msg.arguments["exception"].toString().contains("boom"))
+ }
+
+ @Test
+ fun `bundle key is resolved to localised string`() {
+ val bundle =
+ object : ResourceBundle() {
+ override fun handleGetObject(key: String) = if (key == "greeting")
"hello" else null
+
+ override fun getKeys() =
java.util.Collections.enumeration(listOf("greeting"))
+ }
+ logger.log(System.Logger.Level.INFO, bundle, "greeting", null as Array<out
Any?>?)
+ val msg = LogCapture.drain().single { it.logger == "com.example.Task" }
+ assertEquals("hello", msg.event)
+ }
+
+ @Test
+ fun `missing bundle key falls back to the key itself`() {
+ val bundle =
+ object : ResourceBundle() {
+ override fun handleGetObject(key: String) = null
+
+ override fun getKeys() =
java.util.Collections.emptyEnumeration<String>()
+ }
+ logger.log(System.Logger.Level.INFO, bundle, "unknown.key", null as
Array<out Any?>?)
+ val msg = LogCapture.drain().single { it.logger == "com.example.Task" }
+ assertEquals("unknown.key", msg.event)
+ }
+}
diff --git a/java-sdk/bom/build.gradle.kts b/java-sdk/jul/build.gradle.kts
similarity index 55%
copy from java-sdk/bom/build.gradle.kts
copy to java-sdk/jul/build.gradle.kts
index 479cbdde27f..5dcc056976e 100644
--- a/java-sdk/bom/build.gradle.kts
+++ b/java-sdk/jul/build.gradle.kts
@@ -18,29 +18,37 @@
*/
plugins {
- `java-platform`
+ `java-library`
+ id("airflow-jvm-conventions")
id("airflow-publish")
}
-val projectVersion: String by project
-val airflowSupervisorSchemaVersion: String by project
+val mockkVersion: String by project
dependencies {
- constraints {
- api("org.apache.airflow:airflow-sdk:$projectVersion")
- api("org.apache.airflow:airflow-sdk-processor:$projectVersion")
- }
+ implementation(project(":sdk"))
+ testImplementation(kotlin("test"))
+ testImplementation("io.mockk:mockk:$mockkVersion")
+ testImplementation("io.mockk:mockk-agent:$mockkVersion")
+}
+
+java {
+ withSourcesJar() // Required by Maven Central.
+}
+
+tasks.withType<Test> {
+ useJUnitPlatform()
+ jvmArgs("-Djdk.attach.allowAttachSelf=true")
}
publishing {
publications {
- create<MavenPublication>("mavenBom") {
- artifactId = "airflow-sdk-bom"
- from(components["javaPlatform"])
+ create<MavenPublication>("mavenJava") {
+ artifactId = "airflow-sdk-jul"
+ from(components["java"])
pom {
- name = "Apache Airflow Java SDK BOM"
- description = "Bill of Materials for the Apache Airflow Java
SDK."
- properties.put("airflow.supervisor.schema.version",
airflowSupervisorSchemaVersion)
+ name = "Apache Airflow Java SDK java.util.Logging Handler"
+ description = "Routes java.util.Logging calls from task code
through the SDK to Airflow's task log store."
}
}
}
diff --git
a/java-sdk/jul/src/main/kotlin/org/apache/airflow/sdk/jul/AirflowJulHandler.kt
b/java-sdk/jul/src/main/kotlin/org/apache/airflow/sdk/jul/AirflowJulHandler.kt
new file mode 100644
index 00000000000..46605c9b0f2
--- /dev/null
+++
b/java-sdk/jul/src/main/kotlin/org/apache/airflow/sdk/jul/AirflowJulHandler.kt
@@ -0,0 +1,95 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.airflow.sdk.jul
+
+import org.apache.airflow.sdk.execution.Level
+import org.apache.airflow.sdk.execution.Log
+import java.util.logging.Handler
+import java.util.logging.LogRecord
+import java.util.logging.Logger
+import java.util.logging.SimpleFormatter
+import java.util.logging.Level as JLevel
+
+/**
+ * Convert a JUL Level to an SDK Level.
+ *
+ * JUL levels are VASTLY different from SDK levels. The `>` and `>=` criteria
+ * are chosen intentionally (but also arbitrarily) to fit JUL level regions
+ * more equally, while still keeping the predefined levels match.
+ */
+private fun JLevel.convert() =
+ intValue().let {
+ if (it > JLevel.SEVERE.intValue()) {
+ Level.CRITICAL
+ } else if (it > JLevel.WARNING.intValue()) {
+ Level.ERROR
+ } else if (it > JLevel.INFO.intValue()) {
+ Level.WARNING
+ } else if (it >= JLevel.CONFIG.intValue()) {
+ Level.INFO
+ } else if (it >= JLevel.FINER.intValue()) {
+ Level.DEBUG
+ } else {
+ Level.NOTSET
+ }
+ }
+
+/**
+ * A [Handler] that routes java.util.logging records through the Airflow Java
SDK's
+ * log pipeline to Airflow's task log store.
+ */
+class AirflowJulHandler : Handler() {
+ // Used only for [java.util.logging.Formatter.formatMessage]: it localizes
+ // and substitutes parameters but, unlike format(), never appends the
+ // throwable's stack trace, which we send separately instead, to the text.
+ private val formatter = SimpleFormatter()
+
+ override fun publish(record: LogRecord) {
+ if (!isLoggable(record)) return
+ val level = record.level.convert()
+ val logger = record.loggerName
+ if (!Log.isEnabledForLevel(level, logger)) return
+ Log.send(level, logger ?: "", formatter.formatMessage(record)) {
+ record.thrown?.run { put("exception", stackTraceToString()) }
+ }
+ }
+
+ override fun flush() = Unit
+
+ override fun close() = Unit
+
+ companion object {
+ /**
+ * Install an [AirflowJulHandler] on the root logger.
+ *
+ * This is a convenience method to install the handler on all loggers if
+ * you choose to do this programmatically (rather than with a properties
+ * file). You should typically do it in the Dag bundle's `main` method
+ * before you create the [org.apache.airflow.sdk.Bundle] object.
+ */
+ @JvmStatic
+ fun install() {
+ val root = Logger.getLogger("")
+ if (root.handlers.none { it is AirflowJulHandler }) {
+ root.addHandler(AirflowJulHandler())
+ }
+ }
+ }
+}
diff --git
a/java-sdk/jul/src/test/kotlin/org/apache/airflow/sdk/jul/AirflowJulHandlerTest.kt
b/java-sdk/jul/src/test/kotlin/org/apache/airflow/sdk/jul/AirflowJulHandlerTest.kt
new file mode 100644
index 00000000000..d0ad2fe1903
--- /dev/null
+++
b/java-sdk/jul/src/test/kotlin/org/apache/airflow/sdk/jul/AirflowJulHandlerTest.kt
@@ -0,0 +1,124 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.airflow.sdk.jul
+
+import io.mockk.every
+import io.mockk.just
+import io.mockk.mockkObject
+import io.mockk.runs
+import io.mockk.slot
+import io.mockk.unmockkAll
+import io.mockk.verify
+import org.apache.airflow.sdk.execution.Level
+import org.apache.airflow.sdk.execution.Log
+import org.junit.jupiter.api.AfterEach
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Assertions.assertTrue
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import java.util.logging.LogRecord
+import java.util.logging.Logger
+import java.util.logging.Level as JLevel
+
+class AirflowJulHandlerTest {
+ private lateinit var handler: AirflowJulHandler
+
+ @BeforeEach
+ fun setUp() {
+ handler = AirflowJulHandler()
+ mockkObject(Log)
+ every { Log.isEnabledForLevel(any(), any()) } returns true
+ every { Log.send(any(), any(), any(), any<MutableMap<String, Any?>.() ->
Unit>()) } just runs
+ }
+
+ @AfterEach
+ fun tearDown() {
+ unmockkAll()
+ // Remove any handlers installed by install() tests so they don't leak
between tests.
+ val root = Logger.getLogger("")
+ root.handlers.filterIsInstance<AirflowJulHandler>().forEach {
root.removeHandler(it) }
+ }
+
+ // Mapping:
+ // > 1000 -> CRITICAL
+ // > 900 -> ERROR (SEVERE = 1000)
+ // > 800 -> WARNING (WARNING = 900)
+ // >= 700 -> INFO (INFO = 800, CONFIG = 700)
+ // >= 400 -> DEBUG (FINE = 500, FINER = 400)
+ // else -> NOTSET (FINEST = 300, ALL)
+ @Test
+ fun `level conversions`() {
+ // Custom level above SEVERE to hit the CRITICAL branch.
+ val aboveSevere = object : JLevel("ABOVE_SEVERE", 1001) {}
+
+ val cases =
+ listOf(
+ aboveSevere to Level.CRITICAL,
+ JLevel.SEVERE to Level.ERROR,
+ JLevel.WARNING to Level.WARNING,
+ JLevel.INFO to Level.INFO,
+ JLevel.CONFIG to Level.INFO,
+ JLevel.FINE to Level.DEBUG,
+ JLevel.FINER to Level.DEBUG,
+ JLevel.FINEST to Level.NOTSET,
+ )
+ cases.forEach { (julLevel, expected) ->
+ val capturedLevel = slot<Level>()
+ every { Log.send(capture(capturedLevel), any(), any(),
any<MutableMap<String, Any?>.() -> Unit>()) } just runs
+ handler.publish(record("msg", julLevel))
+ assertEquals(expected, capturedLevel.captured, "JUL $julLevel
(${julLevel.intValue()}) should map to SDK $expected")
+ }
+ }
+
+ @Test
+ fun `message and logger name are forwarded`() {
+ handler.publish(record("hello world", JLevel.INFO, loggerName =
"com.example.Task"))
+ verify { Log.send(Level.INFO, "com.example.Task", "hello world",
any<MutableMap<String, Any?>.() -> Unit>()) }
+ }
+
+ @Test
+ fun `message parameters are rendered into the message`() {
+ val rec =
+ record("msg {0} {1}", JLevel.INFO).also {
+ it.parameters = arrayOf<Any>("alpha", 42)
+ }
+ handler.publish(rec)
+ verify { Log.send(Level.INFO, "test.Logger", "msg alpha 42",
any<MutableMap<String, Any?>.() -> Unit>()) }
+ }
+
+ @Test
+ fun `thrown is stored under the exception key`() {
+ val lambdaSlot = slot<MutableMap<String, Any?>.() -> Unit>()
+ every { Log.send(any(), any(), any(), capture(lambdaSlot)) } just runs
+ val rec =
+ LogRecord(JLevel.SEVERE, "failure").also {
+ it.thrown = RuntimeException("kaboom")
+ }
+ handler.publish(rec)
+ val args = mutableMapOf<String, Any?>().also {
lambdaSlot.captured.invoke(it) }
+ assertTrue(args["exception"].toString().contains("kaboom"))
+ }
+
+ private fun record(
+ message: String,
+ level: JLevel,
+ loggerName: String = "test.Logger",
+ ) = LogRecord(level, message).also { it.loggerName = loggerName }
+}
diff --git a/java-sdk/log4j2/build.gradle.kts b/java-sdk/log4j2/build.gradle.kts
new file mode 100644
index 00000000000..9149377167e
--- /dev/null
+++ b/java-sdk/log4j2/build.gradle.kts
@@ -0,0 +1,73 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+plugins {
+ `java-library`
+ id("airflow-jvm-conventions")
+ id("airflow-publish")
+}
+
+val mockkVersion: String by project
+
+// We are intentionally separating compileOnly and testImplementation for
+// "org.apache.logging.log4j:log4j-core:2.26.0" so it's not pulled at
+// runtime when it's unnecessary.
+@Suppress("GradleDependencyAddedMultipleTimes")
+dependencies {
+ annotationProcessor("org.apache.logging.log4j:log4j-core:2.26.0")
+ api("org.apache.logging.log4j:log4j-api:2.26.0")
+ compileOnly("org.apache.logging.log4j:log4j-core:2.26.0")
+ implementation(project(":sdk"))
+
+ testImplementation(kotlin("test"))
+ testImplementation("io.mockk:mockk:$mockkVersion")
+ testImplementation("io.mockk:mockk-agent:$mockkVersion")
+ testImplementation("org.apache.logging.log4j:log4j-core:2.26.0")
+}
+
+java {
+ withSourcesJar() // Required by Maven Central.
+}
+
+tasks.withType<JavaCompile> {
+ options.compilerArgs.addAll(
+ listOf(
+ "-Alog4j.graalvm.groupId=org.apache.airflow",
+ "-Alog4j.graalvm.artifactId=airflow-sdk-log4j2",
+ ),
+ )
+}
+
+tasks.withType<Test> {
+ useJUnitPlatform()
+ jvmArgs("-Djdk.attach.allowAttachSelf=true")
+}
+
+publishing {
+ publications {
+ create<MavenPublication>("mavenJava") {
+ artifactId = "airflow-sdk-log4j2"
+ from(components["java"])
+ pom {
+ name = "Apache Airflow Java SDK Log4j 2 Appender"
+ description = "Routes Log4j 2 log calls from task code through
the SDK to Airflow's task log store."
+ }
+ }
+ }
+}
diff --git
a/java-sdk/log4j2/src/main/java/org/apache/airflow/sdk/log4j/AirflowLog4jAppender.java
b/java-sdk/log4j2/src/main/java/org/apache/airflow/sdk/log4j/AirflowLog4jAppender.java
new file mode 100644
index 00000000000..1c27cd1dd79
--- /dev/null
+++
b/java-sdk/log4j2/src/main/java/org/apache/airflow/sdk/log4j/AirflowLog4jAppender.java
@@ -0,0 +1,99 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.airflow.sdk.log4j;
+
+import java.io.PrintWriter;
+import java.io.StringWriter;
+import java.util.HashMap;
+import java.util.Map;
+import org.apache.airflow.sdk.execution.Level;
+import org.apache.airflow.sdk.execution.Log;
+import org.apache.logging.log4j.core.Appender;
+import org.apache.logging.log4j.core.Core;
+import org.apache.logging.log4j.core.Filter;
+import org.apache.logging.log4j.core.LogEvent;
+import org.apache.logging.log4j.core.appender.AbstractAppender;
+import org.apache.logging.log4j.core.config.Property;
+import org.apache.logging.log4j.core.config.plugins.Plugin;
+import org.apache.logging.log4j.core.config.plugins.PluginAttribute;
+import org.apache.logging.log4j.core.config.plugins.PluginElement;
+import org.apache.logging.log4j.core.config.plugins.PluginFactory;
+import org.apache.logging.log4j.spi.StandardLevel;
+
+/**
+ * A Log4j {@link Appender} to route logs to Airflow.
+ *
+ * <p>This class is not called explicitly. An annotation processor reads the
+ * class (since it's annotated with {@link Plugin}) and generates information
+ * needed by Log4J.
+ */
+@Plugin(
+ name = "AirflowAppender",
+ category = Core.CATEGORY_NAME,
+ elementType = Appender.ELEMENT_TYPE)
+public final class AirflowLog4jAppender extends AbstractAppender {
+
+ private AirflowLog4jAppender(String name, Filter filter) {
+ super(name, filter, null, true, Property.EMPTY_ARRAY);
+ }
+
+ @PluginFactory
+ public static AirflowLog4jAppender createAppender(
+ @PluginAttribute("name") String name, @PluginElement("Filter") Filter
filter) {
+ return new AirflowLog4jAppender(name == null ? "AirflowAppender" : name,
filter);
+ }
+
+ @Override
+ public void append(LogEvent event) {
+ var level = convert(event.getLevel());
+ var logger = event.getLoggerName();
+ if (!Log.INSTANCE.isEnabledForLevel(level, logger)) return;
+ // Log4J does not really provide a good way to access the underlying
unformatted data
+ // since it allows vastly different logging mechanisms. We pre-format the
message here
+ // and send the exception and marker separately as structured metadata.
+ String message = event.getMessage().getFormattedMessage();
+ Map<String, Object> args = new HashMap<>();
+ var thrown = event.getThrown();
+ if (thrown != null) {
+ args.put("exception", stackTrace(thrown));
+ }
+ var marker = event.getMarker();
+ if (marker != null) {
+ args.put("marker", marker.getName());
+ }
+ Log.INSTANCE.send(level, logger, message, args);
+ }
+
+ private static Level convert(org.apache.logging.log4j.Level level) {
+ var v = level.intLevel();
+ if (v < StandardLevel.ERROR.intLevel()) return Level.CRITICAL;
+ if (v < StandardLevel.WARN.intLevel()) return Level.ERROR;
+ if (v < StandardLevel.INFO.intLevel()) return Level.WARNING;
+ if (v < StandardLevel.DEBUG.intLevel()) return Level.INFO;
+ if (v < StandardLevel.TRACE.intLevel()) return Level.DEBUG;
+ return Level.NOTSET;
+ }
+
+ private static String stackTrace(Throwable t) {
+ var sw = new StringWriter();
+ t.printStackTrace(new PrintWriter(sw));
+ return sw.toString();
+ }
+}
diff --git
a/java-sdk/log4j2/src/test/kotlin/org/apache/airflow/sdk/log4j/AirflowLog4jAppenderTest.kt
b/java-sdk/log4j2/src/test/kotlin/org/apache/airflow/sdk/log4j/AirflowLog4jAppenderTest.kt
new file mode 100644
index 00000000000..f8284f5c5b7
--- /dev/null
+++
b/java-sdk/log4j2/src/test/kotlin/org/apache/airflow/sdk/log4j/AirflowLog4jAppenderTest.kt
@@ -0,0 +1,121 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.airflow.sdk.log4j
+
+import io.mockk.every
+import io.mockk.just
+import io.mockk.mockkObject
+import io.mockk.runs
+import io.mockk.slot
+import io.mockk.unmockkAll
+import io.mockk.verify
+import org.apache.airflow.sdk.execution.Level
+import org.apache.airflow.sdk.execution.Log
+import org.apache.logging.log4j.MarkerManager
+import org.apache.logging.log4j.core.impl.Log4jLogEvent
+import org.apache.logging.log4j.message.SimpleMessage
+import org.junit.jupiter.api.AfterEach
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Assertions.assertTrue
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import org.apache.logging.log4j.Level as L4jLevel
+
+class AirflowLog4jAppenderTest {
+ private lateinit var appender: AirflowLog4jAppender
+
+ @BeforeEach
+ fun setUp() {
+ appender = AirflowLog4jAppender.createAppender("AirflowAppender", null)
+ mockkObject(Log)
+ every { Log.isEnabledForLevel(any(), any()) } returns true
+ every { Log.send(any(), any(), any(), any<Map<String, Any?>>()) } just runs
+ }
+
+ @AfterEach
+ fun tearDown() {
+ unmockkAll()
+ }
+
+ @Test
+ fun `factory honors the configured name and defaults when absent`() {
+ assertEquals("Airflow", AirflowLog4jAppender.createAppender("Airflow",
null).name)
+ assertEquals("AirflowAppender", AirflowLog4jAppender.createAppender(null,
null).name)
+ }
+
+ @Test
+ fun `level conversions`() {
+ val cases =
+ listOf(
+ L4jLevel.FATAL to Level.CRITICAL,
+ L4jLevel.ERROR to Level.ERROR,
+ L4jLevel.WARN to Level.WARNING,
+ L4jLevel.INFO to Level.INFO,
+ L4jLevel.DEBUG to Level.DEBUG,
+ L4jLevel.TRACE to Level.NOTSET,
+ L4jLevel.ALL to Level.NOTSET,
+ )
+ cases.forEach { (l4jLevel, expected) ->
+ val capturedLevel = slot<Level>()
+ every { Log.send(capture(capturedLevel), any(), any(), any<Map<String,
Any?>>()) } just runs
+ appender.append(event("msg", l4jLevel))
+ assertEquals(expected, capturedLevel.captured, "Log4j $l4jLevel should
map to SDK $expected")
+ }
+ }
+
+ @Test
+ fun `formatted message and logger name are forwarded`() {
+ val capturedArgs = slot<Map<String, Any?>>()
+ appender.append(event("hello world", L4jLevel.INFO, loggerName =
"com.example.Task"))
+ verify { Log.send(Level.INFO, "com.example.Task", "hello world",
capture(capturedArgs)) }
+ assertTrue(capturedArgs.captured.isEmpty())
+ }
+
+ @Test
+ fun `thrown is stored under the exception key`() {
+ val capturedArgs = slot<Map<String, Any?>>()
+ every { Log.send(any(), any(), any(), capture(capturedArgs)) } just runs
+ appender.append(event("failure", L4jLevel.ERROR, thrown =
RuntimeException("kaboom")))
+
assertTrue(capturedArgs.captured["exception"].toString().contains("kaboom"))
+ }
+
+ @Test
+ fun `marker name is stored under the marker key`() {
+ val capturedArgs = slot<Map<String, Any?>>()
+ every { Log.send(any(), any(), any(), capture(capturedArgs)) } just runs
+ appender.append(event("hi", L4jLevel.INFO, marker =
MarkerManager.getMarker("AUDIT")))
+ assertEquals("AUDIT", capturedArgs.captured["marker"])
+ }
+
+ private fun event(
+ message: String,
+ level: L4jLevel,
+ loggerName: String = "test.Logger",
+ thrown: Throwable? = null,
+ marker: org.apache.logging.log4j.Marker? = null,
+ ) = Log4jLogEvent
+ .newBuilder()
+ .setLoggerName(loggerName)
+ .setLevel(level)
+ .setMessage(SimpleMessage(message))
+ .setThrown(thrown)
+ .setMarker(marker)
+ .build()
+}
diff --git a/java-sdk/sdk/build.gradle.kts b/java-sdk/sdk/build.gradle.kts
index 216ef765131..1fcd07292a4 100644
--- a/java-sdk/sdk/build.gradle.kts
+++ b/java-sdk/sdk/build.gradle.kts
@@ -21,6 +21,7 @@ val airflowSupervisorSchemaVersion: String by project
plugins {
`java-library`
+ `java-test-fixtures`
id("airflow-jvm-conventions")
id("airflow-publish")
id("org.jetbrains.dokka") version "2.2.0"
@@ -251,6 +252,9 @@ publishing {
create<MavenPublication>("mavenJava") {
artifactId = "airflow-sdk"
from(components["java"])
+ // test-fixtures are not published to Maven Central.
+ suppressPomMetadataWarningsFor("testFixturesApiElements")
+ suppressPomMetadataWarningsFor("testFixturesRuntimeElements")
artifact(javadocJar)
pom {
name = "Apache Airflow Java SDK"
diff --git a/java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/Server.kt
b/java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/Server.kt
index 2bab74bf23d..6f82ca3c4c3 100644
--- a/java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/Server.kt
+++ b/java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/Server.kt
@@ -25,6 +25,7 @@ import io.ktor.network.sockets.InetSocketAddress
import io.ktor.network.sockets.aSocket
import io.ktor.network.sockets.openReadChannel
import io.ktor.network.sockets.openWriteChannel
+import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
@@ -139,20 +140,27 @@ class Server(
*/
suspend fun serveAsync(bundle: Bundle) =
coroutineScope {
+ val deferral = CompletableDeferred<Unit>()
+
launch {
- aSocket(SelectorManager(Dispatchers.IO)).tcp().connect(comm).use {
socket ->
- logger.debug("Connected comm", mapOf("addr" to comm))
- CoordinatorComm(
- bundle,
- socket.openReadChannel(),
- socket.openWriteChannel(autoFlush = true),
- ).startProcessing()
+ try {
+ aSocket(SelectorManager(Dispatchers.IO)).tcp().connect(comm).use {
socket ->
+ logger.debug("Connected comm", mapOf("addr" to comm))
+ CoordinatorComm(
+ bundle,
+ socket.openReadChannel(),
+ socket.openWriteChannel(autoFlush = true),
+ ).startProcessing()
+ }
+ } finally {
+ deferral.complete(Unit)
}
}
launch {
aSocket(SelectorManager(Dispatchers.IO)).tcp().connect(logs).use {
socket ->
logger.debug("Connected logs", mapOf("addr" to logs))
LogSender.configure(socket.openWriteChannel(autoFlush = true))
+ deferral.await()
}
}
}
diff --git
a/java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/execution/Logger.kt
b/java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/execution/Logger.kt
index 27005f92082..b778e9aa79f 100644
--- a/java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/execution/Logger.kt
+++ b/java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/execution/Logger.kt
@@ -34,52 +34,188 @@ import java.util.concurrent.ConcurrentLinkedDeque
import kotlin.reflect.KClass
import kotlin.time.Clock
-enum class Level { ERROR, DEBUG, }
+// Adapted from Python logging.
+enum class Level(
+ val value: Short,
+) {
+ CRITICAL(50),
+ ERROR(40),
+ WARNING(30),
+ INFO(20),
+ DEBUG(10),
+ NOTSET(0),
+ ;
+
+ internal companion object {
+ val accepted = entries.associateBy { it.name }
+
+ fun parse(s: String?): Level? = s?.let {
Level.accepted[it.trim().uppercase()] }
+ }
+}
+
+/**
+ * Parser for the namespace levels configuration.
+ *
+ * The value is a series of `<logger>=<level>` pairs separated by whitespaces
and/or commas.
+ * Each `<level>` must name one of [Level]s (case-insensitive). When the same
logger appears
+ * more than once, the last value wins.
+ */
+internal object NamespaceLevels {
+ const val ENV_VAR = "AIRFLOW__LOGGING__NAMESPACE_LEVELS"
+ private const val LOGGER_NAME =
"org.apache.airflow.sdk.execution.NamespaceLevels"
+
+ /**
+ * Parse [raw] into per-logger [Level] overrides.
+ *
+ * Invalid entries are skipped — the affected logger then falls back to the
global level — and
+ * reported once at [Level.ERROR], so a misconfiguration never takes down
logging nor discards the
+ * valid overrides. A `null`, empty, or whitespace/comma-only value yields
no overrides.
+ * Surrounding and repeated separators are ignored.
+ */
+ fun parse(raw: String?): Map<String, Level> {
+ if (raw.isNullOrBlank()) return emptyMap()
+
+ val levels = mutableMapOf<String, Level>()
+ val errors = mutableListOf<String>()
+ raw
+ .split(Regex("""[\s,]+"""))
+ .filter { it.isNotEmpty() }
+ .forEach { entry -> parseEntry(entry, levels)?.let { errors += it } }
+
+ if (errors.isNotEmpty()) {
+ LogSender.send(
+ LogMessage(
+ event = "Ignoring invalid $ENV_VAR entries: ${errors.joinToString(";
")}",
+ arguments = emptyMap(),
+ logger = LOGGER_NAME,
+ level = Level.ERROR,
+ ),
+ )
+ }
+ return levels
+ }
+
+ /** Parse a single [entry] into [levels], or return a description of why it
was skipped. */
+ private fun parseEntry(
+ entry: String,
+ levels: MutableMap<String, Level>,
+ ): String? {
+ val separator = entry.indexOf('=')
+ if (separator < 0) {
+ return "malformed entry \"$entry\", expected \"<logger>=<level>\""
+ }
+
+ val logger = entry.substring(0, separator).trim()
+ if (logger.isEmpty()) {
+ return "malformed entry \"$entry\", logger name is empty"
+ }
+
+ val levelName = entry.substring(separator + 1).trim()
+ val level =
+ Level.parse(levelName)
+ ?: return "invalid level \"$levelName\" for logger \"$logger\", " +
+ "expected one of: ${Level.entries.joinToString(", ") { it.name }}"
+
+ levels[logger] = level
+ return null
+ }
+}
+
+/**
+ * Public entry point into Airflow's log pipeline.
+ *
+ * This is useful for Java-side logging providers such as [java.util.logging]
+ * and SLF4J to integrate logs they receive into Airflow.
+ *
+ * Not intended for use by task code.
+ */
+object Log {
+ private const val LOGGING_LEVEL_ENV = "AIRFLOW__LOGGING__LOGGING_LEVEL"
+
+ internal var globalThreshold = Level.parse(System.getenv(LOGGING_LEVEL_ENV))
?: Level.INFO
+ internal var namedThresholds =
NamespaceLevels.parse(System.getenv(NamespaceLevels.ENV_VAR))
+
+ /**
+ * Whether a [level] message from the logger called [name] should be emitted.
+ *
+ * Thresholds cascade down the dotted-name hierarchy, mirroring Java logging
+ * convention. A logger inherits the level of its nearest configured
+ * ancestor, so with `foo=WARNING` configured, loggers named e.g. `foo.bar`
+ * also resolves to `WARNING` without additional configuration. A logger with
+ * no configured ancestor falls back to [globalThreshold].
+ */
+ fun isEnabledForLevel(
+ level: Level,
+ name: String?,
+ ): Boolean {
+ var threshold = name
+ while (threshold != null) {
+ namedThresholds[threshold]?.let { return level.value >= it.value }
+ threshold = threshold.substringBeforeLast('.', missingDelimiterValue =
"").ifEmpty { null }
+ }
+ return level.value >= globalThreshold.value
+ }
+
+ fun send(
+ level: Level,
+ logger: String,
+ event: String,
+ arguments: Map<String, Any?> = emptyMap(),
+ ) {
+ if (!isEnabledForLevel(level, logger)) return
+ LogSender.send(LogMessage(event, arguments, logger, level))
+ }
+
+ fun send(
+ level: Level,
+ logger: String,
+ event: String,
+ buildArguments: MutableMap<String, Any?>.() -> Unit,
+ ) = send(level, logger, event, buildMap(buildArguments))
+}
internal data class LogMessage(
val event: String,
- val arguments: Map<String, Any>,
- val logger: Logger,
+ val arguments: Map<String, Any?>,
+ val logger: String,
val level: Level,
val timestamp: LocalDateTime =
Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()),
)
+/**
+ * Logger used by task scaffolding.
+ *
+ * This is a thin wrapper around [LogSender] that our own code can
+ * use instead of needing to go through a "real" logging provider.
+ */
internal class Logger(
- cls: KClass<*>,
+ val name: String,
) {
- val name: String? = cls.java.typeName
-
- // TODO: Actually implement level filtering.
- @Suppress("UNUSED_PARAMETER")
- fun isEnabledForLevel(level: Level): Boolean = true
+ constructor(cls: KClass<*>) : this(cls.java.typeName)
fun debug(
message: String,
arguments: Map<String, Any> = emptyMap(),
- ) {
- log(Level.DEBUG, message, arguments)
- }
+ ) = log(Level.DEBUG, message, arguments)
fun error(
message: String,
arguments: Map<String, Any> = emptyMap(),
- ) {
- log(Level.ERROR, message, arguments)
- }
+ ) = log(Level.ERROR, message, arguments)
private fun log(
level: Level,
event: String,
arguments: Map<String, Any>,
) {
- if (!isEnabledForLevel(level)) return
- LogSender.send(LogMessage(event, arguments, this, level))
+ if (!Log.isEnabledForLevel(level, name)) return
+ LogSender.send(LogMessage(event, arguments, name, level))
}
}
internal object LogSender {
private var writer: ByteWriteChannel? = null
- private val messages: ConcurrentLinkedDeque<LogMessage> =
ConcurrentLinkedDeque()
+ internal val messages: ConcurrentLinkedDeque<LogMessage> =
ConcurrentLinkedDeque()
fun configure(channel: ByteWriteChannel) {
writer = channel
@@ -106,7 +242,7 @@ internal object LogSender {
val map = message.arguments.toMutableMap()
map["event"] = message.event
map["level"] = message.level.name.lowercase()
- map["logger"] = message.logger.name ?: "(java)"
+ map["logger"] = message.logger
map["timestamp"] = message.timestamp
// TODO: Can this be done asynchronously instead?
runBlocking { writer.writeString("${map.toJsonElement()}\n") }
diff --git
a/java-sdk/sdk/src/test/kotlin/org/apache/airflow/sdk/execution/LogTest.kt
b/java-sdk/sdk/src/test/kotlin/org/apache/airflow/sdk/execution/LogTest.kt
new file mode 100644
index 00000000000..e1d7ebf0f63
--- /dev/null
+++ b/java-sdk/sdk/src/test/kotlin/org/apache/airflow/sdk/execution/LogTest.kt
@@ -0,0 +1,89 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.airflow.sdk.execution
+
+import org.junit.jupiter.api.AfterEach
+import org.junit.jupiter.api.Assertions.assertFalse
+import org.junit.jupiter.api.Assertions.assertTrue
+import org.junit.jupiter.api.DisplayName
+import org.junit.jupiter.api.Test
+
+class LogTest {
+ @AfterEach
+ fun tearDown() {
+ LogCapture.resetThresholds()
+ }
+
+ @Test
+ @DisplayName("Without overrides, a logger is gated by the global threshold")
+ fun shouldUseGlobalThreshold() {
+ Log.globalThreshold = Level.INFO
+ Log.namedThresholds = emptyMap()
+
+ assertTrue(Log.isEnabledForLevel(Level.WARNING, "any"))
+ assertTrue(Log.isEnabledForLevel(Level.INFO, "any"))
+ assertFalse(Log.isEnabledForLevel(Level.DEBUG, "any"))
+ }
+
+ @Test
+ @DisplayName("An exact namespace override applies to its logger; unrelated
loggers use global")
+ fun shouldApplyNamespaceOverride() {
+ Log.globalThreshold = Level.INFO
+ Log.namedThresholds = mapOf("chatty" to Level.DEBUG)
+
+ assertTrue(Log.isEnabledForLevel(Level.DEBUG, "chatty"))
+ assertFalse(Log.isEnabledForLevel(Level.DEBUG, "other"))
+ assertTrue(Log.isEnabledForLevel(Level.INFO, "other"))
+ }
+
+ @Test
+ @DisplayName("A nested logger inherits the level of its nearest configured
ancestor")
+ fun shouldInheritFromAncestor() {
+ Log.globalThreshold = Level.INFO
+ Log.namedThresholds = mapOf("foo" to Level.WARNING)
+
+ // foo.bar and foo.bar.rex have no exact entry, so they inherit
foo=WARNING.
+ assertFalse(Log.isEnabledForLevel(Level.INFO, "foo.bar"))
+ assertFalse(Log.isEnabledForLevel(Level.INFO, "foo.bar.rex"))
+ assertTrue(Log.isEnabledForLevel(Level.WARNING, "foo.bar.rex"))
+ }
+
+ @Test
+ @DisplayName("The most specific configured ancestor wins")
+ fun shouldPreferMostSpecificAncestor() {
+ Log.globalThreshold = Level.INFO
+ Log.namedThresholds = mapOf("foo" to Level.WARNING, "foo.bar" to
Level.DEBUG)
+
+ // foo.bar.rex's nearest ancestor is foo.bar=DEBUG, not foo=WARNING.
+ assertTrue(Log.isEnabledForLevel(Level.DEBUG, "foo.bar.rex"))
+ // A sibling under foo (but not under foo.bar) still resolves to
foo=WARNING.
+ assertFalse(Log.isEnabledForLevel(Level.INFO, "foo.baz"))
+ }
+
+ @Test
+ @DisplayName("Inheritance respects dotted-segment boundaries, not raw string
prefixes")
+ fun shouldNotMatchPartialSegmentPrefixes() {
+ Log.globalThreshold = Level.INFO
+ Log.namedThresholds = mapOf("foo" to Level.WARNING)
+
+ // "foobar" is not a child of "foo"; it must fall back to the global level.
+ assertTrue(Log.isEnabledForLevel(Level.INFO, "foobar"))
+ }
+}
diff --git
a/java-sdk/sdk/src/test/kotlin/org/apache/airflow/sdk/execution/NamespaceLevelsTest.kt
b/java-sdk/sdk/src/test/kotlin/org/apache/airflow/sdk/execution/NamespaceLevelsTest.kt
new file mode 100644
index 00000000000..e2a4cb306e5
--- /dev/null
+++
b/java-sdk/sdk/src/test/kotlin/org/apache/airflow/sdk/execution/NamespaceLevelsTest.kt
@@ -0,0 +1,134 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.airflow.sdk.execution
+
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Assertions.assertTrue
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.DisplayName
+import org.junit.jupiter.api.Test
+
+class NamespaceLevelsTest {
+ @BeforeEach
+ fun setUp() {
+ LogCapture.drain() // discard anything buffered by earlier tests
+ }
+
+ @Test
+ @DisplayName("null, empty and separator-only input parse to no overrides and
log nothing")
+ fun shouldParseBlankToEmptyMap() {
+ for (raw in listOf(null, "", " ", " , , ")) {
+ assertEquals(emptyMap<String, Level>(), NamespaceLevels.parse(raw),
"levels for <$raw>")
+ }
+ assertTrue(LogCapture.drain().isEmpty(), "no log should be emitted for
blank input")
+ }
+
+ @Test
+ @DisplayName("Should parse the documented example without logging")
+ fun shouldParseDocumentedExample() {
+ assertEquals(
+ mapOf("sqlalchemy" to Level.INFO, "sqlalchemy.engine" to Level.DEBUG),
+ NamespaceLevels.parse("sqlalchemy=INFO sqlalchemy.engine=DEBUG"),
+ )
+ assertTrue(LogCapture.drain().isEmpty(), "no log should be emitted for
valid input")
+ }
+
+ @Test
+ @DisplayName("Should accept whitespace, commas and a mix as separators")
+ fun shouldAcceptVariousSeparators() {
+ val expected = mapOf("a" to Level.INFO, "b" to Level.DEBUG, "c" to
Level.ERROR)
+ assertEquals(expected, NamespaceLevels.parse("a=INFO b=DEBUG c=ERROR"))
+ assertEquals(expected, NamespaceLevels.parse("a=INFO,b=DEBUG,c=ERROR"))
+ assertEquals(expected, NamespaceLevels.parse(" a=INFO ,, b=DEBUG\t
c=ERROR "))
+ }
+
+ @Test
+ @DisplayName("Level names are case-insensitive")
+ fun shouldParseLevelCaseInsensitively() {
+ assertEquals(
+ mapOf("a" to Level.INFO, "b" to Level.CRITICAL),
+ NamespaceLevels.parse("a=info b=Critical"),
+ )
+ }
+
+ @Test
+ @DisplayName("When a logger is repeated the last value wins")
+ fun shouldLetLastValueWin() {
+ assertEquals(mapOf("a" to Level.ERROR), NamespaceLevels.parse("a=INFO
a=ERROR"))
+ }
+
+ @Test
+ @DisplayName("An entry without '=' is skipped, valid entries kept, and an
error is logged")
+ fun shouldSkipEntryWithoutEquals() {
+ // "sqlalchemy" keeps its override; only the malformed "botocore" falls
back to the global level.
+ assertEquals(mapOf("sqlalchemy" to Level.INFO),
NamespaceLevels.parse("sqlalchemy=INFO botocore"))
+
+ val error = LogCapture.drain().single()
+ assertEquals(Level.ERROR, error.level)
+ assertEquals("org.apache.airflow.sdk.execution.NamespaceLevels",
error.logger)
+ assertTrue(error.event.contains("AIRFLOW__LOGGING__NAMESPACE_LEVELS"),
error.event)
+ assertTrue(error.event.contains("botocore"), error.event)
+ assertTrue(error.event.contains("<logger>=<level>"), error.event)
+ }
+
+ @Test
+ @DisplayName("An entry with an empty logger name is skipped while valid
entries are kept")
+ fun shouldSkipEmptyLoggerName() {
+ assertEquals(mapOf("a" to Level.DEBUG), NamespaceLevels.parse("=INFO
a=DEBUG"))
+ assertTrue(
+ LogCapture
+ .drain()
+ .single()
+ .event
+ .contains("logger name is empty"),
+ )
+ }
+
+ @Test
+ @DisplayName("An unknown level is skipped and reported with the valid
levels")
+ fun shouldSkipUnknownLevel() {
+ assertEquals(emptyMap<String, Level>(),
NamespaceLevels.parse("sqlalchemy=VERBOSE"))
+
+ val event = LogCapture.drain().single().event
+ assertTrue(event.contains("VERBOSE"), event)
+ assertTrue(event.contains("sqlalchemy"), event)
+ assertTrue(event.contains("INFO"), event)
+ }
+
+ @Test
+ @DisplayName("An entry with an empty level is skipped")
+ fun shouldSkipEmptyLevel() {
+ assertEquals(emptyMap<String, Level>(),
NamespaceLevels.parse("sqlalchemy="))
+ assertEquals(1, LogCapture.drain().size)
+ }
+
+ @Test
+ @DisplayName("Every invalid entry is reported in a single log, valid ones
kept")
+ fun shouldReportEveryInvalidEntry() {
+ assertEquals(
+ mapOf("a" to Level.INFO, "c" to Level.DEBUG),
+ NamespaceLevels.parse("a=INFO botocore b=NOPE c=DEBUG"),
+ )
+
+ val event = LogCapture.drain().single().event
+ assertTrue(event.contains("botocore"), event)
+ assertTrue(event.contains("NOPE"), event)
+ }
+}
diff --git a/java-sdk/example/build.gradle
b/java-sdk/sdk/src/testFixtures/kotlin/org/apache/airflow/sdk/execution/LogCapture.kt
similarity index 54%
copy from java-sdk/example/build.gradle
copy to
java-sdk/sdk/src/testFixtures/kotlin/org/apache/airflow/sdk/execution/LogCapture.kt
index 497ea8f5103..5c72d36cbbc 100644
--- a/java-sdk/example/build.gradle
+++
b/java-sdk/sdk/src/testFixtures/kotlin/org/apache/airflow/sdk/execution/LogCapture.kt
@@ -17,34 +17,34 @@
* under the License.
*/
-plugins {
- id("org.apache.airflow.sdk") version "${projectVersion}"
-}
-
-repositories {
- mavenLocal() // Not needed for your own project.
- mavenCentral()
-}
+package org.apache.airflow.sdk.execution
-dependencies {
-
annotationProcessor("org.apache.airflow:airflow-sdk-processor:${projectVersion}")
- implementation("org.apache.airflow:airflow-sdk:${projectVersion}")
- implementation("org.slf4j:slf4j-simple:2.0.17")
-}
+data class CapturedLogMessage(
+ val level: Level,
+ val logger: String,
+ val event: String,
+ val arguments: Map<String, Any?>,
+)
-java {
- toolchain {
- languageVersion.set(JavaLanguageVersion.of(11))
+object LogCapture {
+ fun drain(): List<CapturedLogMessage> =
+ buildList {
+ var msg = LogSender.messages.poll()
+ while (msg != null) {
+ add(CapturedLogMessage(msg.level, msg.logger, msg.event,
msg.arguments))
+ msg = LogSender.messages.poll()
+ }
}
- sourceCompatibility = JavaVersion.VERSION_11
-}
-sourceSets {
- main {
- java.srcDir("src/java")
- }
-}
+ fun resetThresholds() {
+ configureThresholds(Level.NOTSET, emptyMap())
+ }
-airflowBundle {
- mainClass = "org.apache.airflow.example.ExampleBundleBuilder"
+ fun configureThresholds(
+ global: Level,
+ named: Map<String, Level> = emptyMap(),
+ ) {
+ Log.globalThreshold = global
+ Log.namedThresholds = named
+ }
}
diff --git a/java-sdk/settings.gradle.kts b/java-sdk/settings.gradle.kts
index fd4f4ac001c..d8792763718 100644
--- a/java-sdk/settings.gradle.kts
+++ b/java-sdk/settings.gradle.kts
@@ -22,4 +22,4 @@ plugins {
}
rootProject.name = "airflow-java-sdk"
-include("bom", "plugin", "processor", "sdk")
+include("bom", "jpl", "jul", "log4j2", "plugin", "processor", "sdk", "slf4j")
diff --git a/java-sdk/slf4j/build.gradle.kts b/java-sdk/slf4j/build.gradle.kts
new file mode 100644
index 00000000000..5ed02fa64f7
--- /dev/null
+++ b/java-sdk/slf4j/build.gradle.kts
@@ -0,0 +1,87 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+plugins {
+ `java-library`
+ id("airflow-jvm-conventions")
+ id("airflow-publish")
+}
+
+val slf4jVersion: String by project
+
+abstract class GenerateConstants : DefaultTask() {
+ @get:Input
+ abstract val requestedSlf4jApiVersion: Property<String>
+
+ @get:OutputDirectory
+ abstract val outputDir: DirectoryProperty
+
+ @TaskAction
+ fun generate() =
+
outputDir.get().file("org/apache/airflow/sdk/slf4j/Constants.kt").asFile.apply {
+ parentFile.mkdirs()
+ writeText(
+ """
+ // Generated by Gradle — do not edit.
+ package org.apache.airflow.sdk.slf4j
+
+ internal const val REQUESTED_SLF4J_API_VERSION =
"${requestedSlf4jApiVersion.get()}"
+ """.trimIndent() + "\n",
+ )
+ }
+}
+
+val generateSlf4jApiVersion by tasks.registering(GenerateConstants::class) {
+ description = "Generate source for constants pulled from Gradle
properties."
+ requestedSlf4jApiVersion = slf4jVersion
+ outputDir =
layout.buildDirectory.dir("generated/sources/slf4jApiVersion/kotlin/main")
+}
+
+kotlin.sourceSets.main {
+ kotlin.srcDir(generateSlf4jApiVersion.map { it.outputs.files.singleFile })
+}
+
+dependencies {
+ api("org.slf4j:slf4j-api:$slf4jVersion")
+ implementation(project(":sdk"))
+ testImplementation(kotlin("test"))
+ testImplementation(testFixtures(project(":sdk")))
+}
+
+java {
+ withSourcesJar() // Required by Maven Central.
+}
+
+tasks.withType<Test> {
+ useJUnitPlatform()
+}
+
+publishing {
+ publications {
+ create<MavenPublication>("mavenJava") {
+ artifactId = "airflow-sdk-slf4j"
+ from(components["java"])
+ pom {
+ name = "Apache Airflow Java SDK SLF4J Provider"
+ description = "SLF4J logging provider for the Apache Airflow
Java SDK. " +
+ "Routes SLF4J log calls from task code through the SDK to
Airflow's task log store."
+ }
+ }
+ }
+}
diff --git a/java-sdk/gradle.properties b/java-sdk/slf4j/gradle.properties
similarity index 87%
copy from java-sdk/gradle.properties
copy to java-sdk/slf4j/gradle.properties
index 47099646bfb..15392279b83 100644
--- a/java-sdk/gradle.properties
+++ b/java-sdk/slf4j/gradle.properties
@@ -15,8 +15,4 @@
# specific language governing permissions and limitations
# under the License.
-org.gradle.configuration-cache=true
-
-airflowSupervisorSchemaVersion=2026-06-16
-
-projectVersion=1.0.0-SNAPSHOT
+slf4jVersion=2.0.17
diff --git
a/java-sdk/slf4j/src/main/kotlin/org/apache/airflow/sdk/slf4j/AirflowSlf4jProvider.kt
b/java-sdk/slf4j/src/main/kotlin/org/apache/airflow/sdk/slf4j/AirflowSlf4jProvider.kt
new file mode 100644
index 00000000000..f353204a1ee
--- /dev/null
+++
b/java-sdk/slf4j/src/main/kotlin/org/apache/airflow/sdk/slf4j/AirflowSlf4jProvider.kt
@@ -0,0 +1,110 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.airflow.sdk.slf4j
+
+import org.apache.airflow.sdk.execution.Level
+import org.apache.airflow.sdk.execution.Log
+import org.slf4j.ILoggerFactory
+import org.slf4j.IMarkerFactory
+import org.slf4j.Logger
+import org.slf4j.Marker
+import org.slf4j.helpers.AbstractLogger
+import org.slf4j.helpers.BasicMarkerFactory
+import org.slf4j.helpers.MessageFormatter
+import org.slf4j.helpers.NOPMDCAdapter
+import org.slf4j.spi.MDCAdapter
+import org.slf4j.spi.SLF4JServiceProvider
+import java.util.concurrent.ConcurrentHashMap
+import org.slf4j.event.Level as SLevel
+
+private fun SLevel.convert(): Level =
+ when (this) {
+ SLevel.TRACE -> Level.NOTSET
+ SLevel.DEBUG -> Level.DEBUG
+ SLevel.INFO -> Level.INFO
+ SLevel.WARN -> Level.WARNING
+ SLevel.ERROR -> Level.ERROR
+ }
+
+internal class AirflowSlf4jLogger(
+ name: String,
+) : AbstractLogger() {
+ init {
+ this.name = name
+ }
+
+ override fun getFullyQualifiedCallerName(): String? = null
+
+ override fun handleNormalizedLoggingCall(
+ level: SLevel,
+ marker: Marker?,
+ messagePattern: String?,
+ arguments: Array<out Any?>?,
+ throwable: Throwable?,
+ ) {
+ val event = MessageFormatter.basicArrayFormat(messagePattern ?: "",
arguments)
+ Log.send(level.convert(), name, event) {
+ throwable?.run { put("exception", stackTraceToString()) }
+ marker?.let { put("marker", it.name) }
+ }
+ }
+
+ override fun isTraceEnabled(marker: Marker?) =
Log.isEnabledForLevel(Level.NOTSET, name)
+
+ override fun isTraceEnabled() = Log.isEnabledForLevel(Level.NOTSET, name)
+
+ override fun isDebugEnabled(marker: Marker?) =
Log.isEnabledForLevel(Level.DEBUG, name)
+
+ override fun isDebugEnabled() = Log.isEnabledForLevel(Level.DEBUG, name)
+
+ override fun isInfoEnabled(marker: Marker?) =
Log.isEnabledForLevel(Level.INFO, name)
+
+ override fun isInfoEnabled() = Log.isEnabledForLevel(Level.INFO, name)
+
+ override fun isWarnEnabled(marker: Marker?) =
Log.isEnabledForLevel(Level.WARNING, name)
+
+ override fun isWarnEnabled() = Log.isEnabledForLevel(Level.WARNING, name)
+
+ override fun isErrorEnabled(marker: Marker?) =
Log.isEnabledForLevel(Level.ERROR, name)
+
+ override fun isErrorEnabled() = Log.isEnabledForLevel(Level.ERROR, name)
+}
+
+internal class AirflowLoggerFactory : ILoggerFactory {
+ private val loggers = ConcurrentHashMap<String, AirflowSlf4jLogger>()
+
+ override fun getLogger(name: String): Logger = loggers.computeIfAbsent(name)
{ AirflowSlf4jLogger(it) }
+}
+
+class AirflowSlf4jProvider : SLF4JServiceProvider {
+ private lateinit var factory: AirflowLoggerFactory
+
+ override fun getLoggerFactory(): ILoggerFactory = factory
+
+ override fun getMarkerFactory(): IMarkerFactory = BasicMarkerFactory()
+
+ override fun getMDCAdapter(): MDCAdapter = NOPMDCAdapter()
+
+ override fun getRequestedApiVersion() = REQUESTED_SLF4J_API_VERSION
+
+ override fun initialize() {
+ factory = AirflowLoggerFactory()
+ }
+}
diff --git a/java-sdk/gradle.properties
b/java-sdk/slf4j/src/main/resources/META-INF/services/org.slf4j.spi.SLF4JServiceProvider
similarity index 87%
copy from java-sdk/gradle.properties
copy to
java-sdk/slf4j/src/main/resources/META-INF/services/org.slf4j.spi.SLF4JServiceProvider
index 47099646bfb..1cec01dbd96 100644
--- a/java-sdk/gradle.properties
+++
b/java-sdk/slf4j/src/main/resources/META-INF/services/org.slf4j.spi.SLF4JServiceProvider
@@ -1,3 +1,4 @@
+#
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
@@ -14,9 +15,4 @@
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
-
-org.gradle.configuration-cache=true
-
-airflowSupervisorSchemaVersion=2026-06-16
-
-projectVersion=1.0.0-SNAPSHOT
+org.apache.airflow.sdk.slf4j.AirflowSlf4jProvider
diff --git
a/java-sdk/slf4j/src/test/kotlin/org/apache/airflow/sdk/slf4j/AirflowSlf4jLoggerTest.kt
b/java-sdk/slf4j/src/test/kotlin/org/apache/airflow/sdk/slf4j/AirflowSlf4jLoggerTest.kt
new file mode 100644
index 00000000000..b2f366cf863
--- /dev/null
+++
b/java-sdk/slf4j/src/test/kotlin/org/apache/airflow/sdk/slf4j/AirflowSlf4jLoggerTest.kt
@@ -0,0 +1,121 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.airflow.sdk.slf4j
+
+import org.apache.airflow.sdk.execution.Level
+import org.apache.airflow.sdk.execution.LogCapture
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Assertions.assertFalse
+import org.junit.jupiter.api.Assertions.assertTrue
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import org.slf4j.MarkerFactory
+import org.slf4j.event.Level as SLevel
+
+class AirflowSlf4jLoggerTest {
+ private lateinit var logger: AirflowSlf4jLogger
+
+ @BeforeEach
+ fun setUp() {
+ logger = AirflowSlf4jLogger("com.example.Task")
+ LogCapture.resetThresholds()
+ LogCapture.drain() // discard any messages buffered before this test
+ }
+
+ @Test
+ fun `level conversions`() {
+ val cases =
+ listOf(
+ SLevel.TRACE to Level.NOTSET,
+ SLevel.DEBUG to Level.DEBUG,
+ SLevel.INFO to Level.INFO,
+ SLevel.WARN to Level.WARNING,
+ SLevel.ERROR to Level.ERROR,
+ )
+ cases.forEach { (slf4jLevel, expected) ->
+ LogCapture.drain()
+ when (slf4jLevel) {
+ SLevel.TRACE -> logger.trace("m")
+ SLevel.DEBUG -> logger.debug("m")
+ SLevel.INFO -> logger.info("m")
+ SLevel.WARN -> logger.warn("m")
+ SLevel.ERROR -> logger.error("m")
+ }
+ val messages = LogCapture.drain().filter { it.logger ==
"com.example.Task" }
+ assertEquals(1, messages.size, "Expected exactly one message for SLF4J
$slf4jLevel")
+ assertEquals(expected, messages.single().level, "SLF4J $slf4jLevel
should map to SDK $expected")
+ }
+ }
+
+ @Test
+ fun `message and logger name are forwarded`() {
+ logger.info("hello")
+ val msg = LogCapture.drain().single { it.logger == "com.example.Task" }
+ assertEquals(Level.INFO, msg.level)
+ assertEquals("com.example.Task", msg.logger)
+ assertEquals("hello", msg.event)
+ }
+
+ @Test
+ fun `message parameters are rendered into the message`() {
+ logger.info("{} {}", "alpha", 42 as Any)
+ val msg = LogCapture.drain().single { it.logger == "com.example.Task" }
+ assertEquals("alpha 42", msg.event)
+ assertFalse(msg.arguments.containsKey("0"))
+ }
+
+ @Test
+ fun `throwable is stored under the exception key`() {
+ val ex = RuntimeException("boom")
+ logger.error("oops", ex)
+ val msg = LogCapture.drain().single { it.logger == "com.example.Task" }
+ assertTrue(msg.arguments["exception"].toString().contains("boom"))
+ }
+
+ @Test
+ fun `marker name is stored under the marker key`() {
+ logger.info(MarkerFactory.getMarker("AUDIT"), "hello")
+ val msg = LogCapture.drain().single { it.logger == "com.example.Task" }
+ assertEquals("AUDIT", msg.arguments["marker"])
+ }
+
+ @Test
+ fun `no marker key is added when none is supplied`() {
+ logger.info("hello")
+ val msg = LogCapture.drain().single { it.logger == "com.example.Task" }
+ assertFalse(msg.arguments.containsKey("marker"))
+ }
+
+ @Test
+ fun `named DEBUG override enables debug while the global level stays INFO`()
{
+ LogCapture.configureThresholds(Level.INFO, mapOf("com.example.Task" to
Level.DEBUG))
+
+ // AbstractLogger gates debug() on isDebugEnabled(); the per-logger
override must let it through.
+ assertTrue(logger.isDebugEnabled)
+ logger.debug("hello")
+
+ val messages = LogCapture.drain().filter { it.logger == "com.example.Task"
}
+ assertEquals(1, messages.size, "named DEBUG override should let debug
through")
+ assertEquals(Level.DEBUG, messages.single().level)
+
+ // A logger without an override still follows the global INFO threshold.
+ assertFalse(AirflowSlf4jLogger("com.other.Task").isDebugEnabled)
+ }
+}