This is an automated email from the ASF dual-hosted git repository.
henry3260 pushed a commit to branch v3-4-test
in repository https://gitbox.apache.org/repos/asf/airflow.git
The following commit(s) were added to refs/heads/v3-4-test by this push:
new 2104e114168 [v3-4-test] Go SDK: stop log attributes from displacing
standard log fields (#73820) (#75564)
2104e114168 is described below
commit 2104e114168ccd74737cd84bb7818ba4c0637e67
Author: github-actions[bot]
<41898282+github-actions[bot]@users.noreply.github.com>
AuthorDate: Sat Oct 24 16:49:50 2026 +0000
[v3-4-test] Go SDK: stop log attributes from displacing standard log fields
(#73820) (#75564)
* Go SDK: stop log attributes from displacing standard log fields
A user attribute whose key collides with a standard field replaced that
field instead of losing to it. "event" is the one that hurts: the
supervisor takes a task's log line out of that key, so a Dag logging an
attribute called "event" lost its message entirely, with nothing left in
the line to show the message had ever existed. "timestamp" was displaced
the same way, leaving the supervisor a value it cannot decode as a
datetime.
"level" and "logger" were already immune, for no reason other than being
written after the attributes rather than before them.
* Move a clashing log attribute aside instead of overwriting it
Letting the standard field win the slot still discarded a value the caller
asked to log. Reserving the slot and re-keying the attribute to
"fields.<key>" keeps both: the supervisor finds the message in "event" and
a parsable "timestamp", and the attribute survives under a name that
collides with nothing. logrus resolves the same clash the same way.
Rejecting the attribute outright, as structlog's TypeError does, is not
available here. slog discards a handler's error, so returning one would
drop the whole line, and a panic would surface in production rather than
fail fast at the call site.
(cherry picked from commit 9761167313d84d1367f3bb4735aa88242ad4c956)
Co-authored-by: Henry Chen <[email protected]>
---
go-sdk/pkg/execution/logger.go | 36 ++++++++++++++++++++++++++++-------
go-sdk/pkg/execution/logger_test.go | 38 +++++++++++++++++++++++++++++++++++++
2 files changed, 67 insertions(+), 7 deletions(-)
diff --git a/go-sdk/pkg/execution/logger.go b/go-sdk/pkg/execution/logger.go
index bb554523c40..c577e3278a3 100644
--- a/go-sdk/pkg/execution/logger.go
+++ b/go-sdk/pkg/execution/logger.go
@@ -47,7 +47,8 @@ const (
// - "event" for the log message (not "msg")
// - "level" in lowercase (not "INFO"/"ERROR")
// - "timestamp" in RFC3339Nano format (not "time")
-// - Additional attributes are included as top-level fields
+// - Additional attributes are included as top-level fields; one whose key
+// collides with a standard field is moved to "fields.<key>"
//
// Groups are encoded as dotted key prefixes on a flat JSON object
// (`{"grp.key": "val"}`), not as nested objects. The Airflow supervisor's
@@ -236,12 +237,6 @@ func (h *SocketLogHandler) Handle(_ context.Context, r
slog.Record) error {
entry := make(map[string]any)
- // Set standard fields.
- entry["event"] = r.Message
- if !r.Time.IsZero() {
- entry["timestamp"] = r.Time.Format(time.RFC3339Nano)
- }
-
// Apply pre-configured attrs. Keys are already qualified with the
groups
// active at the WithAttrs call site, so the current h.groups is NOT
// applied here — only to record-level attrs below. The stored key
already
@@ -259,6 +254,12 @@ func (h *SocketLogHandler) Handle(_ context.Context, r
slog.Record) error {
return true
})
+ moveClashingAttrs(entry)
+
+ entry["event"] = r.Message
+ if !r.Time.IsZero() {
+ entry["timestamp"] = r.Time.Format(time.RFC3339Nano)
+ }
if loggerName != "" {
entry["logger"] = loggerName
}
@@ -282,6 +283,27 @@ func (h *SocketLogHandler) Handle(_ context.Context, r
slog.Record) error {
return err
}
+// standardLogFields are the keys SocketLogHandler writes itself on every line.
+// The supervisor takes a task's message out of "event" and decodes "timestamp"
+// as a datetime, so an attribute must never end up occupying those slots.
+var standardLogFields = []string{"event", "timestamp", "level", "logger"}
+
+// clashedAttrPrefix qualifies an attribute whose key collides with a standard
+// field, the way logrus does. Letting the standard field simply overwrite it
+// would discard a value the caller asked to log.
+const clashedAttrPrefix = "fields."
+
+// moveClashingAttrs re-keys any attribute sitting in a standard field's slot,
+// so that writing the standard field afterwards keeps both values.
+func moveClashingAttrs(entry map[string]any) {
+ for _, key := range standardLogFields {
+ if value, ok := entry[key]; ok {
+ entry[clashedAttrPrefix+key] = value
+ delete(entry, key)
+ }
+ }
+}
+
func (h *SocketLogHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
if len(attrs) == 0 {
return h
diff --git a/go-sdk/pkg/execution/logger_test.go
b/go-sdk/pkg/execution/logger_test.go
index 19a67ad59dd..39769e3d8f3 100644
--- a/go-sdk/pkg/execution/logger_test.go
+++ b/go-sdk/pkg/execution/logger_test.go
@@ -25,6 +25,7 @@ import (
"log/slog"
"strings"
"testing"
+ "time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -355,6 +356,43 @@ func TestSocketLogHandlerStandardFieldsOverrideAttrs(t
*testing.T) {
require.NoError(t,
json.Unmarshal([]byte(strings.TrimSpace(buf.String())), &entry))
assert.Equal(t, "info", entry["level"])
assert.Equal(t, "example", entry["logger"])
+ assert.Equal(t, "custom", entry["fields.level"])
+ assert.Equal(t, "custom", entry["fields.logger"])
+}
+
+func TestSocketLogHandlerMovesAttrsClashingWithStandardFields(t *testing.T) {
+ for _, tt := range []struct {
+ key string
+ val any
+ want any
+ }{
+ {key: "event", val: "user value", want: "user value"},
+ {key: "timestamp", val: 12345, want: float64(12345)},
+ {key: "level", val: "custom", want: "custom"},
+ {key: "logger", val: "custom", want: "custom"},
+ } {
+ t.Run(tt.key, func(t *testing.T) {
+ var buf bytes.Buffer
+ handler := NewSocketLogHandler(&buf, slog.LevelDebug)
+ logger := slog.New(handler).With(tt.key, tt.val)
+
+ logger.Info("the real message")
+
+ var entry map[string]any
+ require.NoError(t,
json.Unmarshal([]byte(strings.TrimSpace(buf.String())), &entry))
+
+ assert.Equal(t, "the real message", entry["event"])
+ assert.Equal(t, "info", entry["level"])
+
+ ts, ok := entry["timestamp"].(string)
+ require.True(t, ok, "timestamp should stay a string,
got %T", entry["timestamp"])
+ _, err := time.Parse(time.RFC3339Nano, ts)
+ assert.NoError(t, err, "timestamp should stay
RFC3339Nano")
+
+ assert.Equal(t, tt.want, entry["fields."+tt.key])
+ assert.NotEqual(t, tt.want, entry[tt.key], "reserved
slot must not keep the attr")
+ })
+ }
}
// TestSocketLogHandlerStringifiesErrorAttr verifies that an attribute whose