diff --git a/airflow/config_templates/config.yml b/airflow/config_templates/config.yml
index 60a9d3c7dc6b7..995f9f0393561 100644
--- a/airflow/config_templates/config.yml
+++ b/airflow/config_templates/config.yml
@@ -979,6 +979,22 @@ logging:
type: boolean
example: ~
default: "True"
+ color_log_error_keywords:
+ description: |
+ A comma separated list of keywords related to errors whose presence should display the line in red
+ color in UI
+ version_added: 2.10.0
+ type: string
+ example: ~
+ default: "error,exception"
+ color_log_warning_keywords:
+ description: |
+ A comma separated list of keywords related to warning whose presence should display the line in yellow
+ color in UI
+ version_added: 2.10.0
+ type: string
+ example: ~
+ default: "warn"
metrics:
description: |
StatsD (https://github.com/etsy/statsd) integration settings.
diff --git a/airflow/www/static/js/dag/details/taskInstance/Logs/utils.ts b/airflow/www/static/js/dag/details/taskInstance/Logs/utils.ts
index 1d41b3dccd5a1..0de5676916bbb 100644
--- a/airflow/www/static/js/dag/details/taskInstance/Logs/utils.ts
+++ b/airflow/www/static/js/dag/details/taskInstance/Logs/utils.ts
@@ -20,6 +20,7 @@
/* global moment */
import { AnsiUp } from "ansi_up";
+import { getMetaValue, highlightByKeywords } from "src/utils";
import { defaultFormatWithTZ } from "src/datetime_utils";
export enum LogLevel {
@@ -38,6 +39,15 @@ export const logLevelColorMapping = {
[LogLevel.CRITICAL]: "red.400",
};
+const errorKeywords = getMetaValue("color_log_error_keywords")
+ .split(",")
+ .filter((keyword) => keyword.length > 0)
+ .map((keyword) => keyword.toLowerCase());
+const warningKeywords = getMetaValue("color_log_warning_keywords")
+ .split(",")
+ .filter((keyword) => keyword.length > 0)
+ .map((keyword) => keyword.toLowerCase());
+
export const parseLogs = (
data: string | undefined,
timezone: string | null,
@@ -112,6 +122,11 @@ export const parseLogs = (
line.includes(fileSourceFilter)
)
) {
+ parsedLine = highlightByKeywords(
+ parsedLine,
+ errorKeywords,
+ warningKeywords
+ );
// for lines with color convert to nice HTML
const coloredLine = ansiUp.ansi_to_html(parsedLine);
diff --git a/airflow/www/static/js/utils/index.test.ts b/airflow/www/static/js/utils/index.test.ts
index 4c8be82cfd470..a76ee62c867dd 100644
--- a/airflow/www/static/js/utils/index.test.ts
+++ b/airflow/www/static/js/utils/index.test.ts
@@ -19,7 +19,12 @@
import { isEmpty } from "lodash";
import type { DagRun } from "src/types";
-import { getDagRunLabel, getTask, getTaskSummary } from ".";
+import {
+ getDagRunLabel,
+ getTask,
+ getTaskSummary,
+ highlightByKeywords,
+} from ".";
const sampleTasks = {
id: null,
@@ -148,3 +153,45 @@ describe("Test getDagRunLabel", () => {
expect(runLabel).toBe(dagRun.executionDate);
});
});
+
+describe("Test highlightByKeywords", () => {
+ test("Highlight error line by red color", async () => {
+ const originalLine = "line with Error";
+ const expected = `\x1b[1m\x1b[31mline with Error\x1b[39m\x1b[0m`;
+ const highlightedLine = highlightByKeywords(
+ originalLine,
+ ["error"],
+ ["warn"]
+ );
+ expect(highlightedLine).toBe(expected);
+ });
+ test("Highlight warning line by yellow color", async () => {
+ const originalLine = "line with Warning";
+ const expected = `\x1b[1m\x1b[33mline with Warning\x1b[39m\x1b[0m`;
+ const highlightedLine = highlightByKeywords(
+ originalLine,
+ ["error"],
+ ["warn"]
+ );
+ expect(highlightedLine).toBe(expected);
+ });
+ test("Highlight line by red color when line has both error and warning", async () => {
+ const originalLine = "line with error Warning";
+ const expected = `\x1b[1m\x1b[31mline with error Warning\x1b[39m\x1b[0m`;
+ const highlightedLine = highlightByKeywords(
+ originalLine,
+ ["error"],
+ ["warn"]
+ );
+ expect(highlightedLine).toBe(expected);
+ });
+ test("No highlight", async () => {
+ const originalLine = "sample line";
+ const highlightedLine = highlightByKeywords(
+ originalLine,
+ ["error"],
+ ["warn"]
+ );
+ expect(highlightedLine).toBe(originalLine);
+ });
+});
diff --git a/airflow/www/static/js/utils/index.ts b/airflow/www/static/js/utils/index.ts
index 37dc5cb022c88..9eb8af9638c42 100644
--- a/airflow/www/static/js/utils/index.ts
+++ b/airflow/www/static/js/utils/index.ts
@@ -185,6 +185,34 @@ const toSentenceCase = (camelCase: string): string => {
return "";
};
+const highlightByKeywords = (
+ parsedLine: string,
+ errorKeywords: string[],
+ warningKeywords: string[]
+): string => {
+ const lowerParsedLine = parsedLine.toLowerCase();
+ const red = (line: string) => `\x1b[1m\x1b[31m${line}\x1b[39m\x1b[0m`;
+ const yellow = (line: string) => `\x1b[1m\x1b[33m${line}\x1b[39m\x1b[0m`;
+
+ const containsError = errorKeywords.some((keyword) =>
+ lowerParsedLine.includes(keyword)
+ );
+
+ if (containsError) {
+ return red(parsedLine);
+ }
+
+ const containsWarning = warningKeywords.some((keyword) =>
+ lowerParsedLine.includes(keyword)
+ );
+
+ if (containsWarning) {
+ return yellow(parsedLine);
+ }
+
+ return parsedLine;
+};
+
export {
hoverDelay,
finalStatesMap,
@@ -197,4 +225,5 @@ export {
getStatusBackgroundColor,
useOffsetTop,
toSentenceCase,
+ highlightByKeywords,
};
diff --git a/airflow/www/templates/airflow/grid.html b/airflow/www/templates/airflow/grid.html
index 3c7bb236e178e..e90389212b14f 100644
--- a/airflow/www/templates/airflow/grid.html
+++ b/airflow/www/templates/airflow/grid.html
@@ -28,6 +28,8 @@
+
+
{% endblock %}
{% block content %}
diff --git a/airflow/www/views.py b/airflow/www/views.py
index 328312658bd18..f26a5337ff548 100644
--- a/airflow/www/views.py
+++ b/airflow/www/views.py
@@ -2786,6 +2786,9 @@ def legacy_tree(self):
@provide_session
def grid(self, dag_id: str, session: Session = NEW_SESSION):
"""Get Dag's grid view."""
+ color_log_error_keywords = conf.get("logging", "color_log_error_keywords", fallback="")
+ color_log_warning_keywords = conf.get("logging", "color_log_warning_keywords", fallback="")
+
dag = get_airflow_app().dag_bag.get_dag(dag_id, session=session)
dag_model = DagModel.get_dagmodel(dag_id, session=session)
if not dag:
@@ -2843,6 +2846,8 @@ def grid(self, dag_id: str, session: Session = NEW_SESSION):
),
included_events_raw=included_events_raw,
excluded_events_raw=excluded_events_raw,
+ color_log_error_keywords=color_log_error_keywords,
+ color_log_warning_keywords=color_log_warning_keywords,
)
@expose("/calendar")