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"] => {