This is an automated email from the ASF dual-hosted git repository.

jason810496 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 577496acc3b Honor Airflow log levels in Go task logging (#71882)
577496acc3b is described below

commit 577496acc3b99767c24e4146e02f1a0c308d6a95
Author: Jason(Zhe-You) Liu <[email protected]>
AuthorDate: Sun Sep 6 21:05:46 2026 -0500

    Honor Airflow log levels in Go task logging (#71882)
    
    * Honor Airflow log levels in Go task logging
    
    Coordinator-mode Go tasks currently transmit records below Airflow's 
configured thresholds, diverging from Python task behavior and overstating 
logging compatibility.
    
    * Normalize custom Go task log levels
    
    Custom slog levels should remain visible in task logs instead of being
    silently discarded when they do not exactly match an Airflow severity.
    
    * Use slog groups for Go task log namespaces
    
    Group-scoped loggers provide an idiomatic namespace that allows Airflow to
    apply namespace thresholds before constructing each record.
    
    * Align Go namespace log levels with Python tasks
    
    Shared namespace logging configuration should filter Go and Python task 
records consistently.
    
    * Report invalid Go namespace log levels
    
    Configuration typos must be visible instead of silently changing which Go 
task records reach Airflow.
    
    * Verify custom Go task log level normalization
    
    Filtering behavior alone cannot protect the level name that Airflow 
receives for custom slog levels.
    
    * Protect Go task log metadata from user attributes
    
    Airflow's level and logger fields must remain authoritative when task code 
uses the same attribute names.
---
 go-sdk/README.md                         |  11 +++
 go-sdk/pkg/execution/integration_test.go |  69 ++++++++++++++
 go-sdk/pkg/execution/logger.go           | 146 ++++++++++++++++++++++++++--
 go-sdk/pkg/execution/logger_test.go      | 158 ++++++++++++++++++++++++++++++-
 go-sdk/pkg/execution/server.go           |   2 +-
 5 files changed, 378 insertions(+), 8 deletions(-)

diff --git a/go-sdk/README.md b/go-sdk/README.md
index 360e185a9a5..8fb6564c9e7 100644
--- a/go-sdk/README.md
+++ b/go-sdk/README.md
@@ -194,6 +194,17 @@ func extract(ctx sdk.TIRunContext, log *slog.Logger) (any, 
error) {
 `TryNumber`; `ctx.DagRun()` returns `DagID`, `RunID`, and the `*time.Time` 
fields `LogicalDate`,
 `DataIntervalStart`, and `DataIntervalEnd` (nil when the run has no such 
value, e.g. a manual trigger).
 
+### Task logging
+
+In coordinator mode, the injected logger filters records using Airflow's 
configured `[logging] logging_level` before sending them to the supervisor. 
Airflow also propagates `[logging] namespace_levels`; use a group-scoped logger 
to set the namespace:
+
+```go
+databaseLog := log.WithGroup("example.database")
+databaseLog.Debug("query complete", "rows", 42)
+```
+
+Namespace levels use longest dotted-prefix matching, so an `example=DEBUG` 
override also applies to `example.database` unless a more specific override 
takes precedence.
+
 ## Deployment
 
 A Python task runner executes the Go task directly, with no separate Go worker 
process to run on the host.
diff --git a/go-sdk/pkg/execution/integration_test.go 
b/go-sdk/pkg/execution/integration_test.go
index da6b10784fe..482165d887d 100644
--- a/go-sdk/pkg/execution/integration_test.go
+++ b/go-sdk/pkg/execution/integration_test.go
@@ -20,10 +20,12 @@ package execution
 import (
        "bytes"
        "context"
+       "encoding/json"
        "errors"
        "io"
        "log/slog"
        "net"
+       "strings"
        "testing"
        "time"
 
@@ -660,6 +662,73 @@ func TestServeStartupDetailsEndToEnd(t *testing.T) {
        }
 }
 
+func TestServeUsesSupervisorLogLevelEnvironment(t *testing.T) {
+       t.Setenv(loggingLevelEnv, "ERROR")
+       t.Setenv(namespaceLevelsEnv, "example=DEBUG")
+
+       commAddr, logsAddr, commCh, logsCh, cleanup := startSupervisor(t)
+       defer cleanup()
+
+       provider := &fakeProvider{
+               register: func(r bundlev1.Registry) error {
+                       r.AddDag("dag1").AddTaskWithName("logging", func(logger 
*slog.Logger) error {
+                               logger.Info("global filtered")
+                               
logger.WithGroup("example.child").Debug("namespace debug")
+                               logger.WithGroup("unrelated").Warn("unrelated 
filtered")
+                               logger.Error("global error")
+                               return nil
+                       })
+                       return nil
+               },
+       }
+
+       done := make(chan error, 1)
+       go func() { done <- Serve(provider, commAddr, logsAddr) }()
+
+       commConn := <-commCh
+       defer commConn.Close()
+       logsConn := <-logsCh
+       defer logsConn.Close()
+       require.NoError(t, commConn.SetDeadline(time.Now().Add(10*time.Second)))
+       require.NoError(t, logsConn.SetDeadline(time.Now().Add(10*time.Second)))
+
+       payload, err := encodeRequest(0, map[string]any{
+               "type": "StartupDetails",
+               "ti": map[string]any{
+                       "id":         "550e8400-e29b-41d4-a716-446655440000",
+                       "dag_id":     "dag1",
+                       "task_id":    "logging",
+                       "run_id":     "run1",
+                       "try_number": 1,
+               },
+               "bundle_info": map[string]any{"name": "fake", "version": "1.0"},
+       })
+       require.NoError(t, err)
+       require.NoError(t, writeFrame(commConn, payload))
+
+       frame, err := readFrame(commConn)
+       require.NoError(t, err)
+       require.True(t, isNilRaw(frame.Err))
+       assert.Equal(t, "SucceedTask", peekBodyType(frame.Body))
+
+       select {
+       case err := <-done:
+               require.NoError(t, err)
+       case <-time.After(2 * time.Second):
+               t.Fatal("Serve did not return after task completion")
+       }
+
+       output, err := io.ReadAll(logsConn)
+       require.NoError(t, err)
+       var events []string
+       for line := range strings.Lines(string(output)) {
+               var entry map[string]any
+               require.NoError(t, json.Unmarshal([]byte(line), &entry))
+               events = append(events, entry["event"].(string))
+       }
+       assert.Equal(t, []string{"namespace debug", "global error"}, events)
+}
+
 // TestServeClientRoundTripEndToEnd drives a task that calls back into the
 // supervisor mid-execution, so the comm dispatcher's request/response
 // multiplexing is exercised against the real Serve rather than only the
diff --git a/go-sdk/pkg/execution/logger.go b/go-sdk/pkg/execution/logger.go
index 05de45c01f7..bb554523c40 100644
--- a/go-sdk/pkg/execution/logger.go
+++ b/go-sdk/pkg/execution/logger.go
@@ -22,9 +22,21 @@ import (
        "encoding/json"
        "io"
        "log/slog"
+       "os"
        "strings"
        "sync"
        "time"
+       "unicode"
+)
+
+const (
+       loggingLevelEnv    = "AIRFLOW__LOGGING__LOGGING_LEVEL"
+       namespaceLevelsEnv = "AIRFLOW__LOGGING__NAMESPACE_LEVELS"
+
+       // NOTSET sits below every supported record level. CRITICAL sits above 
ERROR
+       // because slog has no built-in equivalents for either Airflow level.
+       notsetLogLevel   = slog.Level(-100)
+       criticalLogLevel = slog.LevelError + 4
 )
 
 // SocketLogHandler is an slog.Handler that streams structured JSON log lines
@@ -44,7 +56,7 @@ import (
 // updating the supervisor side in lockstep.
 type SocketLogHandler struct {
        shared *socketLogHandlerShared
-       level  slog.Level
+       filter *logLevelFilter
        // attrs is the list of attributes accumulated via WithAttrs. Each 
entry's
        // key has already been qualified with whatever groups were active at 
the
        // WithAttrs call site, so a later WithGroup does NOT retroactively 
prefix
@@ -75,9 +87,35 @@ type socketLogHandlerShared struct {
 
 var _ slog.Handler = (*SocketLogHandler)(nil)
 
+type logLevelFilter struct {
+       defaultLevel    slog.Level
+       namespaceLevels map[string]slog.Level
+}
+
 // NewSocketLogHandler creates a new handler. If writer is nil, messages are
 // buffered until Connect() is called.
 func NewSocketLogHandler(writer io.Writer, level slog.Level) *SocketLogHandler 
{
+       return newSocketLogHandler(writer, newLogLevelFilter(level, nil))
+}
+
+func newSocketLogHandlerFromEnv(writer io.Writer) *SocketLogHandler {
+       defaultLevel, ok := parseLogLevel(os.Getenv(loggingLevelEnv))
+       if !ok {
+               defaultLevel = slog.LevelInfo
+       }
+       namespaceLevels, invalidEntries := 
parseNamespaceLogLevels(os.Getenv(namespaceLevelsEnv))
+       handler := newSocketLogHandler(
+               writer,
+               newLogLevelFilter(defaultLevel, namespaceLevels),
+       )
+       logger := slog.New(handler)
+       for _, invalidEntry := range invalidEntries {
+               logger.Error("Ignoring invalid namespace_levels entry", 
"entry", invalidEntry)
+       }
+       return handler
+}
+
+func newSocketLogHandler(writer io.Writer, filter *logLevelFilter) 
*SocketLogHandler {
        shared := &socketLogHandlerShared{}
        if writer != nil {
                shared.writer = writer
@@ -85,10 +123,93 @@ func NewSocketLogHandler(writer io.Writer, level 
slog.Level) *SocketLogHandler {
        }
        return &SocketLogHandler{
                shared: shared,
-               level:  level,
+               filter: filter,
+       }
+}
+
+func newLogLevelFilter(
+       defaultLevel slog.Level,
+       namespaceLevels map[string]slog.Level,
+) *logLevelFilter {
+       return &logLevelFilter{
+               defaultLevel:    defaultLevel,
+               namespaceLevels: namespaceLevels,
+       }
+}
+
+func parseLogLevel(value string) (slog.Level, bool) {
+       switch strings.ToUpper(strings.TrimSpace(value)) {
+       case "NOTSET":
+               return notsetLogLevel, true
+       case "DEBUG":
+               return slog.LevelDebug, true
+       case "INFO":
+               return slog.LevelInfo, true
+       case "WARN", "WARNING":
+               return slog.LevelWarn, true
+       case "ERROR", "EXCEPTION":
+               return slog.LevelError, true
+       case "CRITICAL", "FATAL":
+               return criticalLogLevel, true
+       default:
+               return 0, false
+       }
+}
+
+func getAirflowLogLevelName(level slog.Level) string {
+       switch {
+       case level < slog.LevelDebug:
+               return "notset"
+       case level < slog.LevelInfo:
+               return "debug"
+       case level < slog.LevelWarn:
+               return "info"
+       case level < slog.LevelError:
+               return "warning"
+       case level < criticalLogLevel:
+               return "error"
+       default:
+               return "critical"
        }
 }
 
+func parseNamespaceLogLevels(value string) (map[string]slog.Level, []string) {
+       levels := make(map[string]slog.Level)
+       var invalidEntries []string
+       entries := strings.FieldsFunc(value, func(r rune) bool {
+               return r == ',' || unicode.IsSpace(r)
+       })
+       for _, entry := range entries {
+               loggerName, levelName, ok := strings.Cut(entry, "=")
+               loggerName = strings.TrimSpace(loggerName)
+               level, validLevel := parseLogLevel(levelName)
+               if !ok || loggerName == "" || !validLevel {
+                       invalidEntries = append(invalidEntries, entry)
+                       continue
+               }
+               levels[loggerName] = level
+       }
+       return levels, invalidEntries
+}
+
+func (f *logLevelFilter) getLevel(loggerName string) slog.Level {
+       for namespace := loggerName; namespace != ""; {
+               if level, ok := f.namespaceLevels[namespace]; ok {
+                       return level
+               }
+               separator := strings.LastIndexByte(namespace, '.')
+               if separator == -1 {
+                       break
+               }
+               namespace = namespace[:separator]
+       }
+       return f.defaultLevel
+}
+
+func (f *logLevelFilter) isEnabled(loggerName string, level slog.Level) bool {
+       return level >= f.getLevel(loggerName)
+}
+
 // Connect sets the writer and flushes any buffered log messages.
 func (h *SocketLogHandler) Connect(w io.Writer) {
        h.shared.mu.Lock()
@@ -104,15 +225,19 @@ func (h *SocketLogHandler) Connect(w io.Writer) {
 }
 
 func (h *SocketLogHandler) Enabled(_ context.Context, level slog.Level) bool {
-       return level >= h.level
+       return h.filter.isEnabled(h.getLoggerName(), level)
 }
 
 func (h *SocketLogHandler) Handle(_ context.Context, r slog.Record) error {
+       loggerName := h.getLoggerName()
+       if !h.filter.isEnabled(loggerName, r.Level) {
+               return nil
+       }
+
        entry := make(map[string]any)
 
        // Set standard fields.
        entry["event"] = r.Message
-       entry["level"] = strings.ToLower(r.Level.String())
        if !r.Time.IsZero() {
                entry["timestamp"] = r.Time.Format(time.RFC3339Nano)
        }
@@ -134,6 +259,11 @@ func (h *SocketLogHandler) Handle(_ context.Context, r 
slog.Record) error {
                return true
        })
 
+       if loggerName != "" {
+               entry["logger"] = loggerName
+       }
+       entry["level"] = getAirflowLogLevelName(r.Level)
+
        line, err := json.Marshal(entry)
        if err != nil {
                return err
@@ -170,7 +300,7 @@ func (h *SocketLogHandler) WithAttrs(attrs []slog.Attr) 
slog.Handler {
        }
        return &SocketLogHandler{
                shared: h.shared,
-               level:  h.level,
+               filter: h.filter,
                attrs:  newAttrs,
                groups: h.groups,
        }
@@ -182,12 +312,16 @@ func (h *SocketLogHandler) WithGroup(name string) 
slog.Handler {
        }
        return &SocketLogHandler{
                shared: h.shared,
-               level:  h.level,
+               filter: h.filter,
                attrs:  h.attrs,
                groups: append(append([]string{}, h.groups...), name),
        }
 }
 
+func (h *SocketLogHandler) getLoggerName() string {
+       return strings.Join(h.groups, ".")
+}
+
 // groupPrefix returns the dotted prefix (including trailing ".") to apply to
 // attribute keys for the currently-active groups, or "" if no group is
 // active.
diff --git a/go-sdk/pkg/execution/logger_test.go 
b/go-sdk/pkg/execution/logger_test.go
index ee39fef69f0..19a67ad59dd 100644
--- a/go-sdk/pkg/execution/logger_test.go
+++ b/go-sdk/pkg/execution/logger_test.go
@@ -19,6 +19,7 @@ package execution
 
 import (
        "bytes"
+       "context"
        "encoding/json"
        "errors"
        "log/slog"
@@ -64,7 +65,146 @@ func TestSocketLogHandlerLevelFiltering(t *testing.T) {
        var entry map[string]any
        require.NoError(t, json.Unmarshal([]byte(lines[0]), &entry))
        assert.Equal(t, "should appear", entry["event"])
-       assert.Equal(t, "warn", entry["level"])
+       assert.Equal(t, "warning", entry["level"])
+}
+
+func TestParseLogLevel(t *testing.T) {
+       tests := map[string]slog.Level{
+               "notset":    notsetLogLevel,
+               "DEBUG":     slog.LevelDebug,
+               " info ":    slog.LevelInfo,
+               "WARN":      slog.LevelWarn,
+               "warning":   slog.LevelWarn,
+               "ERROR":     slog.LevelError,
+               "exception": slog.LevelError,
+               "critical":  criticalLogLevel,
+               "FATAL":     criticalLogLevel,
+       }
+       for value, expected := range tests {
+               level, ok := parseLogLevel(value)
+               assert.True(t, ok, value)
+               assert.Equal(t, expected, level, value)
+       }
+
+       _, ok := parseLogLevel("verbose")
+       assert.False(t, ok)
+}
+
+func TestGetAirflowLogLevelName(t *testing.T) {
+       tests := map[slog.Level]string{
+               notsetLogLevel:       "notset",
+               slog.LevelDebug - 1:  "notset",
+               slog.LevelDebug:      "debug",
+               slog.LevelDebug + 1:  "debug",
+               slog.LevelInfo - 1:   "debug",
+               slog.LevelInfo:       "info",
+               slog.LevelInfo + 1:   "info",
+               slog.LevelWarn - 1:   "info",
+               slog.LevelWarn:       "warning",
+               slog.LevelWarn + 1:   "warning",
+               slog.LevelError - 1:  "warning",
+               slog.LevelError:      "error",
+               slog.LevelError + 1:  "error",
+               criticalLogLevel - 1: "error",
+               criticalLogLevel:     "critical",
+               criticalLogLevel + 1: "critical",
+       }
+       for level, expected := range tests {
+               assert.Equal(t, expected, getAirflowLogLevelName(level), level)
+       }
+}
+
+func TestParseNamespaceLogLevels(t *testing.T) {
+       levels, invalidEntries := parseNamespaceLogLevels(
+               "example=INFO, malformed example.detail=DEBUG example=WARNING 
=ERROR empty= unknown=VERBOSE",
+       )
+       assert.Equal(t, map[string]slog.Level{
+               "example":        slog.LevelWarn,
+               "example.detail": slog.LevelDebug,
+       }, levels)
+       assert.Equal(t, []string{"malformed", "=ERROR", "empty=", 
"unknown=VERBOSE"}, invalidEntries)
+}
+
+func TestSocketLogHandlerReportsInvalidNamespaceLevels(t *testing.T) {
+       t.Setenv(loggingLevelEnv, "INFO")
+       invalidEntry := "example=DEB" + "GU"
+       t.Setenv(namespaceLevelsEnv, invalidEntry)
+
+       var buf bytes.Buffer
+       newSocketLogHandlerFromEnv(&buf)
+
+       var entry map[string]any
+       require.NoError(t, 
json.Unmarshal([]byte(strings.TrimSpace(buf.String())), &entry))
+       assert.Equal(t, "Ignoring invalid namespace_levels entry", 
entry["event"])
+       assert.Equal(t, invalidEntry, entry["entry"])
+       assert.Equal(t, "error", entry["level"])
+}
+
+func TestLogLevelFilterUsesLongestNamespacePrefix(t *testing.T) {
+       filter := newLogLevelFilter(slog.LevelError, map[string]slog.Level{
+               "example":        slog.LevelInfo,
+               "example.detail": slog.LevelDebug,
+       })
+
+       assert.Equal(t, slog.LevelDebug, 
filter.getLevel("example.detail.child"))
+       assert.Equal(t, slog.LevelInfo, filter.getLevel("example.other"))
+       assert.Equal(t, slog.LevelError, filter.getLevel("exampled"))
+       assert.Equal(t, slog.LevelError, filter.getLevel(""))
+}
+
+func TestSocketLogHandlerUsesEnvironmentLevelFiltering(t *testing.T) {
+       t.Setenv(loggingLevelEnv, "ERROR")
+       t.Setenv(namespaceLevelsEnv, "example=DEBUG, example.noisy=WARNING")
+
+       var buf bytes.Buffer
+       logger := slog.New(newSocketLogHandlerFromEnv(&buf))
+       logger.Info("global filtered")
+       logger.WithGroup("example.detail").Debug("namespace debug")
+       logger.WithGroup("example.detail").Log(context.Background(), 
slog.LevelDebug+1, "custom level")
+       logger.WithGroup("example.noisy.child").Info("namespace filtered")
+       logger.WithGroup("example.noisy.child").Warn("namespace warning")
+       logger.WithGroup("unrelated").Warn("unrelated filtered")
+       logger.Error("global error")
+
+       var entries []map[string]any
+       for line := range strings.Lines(buf.String()) {
+               var entry map[string]any
+               require.NoError(t, json.Unmarshal([]byte(line), &entry))
+               entries = append(entries, entry)
+       }
+       require.Len(t, entries, 4)
+       assert.Equal(t, "namespace debug", entries[0]["event"])
+       assert.Equal(t, "example.detail", entries[0]["logger"])
+       assert.Equal(t, "custom level", entries[1]["event"])
+       assert.Equal(t, "debug", entries[1]["level"])
+       assert.Equal(t, "namespace warning", entries[2]["event"])
+       assert.Equal(t, "example.noisy.child", entries[2]["logger"])
+       assert.Equal(t, "global error", entries[3]["event"])
+       assert.NotContains(t, entries[3], "logger")
+}
+
+func TestSocketLogHandlerUsesGroupNamespaceInEnabled(t *testing.T) {
+       filter := newLogLevelFilter(slog.LevelError, 
map[string]slog.Level{"example": slog.LevelDebug})
+       handler := newSocketLogHandler(nil, filter)
+
+       assert.True(
+               t,
+               
handler.WithGroup("example.detail").Enabled(context.Background(), 
slog.LevelDebug),
+       )
+       assert.False(t, 
handler.WithGroup("unrelated").Enabled(context.Background(), slog.LevelWarn))
+}
+
+func TestSocketLogHandlerDefaultsInvalidEnvironmentLevelToInfo(t *testing.T) {
+       t.Setenv(loggingLevelEnv, "VERBOSE")
+       t.Setenv(namespaceLevelsEnv, "")
+
+       var buf bytes.Buffer
+       logger := slog.New(newSocketLogHandlerFromEnv(&buf))
+       logger.Debug("filtered")
+       logger.Info("included")
+
+       assert.Contains(t, buf.String(), `"event":"included"`)
+       assert.NotContains(t, buf.String(), "filtered")
 }
 
 func TestSocketLogHandlerBuffering(t *testing.T) {
@@ -111,6 +251,7 @@ func TestSocketLogHandlerWithGroup(t *testing.T) {
        var entry map[string]any
        require.NoError(t, 
json.Unmarshal([]byte(strings.TrimSpace(buf.String())), &entry))
        assert.Equal(t, "val", entry["grp.key"])
+       assert.Equal(t, "grp", entry["logger"])
 }
 
 // TestSocketLogHandlerInlineGroup verifies that an inline slog.Group passed as
@@ -201,6 +342,21 @@ func TestSocketLogHandlerKeyMapping(t *testing.T) {
        assert.False(t, hasTime)
 }
 
+func TestSocketLogHandlerStandardFieldsOverrideAttrs(t *testing.T) {
+       var buf bytes.Buffer
+       handler := NewSocketLogHandler(&buf, slog.LevelDebug)
+       logger := slog.New(handler).
+               With("level", "custom", "logger", "custom").
+               WithGroup("example")
+
+       logger.Info("message")
+
+       var entry map[string]any
+       require.NoError(t, 
json.Unmarshal([]byte(strings.TrimSpace(buf.String())), &entry))
+       assert.Equal(t, "info", entry["level"])
+       assert.Equal(t, "example", entry["logger"])
+}
+
 // TestSocketLogHandlerStringifiesErrorAttr verifies that an attribute whose
 // value is a stock `error` is rendered as its .Error() string. Without value
 // resolution the underlying struct's unexported fields would be marshaled as
diff --git a/go-sdk/pkg/execution/server.go b/go-sdk/pkg/execution/server.go
index d4a4d43f157..a69c2303db8 100644
--- a/go-sdk/pkg/execution/server.go
+++ b/go-sdk/pkg/execution/server.go
@@ -92,7 +92,7 @@ func Serve(provider bundlev1.BundleProvider, commAddr, 
logsAddr string) error {
        // Buffer log records until the logs socket is connected. Anything the
        // runtime emits between Connect-time and the first frame still gets
        // flushed.
-       logHandler := NewSocketLogHandler(nil, slog.LevelDebug)
+       logHandler := newSocketLogHandlerFromEnv(nil)
        logger := slog.New(logHandler)
        slog.SetDefault(logger)
 

Reply via email to