From b2a35e023079d7f7d519d571b0c50888e2d37b18 Mon Sep 17 00:00:00 2001 From: pierrejeambrun Date: Thu, 23 Jul 2026 13:27:54 +0200 Subject: [PATCH] Highlight user-code frames in task log tracebacks In an error traceback the frame that actually failed is usually in the user's DAG code, but it sits visually indistinguishable among the framework frames from installed packages. Emphasising user-code frames and muting installed-package frames lets users find where the error originates at a glance. --- .../components/renderStructuredLog.test.tsx | 41 +++++++++++++++++++ .../ui/src/components/renderStructuredLog.tsx | 16 ++++++-- .../src/airflow/ui/src/utils/logs.test.ts | 21 +++++++++- airflow-core/src/airflow/ui/src/utils/logs.ts | 5 +++ 4 files changed, 79 insertions(+), 4 deletions(-) diff --git a/airflow-core/src/airflow/ui/src/components/renderStructuredLog.test.tsx b/airflow-core/src/airflow/ui/src/components/renderStructuredLog.test.tsx index 871568049c959..655522ef45e73 100644 --- a/airflow-core/src/airflow/ui/src/components/renderStructuredLog.test.tsx +++ b/airflow-core/src/airflow/ui/src/components/renderStructuredLog.test.tsx @@ -35,6 +35,47 @@ describe("tiContextFields", () => { }); }); +describe("renderStructuredLog — traceback frame highlighting", () => { + const sitepkgFile = "/usr/local/lib/python3.14/site-packages/airflow/sdk/bases/operator.py"; + const dagBundleFile = "/tmp/airflow/dag_bundles/astro/main/dags/non_alert/enrich_ticket.py"; + + const renderWithError = () => { + const result = renderStructuredLog({ + index: 0, + logLink: "", + logMessage: { + error_detail: [ + { + exc_notes: [], + exc_type: "TypeError", + exc_value: "fromisoformat: argument must be str", + frames: [ + { filename: sitepkgFile, lineno: 445, name: "wrapper" }, + { filename: dagBundleFile, lineno: 226, name: "retrieve_cluster" }, + ], + is_cause: false, + syntax_error: null, + }, + ], + event: "Task failed with exception", + level: "error", + timestamp: "2026-07-22T09:15:20Z", + }, + renderingMode: "jsx", + translate: translate as never, + }); + + return render({result}); + }; + + it("marks a DAG-bundle frame as user code and a site-packages frame as library", () => { + renderWithError(); + + expect(screen.getByText(JSON.stringify(dagBundleFile))).toHaveAttribute("data-frame-source", "user"); + expect(screen.getByText(JSON.stringify(sitepkgFile))).toHaveAttribute("data-frame-source", "library"); + }); +}); + describe("renderStructuredLog — TI context field stripping", () => { it("does not render TI context fields as per-line structured attributes", () => { const result = renderStructuredLog({ diff --git a/airflow-core/src/airflow/ui/src/components/renderStructuredLog.tsx b/airflow-core/src/airflow/ui/src/components/renderStructuredLog.tsx index 153f9f21a2495..a8389611f2429 100644 --- a/airflow-core/src/airflow/ui/src/components/renderStructuredLog.tsx +++ b/airflow-core/src/airflow/ui/src/components/renderStructuredLog.tsx @@ -26,7 +26,7 @@ import type { StructuredLogMessage, TaskInstancesLogResponse } from "openapi/req import AnsiRenderer from "src/components/AnsiRenderer"; import Time from "src/components/Time"; import { urlRegex } from "src/constants/urlRegex"; -import { LogLevel, logLevelColorMapping } from "src/utils/logs"; +import { isUserCodeFrame, LogLevel, logLevelColorMapping } from "src/utils/logs"; type Frame = { filename: string; @@ -255,11 +255,21 @@ const renderStructuredLogImpl = ({ return ` ${translate("components:logs.file")} ${frame.filename}, ${translate("components:logs.location", { line: frame.lineno, name: frame.name })}\n`; } + // Highlight user-code frames (DAG bundle, plugins, local files) in the info blue, + // and leave installed-package frames in the normal text color, so the frame the + // error actually came from stands out from the framework frames around it. + const userCode = isUserCodeFrame(frame.filename); + return ( {translate("components:logs.file")}{" "} - {JSON.stringify(frame.filename)},{" "} - {translate("components:logs.location", { line: frame.lineno, name: frame.name })} + + {JSON.stringify(frame.filename)} + + , {translate("components:logs.location", { line: frame.lineno, name: frame.name })} ); }); diff --git a/airflow-core/src/airflow/ui/src/utils/logs.test.ts b/airflow-core/src/airflow/ui/src/utils/logs.test.ts index 0d7db12ecd8d8..fc6b2dee4dee4 100644 --- a/airflow-core/src/airflow/ui/src/utils/logs.test.ts +++ b/airflow-core/src/airflow/ui/src/utils/logs.test.ts @@ -20,7 +20,26 @@ import { describe, it, expect } from "vitest"; import type { TaskInstancesLogResponse } from "openapi/requests/types.gen"; -import { parseStreamingLogContent } from "./logs"; +import { isUserCodeFrame, parseStreamingLogContent } from "./logs"; + +describe("isUserCodeFrame", () => { + it.each([ + "/tmp/airflow/dag_bundles/astro/main/dags/non_alert/enrich_ticket.py", + "/opt/airflow/dags/my_dag.py", + "/home/user/plugins/my_plugin.py", + "C:\\airflow\\dags\\my_dag.py", + ])("treats %s as user code", (filename) => { + expect(isUserCodeFrame(filename)).toBe(true); + }); + + it.each([ + "/usr/local/lib/python3.14/site-packages/airflow/sdk/execution_time/task_runner.py", + "/usr/lib/python3/dist-packages/airflow/models/baseoperator.py", + "C:\\Python314\\Lib\\site-packages\\airflow\\sdk\\bases\\operator.py", + ])("treats %s as library code", (filename) => { + expect(isUserCodeFrame(filename)).toBe(false); + }); +}); describe("parseStreamingLogContent", () => { it("returns content when data has content property", () => { diff --git a/airflow-core/src/airflow/ui/src/utils/logs.ts b/airflow-core/src/airflow/ui/src/utils/logs.ts index 21f12c660b049..a454fd5fbb4af 100644 --- a/airflow-core/src/airflow/ui/src/utils/logs.ts +++ b/airflow-core/src/airflow/ui/src/utils/logs.ts @@ -54,6 +54,11 @@ export const logLevelOptions = createListCollection<{ ], }); +// A traceback frame is "user code" unless it lives in an installed-package directory +// (site-packages / dist-packages). DAG bundle code, plugins, and local files are all user code. +export const isUserCodeFrame = (filename: string): boolean => + !/[/\\](?:site|dist)-packages[/\\]/u.test(filename); + export const parseStreamingLogContent = ( data: TaskInstancesLogResponse | undefined, ): TaskInstancesLogResponse["content"] => {