This is an automated email from the ASF dual-hosted git repository.
vatsrahul1001 pushed a commit to branch v3-4-test
in repository https://gitbox.apache.org/repos/asf/airflow.git
commit 8744696c21e857b84e5f3628650531c07c78cd31
Author: github-actions[bot]
<41898282+github-actions[bot]@users.noreply.github.com>
AuthorDate: Fri Oct 30 21:27:25 2026 +0530
[v3-4-test] Honor Airflow logging levels in TypeScript SDK (#76005) (#76030)
* Honor Airflow logging levels in TypeScript SDK
* Harden TS SDK log level filtering
- Keep the helper method's level when args carry a `level` field, so a
non-string value can no longer crash `send()`.
- Filter levels outside LogLevel as INFO instead of letting them bypass
the threshold.
- Filter by the channel's own name, matching the `[name]` event prefix,
rather than a caller-supplied `logger` field.
- Report an invalid `logging_level` instead of silently using INFO.
- Drop FATAL so accepted names match structlog's NAME_TO_LEVEL.
- Extract namespace_levels parsing and resolve each channel's threshold
once at construction.
* Restore FATAL log level alias in TS SDK
Airflow's configure_logging adds a `fatal` alias to NAME_TO_LEVEL before
parsing logging_level and namespace_levels, so Python accepts FATAL.
Removing it made the TS SDK reject a setting the Python side honors.
* Address review: rename threshold to logLevel and trim comments
---------
(cherry picked from commit 5cc2413925ae71ecd5951195463d9df78081b55c)
Co-authored-by: Shivam Rastogi <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
---
ts-sdk/src/coordinator/log-channel.ts | 94 ++++++++++++++-
ts-sdk/tests/coordinator/log-channel.test.ts | 173 +++++++++++++++++++++++++++
2 files changed, 261 insertions(+), 6 deletions(-)
diff --git a/ts-sdk/src/coordinator/log-channel.ts
b/ts-sdk/src/coordinator/log-channel.ts
index 9dd85482c01..cb411a25e02 100644
--- a/ts-sdk/src/coordinator/log-channel.ts
+++ b/ts-sdk/src/coordinator/log-channel.ts
@@ -49,28 +49,106 @@ const DEFAULT_LOGGER_NAME = "ts-sdk";
const CLOSE_FLUSH_TIMEOUT_MS = 3_000;
+// Level names match Airflow's Python parser: structlog's NAME_TO_LEVEL plus
the `fatal` alias
+// that configure_logging adds.
+const LOG_LEVELS = new Map([
+ ["NOTSET", 0],
+ ["DEBUG", 10],
+ ["INFO", 20],
+ ["WARN", 30],
+ ["WARNING", 30],
+ ["ERROR", 40],
+ ["EXCEPTION", 40],
+ ["CRITICAL", 50],
+ ["FATAL", 50],
+]);
+
+const DEFAULT_LOG_LEVEL = LOG_LEVELS.get("INFO")!;
+
+function parseLogLevel(value: string | undefined): number | undefined {
+ return LOG_LEVELS.get(value?.trim().toUpperCase() ?? "");
+}
+
+/** Parse `namespace_levels`: `<logger>=<level>` pairs separated by whitespace
and/or commas.
+ * Later duplicates win; malformed entries are returned so the caller can
report them. */
+function parseNamespaceLevels(value: string | undefined): {
+ levels: Map<string, number>;
+ invalid: string[];
+} {
+ const levels = new Map<string, number>();
+ const invalid: string[] = [];
+ for (const entry of (value ?? "").split(/[\s,]+/).filter(Boolean)) {
+ const separator = entry.indexOf("=");
+ const level = parseLogLevel(entry.slice(separator + 1));
+ if (separator <= 0 || level === undefined) {
+ invalid.push(entry);
+ } else {
+ levels.set(entry.slice(0, separator), level);
+ }
+ }
+ return { levels, invalid };
+}
+
+/** Threshold for `name`: the most specific configured dotted ancestor, else
the global level. */
+function resolveLogLevel(shared: LogChannelState, name: string): number {
+ let namespace = name;
+ while (namespace) {
+ const level = shared.namespaceLevels.get(namespace);
+ if (level !== undefined) return level;
+ const separator = namespace.lastIndexOf(".");
+ if (separator < 0) break;
+ namespace = namespace.slice(0, separator);
+ }
+ return shared.level;
+}
+
interface LogChannelState {
sock: Socket | null;
connected: boolean;
closed: boolean;
buffer: string[];
+ level: number;
+ namespaceLevels: ReadonlyMap<string, number>;
}
export class LogChannel {
private readonly shared: LogChannelState;
private readonly name: string;
private readonly isRoot: boolean;
+ private readonly logLevel: number;
private constructor(shared: LogChannelState, name: string, isRoot: boolean) {
this.shared = shared;
this.name = name;
this.isRoot = isRoot;
+ this.logLevel = resolveLogLevel(shared, name);
}
/** Create a root channel with no socket yet; records are buffered
* until {@link LogChannel#connect} flushes them. */
static createBuffered(name: string = DEFAULT_LOGGER_NAME): LogChannel {
- return new LogChannel({ sock: null, connected: false, closed: false,
buffer: [] }, name, true);
+ const rawLevel = process.env["AIRFLOW__LOGGING__LOGGING_LEVEL"];
+ const level = parseLogLevel(rawLevel);
+ const namespaceLevels =
parseNamespaceLevels(process.env["AIRFLOW__LOGGING__NAMESPACE_LEVELS"]);
+ const root = new LogChannel(
+ {
+ sock: null,
+ connected: false,
+ closed: false,
+ buffer: [],
+ level: level ?? DEFAULT_LOG_LEVEL,
+ namespaceLevels: namespaceLevels.levels,
+ },
+ name,
+ true,
+ );
+ if (level === undefined && rawLevel?.trim()) {
+ root.error("Ignoring invalid logging_level; using INFO", {
logging_level: rawLevel });
+ }
+ for (const entry of namespaceLevels.invalid) {
+ root.error("Ignoring invalid namespace_levels entry", { entry });
+ }
+ return root;
}
/** Connect the socket and flush buffered records, in order. Root only,
@@ -116,13 +194,17 @@ export class LogChannel {
}
send(
- record: Omit<LogRecord, "timestamp" | "logger"> & {
+ record: Pick<LogRecord, "event" | "level"> & {
timestamp?: string;
logger?: string;
+ [key: string]: unknown;
},
): void {
// Drop late records after the log socket has closed.
if (this.shared.closed) return;
+ const level =
+ typeof record.level === "string" ?
LOG_LEVELS.get(record.level.toUpperCase()) : undefined;
+ if ((level ?? DEFAULT_LOG_LEVEL) < this.logLevel) return;
// Prepend the logger name to the event message so it surfaces in
// the Airflow UI task log view, which renders the message text but
// hides the `logger` JSON field. The field is still emitted for
@@ -148,19 +230,19 @@ export class LogChannel {
}
debug(event: string, args: Record<string, unknown> = {}): void {
- this.send({ event, level: "debug", ...args });
+ this.send({ event, ...args, level: "debug" });
}
info(event: string, args: Record<string, unknown> = {}): void {
- this.send({ event, level: "info", ...args });
+ this.send({ event, ...args, level: "info" });
}
warning(event: string, args: Record<string, unknown> = {}): void {
- this.send({ event, level: "warning", ...args });
+ this.send({ event, ...args, level: "warning" });
}
error(event: string, args: Record<string, unknown> = {}): void {
- this.send({ event, level: "error", ...args });
+ this.send({ event, ...args, level: "error" });
}
async close(): Promise<void> {
diff --git a/ts-sdk/tests/coordinator/log-channel.test.ts
b/ts-sdk/tests/coordinator/log-channel.test.ts
index f454b718cfc..6b7b6e3a251 100644
--- a/ts-sdk/tests/coordinator/log-channel.test.ts
+++ b/ts-sdk/tests/coordinator/log-channel.test.ts
@@ -62,11 +62,181 @@ describe("LogChannel", () => {
let fx: Fixture;
beforeEach(async () => {
+ vi.stubEnv("AIRFLOW__LOGGING__LOGGING_LEVEL", undefined);
+ vi.stubEnv("AIRFLOW__LOGGING__NAMESPACE_LEVELS", "");
fx = await makeServer();
});
afterEach(async () => {
fx.server.close();
+ vi.unstubAllEnvs();
+ });
+
+ it.each([
+ [undefined, ["info", "warning", "error"]],
+ ["", ["info", "warning", "error"]],
+ // Invalid values fall back to INFO and report themselves as the first
record.
+ ["VERBOSE", ["error", "info", "warning", "error"]],
+ ["INFO", ["info", "warning", "error"]],
+ [" debug ", ["debug", "info", "warning", "error"]],
+ ["WARNING", ["warning", "error"]],
+ ["WARN", ["warning", "error"]],
+ ["ERROR", ["error"]],
+ ["EXCEPTION", ["error"]],
+ ["CRITICAL", []],
+ ["FATAL", []],
+ ["NOTSET", ["debug", "info", "warning", "error"]],
+ ] as const)("filters socket records with logging_level=%s", async (level,
expected) => {
+ vi.stubEnv("AIRFLOW__LOGGING__LOGGING_LEVEL", level);
+ const root = await connectChannel(`127.0.0.1:${fx.port}`);
+ root.debug("debug");
+ root.info("info");
+ root.warning("warning");
+ root.error("error");
+ await root.close();
+ await fx.sockClosed;
+ expect(readRecords(fx.received).map((record) =>
record["level"])).toEqual(expected);
+ });
+
+ it("uses the nearest dotted namespace override and the last duplicate
entry", async () => {
+ vi.stubEnv("AIRFLOW__LOGGING__LOGGING_LEVEL", "ERROR");
+ vi.stubEnv(
+ "AIRFLOW__LOGGING__NAMESPACE_LEVELS",
+ " ts-sdk=WARNING, ts-sdk.comm=ERROR
ts-sdk.comm=INFO,ts-sdk.comm.deep=DEBUG ",
+ );
+ const root = await connectChannel(`127.0.0.1:${fx.port}`);
+ root.info("filtered at root");
+ root.warning("root warning");
+ root.child("comm").debug("filtered at comm");
+ root.child("comm").info("comm info");
+ root.child("comm").child("deep").child("child").debug("deep debug");
+ root.child("commander").info("partial namespace must not match");
+ root.child("commander").warning("inherited root warning");
+ await root.close();
+ await fx.sockClosed;
+ expect(readRecords(fx.received).map((record) => record["event"])).toEqual([
+ "[ts-sdk] root warning",
+ "[ts-sdk.comm] comm info",
+ "[ts-sdk.comm.deep.child] deep debug",
+ "[ts-sdk.commander] inherited root warning",
+ ]);
+ });
+
+ it("keeps valid namespace overrides and reports invalid entries once", async
() => {
+ vi.stubEnv(
+ "AIRFLOW__LOGGING__NAMESPACE_LEVELS",
+ "broken,=DEBUG,ts-sdk.comm=VERBOSE,ts-sdk.client=debug",
+ );
+ const root = await connectChannel(`127.0.0.1:${fx.port}`);
+ root.child("comm").debug("invalid override falls back to INFO");
+ root.child("client").debug("valid override");
+ root.child("client").debug("second record");
+ await root.close();
+ await fx.sockClosed;
+ const records = readRecords(fx.received);
+ expect(records.filter((record) => record["level"] === "error").map((r) =>
r["entry"])).toEqual([
+ "broken",
+ "=DEBUG",
+ "ts-sdk.comm=VERBOSE",
+ ]);
+ expect(records.filter((record) => record["level"] === "debug").map((r) =>
r["event"])).toEqual([
+ "[ts-sdk.client] valid override",
+ "[ts-sdk.client] second record",
+ ]);
+ });
+
+ it("filters before buffering and shares the initial configuration with later
children", async () => {
+ const root = LogChannel.createBuffered();
+ root.debug("filtered before connect");
+ root.info("buffered info");
+ vi.stubEnv("AIRFLOW__LOGGING__LOGGING_LEVEL", "DEBUG");
+ vi.stubEnv("AIRFLOW__LOGGING__NAMESPACE_LEVELS", "ts-sdk.comm=DEBUG");
+ root.child("comm").debug("filtered despite later environment change");
+ await root.connect(`127.0.0.1:${fx.port}`);
+ root.child("comm").info("connected info");
+ await root.close();
+ await fx.sockClosed;
+ expect(readRecords(fx.received).map((record) => record["event"])).toEqual([
+ "[ts-sdk] buffered info",
+ "[ts-sdk.comm] connected info",
+ ]);
+ });
+
+ it("filters records on stderr fallback as well as the socket", async () => {
+ const write = vi.spyOn(process.stderr, "write").mockImplementation(() =>
true);
+ const root = LogChannel.createBuffered();
+ try {
+ root.debug("filtered before connect");
+ root.info("buffered info");
+ await root.close();
+ expect(write.mock.calls).toHaveLength(1);
+ expect(JSON.parse(String(write.mock.calls[0]?.[0]))).toMatchObject({
+ event: "[ts-sdk] buffered info",
+ level: "info",
+ });
+ } finally {
+ write.mockRestore();
+ }
+ });
+
+ it("reports an invalid logging_level once", async () => {
+ vi.stubEnv("AIRFLOW__LOGGING__LOGGING_LEVEL", "TRACE");
+ const root = await connectChannel(`127.0.0.1:${fx.port}`);
+ root.debug("filtered at INFO");
+ await root.close();
+ await fx.sockClosed;
+ expect(readRecords(fx.received)).toMatchObject([
+ {
+ level: "error",
+ event: "[ts-sdk] Ignoring invalid logging_level; using INFO",
+ logging_level: "TRACE",
+ },
+ ]);
+ });
+
+ it("keeps the method's level when args carry a level field", async () => {
+ vi.stubEnv("AIRFLOW__LOGGING__LOGGING_LEVEL", "WARNING");
+ const root = await connectChannel(`127.0.0.1:${fx.port}`);
+ root.info("numeric level", { level: 3 });
+ root.debug("level escalation", { level: "error" });
+ root.error("level demotion", { level: "debug" });
+ await root.close();
+ await fx.sockClosed;
+ expect(readRecords(fx.received)).toMatchObject([
+ { event: "[ts-sdk] level demotion", level: "error" },
+ ]);
+ });
+
+ it("treats a level outside LogLevel as INFO instead of bypassing the
filter", async () => {
+ vi.stubEnv("AIRFLOW__LOGGING__LOGGING_LEVEL", "WARNING");
+ const root = await connectChannel(`127.0.0.1:${fx.port}`);
+ root.send({ event: "unknown", level: "trace" as never });
+ root.send({ event: "non-string", level: 7 as never });
+ root.warning("kept");
+ await root.close();
+ await fx.sockClosed;
+ expect(readRecords(fx.received).map((record) =>
record["event"])).toEqual(["[ts-sdk] kept"]);
+ });
+
+ it("filters by the channel name even when the record overrides logger",
async () => {
+ vi.stubEnv("AIRFLOW__LOGGING__NAMESPACE_LEVELS", "ts-sdk.client=DEBUG");
+ const root = await connectChannel(`127.0.0.1:${fx.port}`);
+ root.child("comm").debug("override does not unlock DEBUG", { logger:
"ts-sdk.client" });
+ root.child("comm").info("non-string logger", { logger: 123 });
+ await root.close();
+ await fx.sockClosed;
+ expect(readRecords(fx.received)).toMatchObject([
+ { event: "[ts-sdk.comm] non-string logger", logger: 123 },
+ ]);
+ });
+
+ it("applies namespace overrides to the invalid-entry report itself", async
() => {
+ vi.stubEnv("AIRFLOW__LOGGING__NAMESPACE_LEVELS", "broken ts-sdk=CRITICAL");
+ const root = await connectChannel(`127.0.0.1:${fx.port}`);
+ root.error("filtered at CRITICAL");
+ await root.close();
+ await fx.sockClosed;
+ expect(readRecords(fx.received)).toEqual([]);
});
it("defaults logger name to 'ts-sdk' and auto-stamps timestamp", async () =>
{
@@ -104,6 +274,7 @@ describe("LogChannel", () => {
});
it("child() creates a hierarchical sibling sharing the socket", async () => {
+ vi.stubEnv("AIRFLOW__LOGGING__LOGGING_LEVEL", "DEBUG");
const root = await connectChannel(`127.0.0.1:${fx.port}`);
const comm = root.child("comm");
const client = root.child("client");
@@ -156,6 +327,7 @@ describe("LogChannel", () => {
});
it("buffers records emitted before connect and flushes them in order on
connect", async () => {
+ vi.stubEnv("AIRFLOW__LOGGING__LOGGING_LEVEL", "DEBUG");
const root = LogChannel.createBuffered();
const child = root.child("runtime");
child.debug("connecting", { attempt: 1 });
@@ -284,6 +456,7 @@ describe("LogChannel", () => {
});
it("warns once and falls back to stderr after an unexpected disconnect",
async () => {
+ vi.stubEnv("AIRFLOW__LOGGING__LOGGING_LEVEL", "DEBUG");
const serverSocks: net.Socket[] = [];
const received: Buffer[] = [];
const server = net.createServer((sock) => {