blob: bb554523c40815eeb625fedddf3348f45c0ce447 [file]
// 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 execution
import (
"context"
"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
// to the logs TCP socket. Each log entry is a single JSON object followed by
// a newline, matching the Airflow log streaming format.
//
// Key mapping:
// - "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
//
// Groups are encoded as dotted key prefixes on a flat JSON object
// (`{"grp.key": "val"}`), not as nested objects. The Airflow supervisor's
// log-streaming format consumes flat top-level fields, so emitting nested
// objects here would not be parsed correctly. Do not change this without
// updating the supervisor side in lockstep.
type SocketLogHandler struct {
shared *socketLogHandlerShared
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
// them — matching the slog.Handler contract that groups apply only to
// subsequently-added attributes.
attrs []prefixedAttr
groups []string
}
// prefixedAttr is an attribute whose key has been pre-qualified with the
// dotted prefix of the groups that were active when it was added via
// WithAttrs. The slog.Value is kept unresolved so LogValuer attributes are
// still evaluated lazily at Handle time.
type prefixedAttr struct {
key string
value slog.Value
}
// socketLogHandlerShared holds the writer and buffer that must remain shared
// across WithAttrs / WithGroup clones; otherwise the sync.Mutex would be
// copied (which the runtime detector flags as a bug).
type socketLogHandlerShared struct {
mu sync.Mutex
writer io.Writer
buf [][]byte
connected bool
}
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
shared.connected = true
}
return &SocketLogHandler{
shared: shared,
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()
defer h.shared.mu.Unlock()
h.shared.writer = w
h.shared.connected = true
for _, line := range h.shared.buf {
_, _ = w.Write(line)
}
h.shared.buf = nil
}
func (h *SocketLogHandler) Enabled(_ context.Context, level slog.Level) bool {
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
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
// carries its prefix, so append with an empty prefix; any group value is
// still expanded under that key.
for _, a := range h.attrs {
appendAttr(entry, "", slog.Attr{Key: a.key, Value: a.value})
}
// Apply record attrs. These were added at Handle time, so they pick up
// the currently-active group prefix.
prefix := h.groupPrefix()
r.Attrs(func(a slog.Attr) bool {
appendAttr(entry, prefix, a)
return true
})
if loggerName != "" {
entry["logger"] = loggerName
}
entry["level"] = getAirflowLogLevelName(r.Level)
line, err := json.Marshal(entry)
if err != nil {
return err
}
line = append(line, '\n')
h.shared.mu.Lock()
defer h.shared.mu.Unlock()
if !h.shared.connected {
h.shared.buf = append(h.shared.buf, line)
return nil
}
_, err = h.shared.writer.Write(line)
return err
}
func (h *SocketLogHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
if len(attrs) == 0 {
return h
}
// Qualify each new attr's key with the groups active right now, then
// freeze the result. A later WithGroup must not retroactively re-prefix
// these.
prefix := h.groupPrefix()
newAttrs := make([]prefixedAttr, len(h.attrs), len(h.attrs)+len(attrs))
copy(newAttrs, h.attrs)
for _, a := range attrs {
newAttrs = append(newAttrs, prefixedAttr{
key: prefix + a.Key,
value: a.Value,
})
}
return &SocketLogHandler{
shared: h.shared,
filter: h.filter,
attrs: newAttrs,
groups: h.groups,
}
}
func (h *SocketLogHandler) WithGroup(name string) slog.Handler {
if name == "" {
return h
}
return &SocketLogHandler{
shared: h.shared,
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.
func (h *SocketLogHandler) groupPrefix() string {
if len(h.groups) == 0 {
return ""
}
return strings.Join(h.groups, ".") + "."
}
// appendAttr writes a single attribute into entry under prefix+key. A group
// value is expanded recursively into dotted keys (matching WithGroup), so an
// inline slog.Group("req", slog.String("method", "GET")) becomes
// {"req.method": "GET"} rather than being dropped as "{}". Following the
// slog.Handler contract, an empty attr is skipped, a group with no attrs is
// ignored, and a group with an empty key is inlined at the current prefix.
func appendAttr(entry map[string]any, prefix string, a slog.Attr) {
a.Value = a.Value.Resolve()
if a.Equal(slog.Attr{}) {
return
}
if a.Value.Kind() == slog.KindGroup {
groupAttrs := a.Value.Group()
if len(groupAttrs) == 0 {
return
}
groupPrefix := prefix
if a.Key != "" {
groupPrefix = prefix + a.Key + "."
}
for _, ga := range groupAttrs {
appendAttr(entry, groupPrefix, ga)
}
return
}
entry[prefix+a.Key] = resolveAttrValue(a.Value)
}
// resolveAttrValue returns the JSON-friendly representation of a slog.Value.
// It dereferences slog.LogValuer via Resolve() and then stringifies an error
// (whose unexported fields would otherwise marshal as "{}"), matching the
// behaviour of slog.NewJSONHandler. Non-error values pass through unchanged.
func resolveAttrValue(v slog.Value) any {
resolved := v.Resolve().Any()
if err, ok := resolved.(error); ok {
return err.Error()
}
return resolved
}