Skip to content

Commit e3fe3fe

Browse files
Merge pull request #300 from cityofaustin/stacked-exception-enunciator
📢 Stacked exception enunciator for airflow slack integration
2 parents 6e961c9 + 2e3d0a8 commit e3fe3fe

4 files changed

Lines changed: 251 additions & 69 deletions

File tree

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -184,7 +184,7 @@ with DAG(
184184

185185
### Slack operator utility
186186

187-
The Slack operator utility makes use of the integration between the Airflow and a Slack app webhook. The purpose of the utility is to add Slack notifications to DAGs using the [callback](https://airflow.apache.org/docs/apache-airflow/stable/administration-and-deployment/logging-monitoring/callbacks.html#callback-types) parameters. Failure, critical failure, and success notifications are implemented.
187+
The Slack operator utility makes use of the integration between Airflow and a Slack app webhook. The purpose of the utility is to add Slack notifications to DAGs using the [callback](https://airflow.apache.org/docs/apache-airflow/stable/administration-and-deployment/logging-monitoring/callbacks.html#callback-types) parameters. Failure and success notifications are implemented.
188188

189189
To configure the Slack operator in your local instance, from the Airflow UI go to **Admin** > **Connections** and choose **Slack API** as the **connection type**. You can find the remaining settings in 1Password under the **Airflow - Slack Bot** item.
190190

@@ -208,7 +208,7 @@ with DAG(
208208
<snip>
209209
```
210210

211-
**To test the Slack operator locally**, see the DAG named `test_slack_notifier`.
211+
**To test the Slack operator locally**, see the DAGs `test_slack_notifier` and `test_docker_failure`.
212212

213213
## Useful Commands
214214

dags/test_docker_failure.py

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,19 +21,52 @@
2121

2222
with DAG(
2323
dag_id=f"test_docker_failure",
24-
description="Throws a python exception from within a docker container",
24+
description="Throws stacked python exceptions from within a docker container",
2525
default_args=DEFAULT_ARGS,
2626
schedule_interval=None,
2727
tags=["repo:atd-airflow", "slack"],
2828
catchup=False,
2929
) as dag:
30-
dag.byline = f"Test failure in a docker container"
30+
dag.byline = f"Test stacked exceptions in a docker container"
3131
dag.icon = ":test_tube:"
3232

3333
t1 = DockerOperator(
3434
task_id="docker_failure",
3535
image="atddocker/atd-airflow:production",
36-
command="python -c \"raise Exception('This is a test exception')\"",
36+
command=[
37+
"python",
38+
"-c",
39+
"""
40+
import sys
41+
import traceback
42+
43+
def outer_function():
44+
try:
45+
middle_function()
46+
except Exception as e:
47+
print("Caught exception in outer_function: " + str(e))
48+
raise RuntimeError("Failed in outer function") from e
49+
50+
def middle_function():
51+
try:
52+
inner_function()
53+
except Exception as e:
54+
print("Caught exception in middle_function: " + str(e))
55+
raise ValueError("Failed in middle function") from e
56+
57+
def inner_function():
58+
print("About to raise ConnectionError")
59+
raise ConnectionError("Connection failed")
60+
61+
if __name__ == "__main__":
62+
try:
63+
outer_function()
64+
except Exception as e:
65+
print("Final exception caught at top level:")
66+
traceback.print_exc()
67+
sys.exit(1)
68+
""",
69+
],
3770
docker_conn_id="docker_default",
3871
auto_remove="force",
3972
tty=True,

dags/utils/log_parsing.py

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
def extract_all_exceptions(context):
2+
exception = context.get("exception")
3+
exception_type = type(exception).__name__ if exception else "Unknown"
4+
exception_message = (
5+
str(exception) if exception else "No exception message available"
6+
)
7+
8+
# Extract all exceptions from logs if available
9+
all_exceptions = []
10+
11+
# DEBUG: Set this to a string containing log text to test exception parsing
12+
# When None, normal operation resumes
13+
DEBUG_LOG_TEXT = None
14+
15+
# First, get exceptions from Docker container logs if available
16+
if DEBUG_LOG_TEXT is not None:
17+
# DEBUG MODE: Use the debug log text instead of actual logs
18+
print(f"DEBUG MODE: Using debug log text for parsing")
19+
parsed_exceptions = extract_all_exceptions_from_log(DEBUG_LOG_TEXT)
20+
21+
# Add parsed exceptions (filter out None entries)
22+
for exc in parsed_exceptions:
23+
if exc[0] is not None:
24+
all_exceptions.append(exc)
25+
elif exception and hasattr(exception, "logs") and exception.logs:
26+
logs = exception.logs
27+
parsed_exceptions = extract_all_exceptions_from_log("\n".join(logs))
28+
29+
# Add parsed exceptions (filter out None entries)
30+
for exc in parsed_exceptions:
31+
if exc[0] is not None:
32+
all_exceptions.append(exc)
33+
34+
# Always add the Airflow-level exception as well if not already found
35+
airflow_exception = (exception_type, exception_message, "Airflow")
36+
# Check if we already have this exception from parsing
37+
airflow_already_found = any(
38+
exc[0] == exception_type and exc[1] == exception_message
39+
for exc in all_exceptions
40+
)
41+
if not airflow_already_found:
42+
all_exceptions.append(airflow_exception)
43+
44+
return all_exceptions
45+
46+
47+
def extract_exception_from_log(log_text):
48+
import re
49+
50+
# Find the last occurrence of 'Traceback (most recent call last):'
51+
traceback_start = log_text.rfind("Traceback (most recent call last):")
52+
if traceback_start == -1:
53+
return None, None # No traceback found
54+
55+
# Extract the traceback portion
56+
traceback_text = log_text[traceback_start:]
57+
58+
# Find the last line (which usually contains the exception type and message)
59+
last_line = traceback_text.strip().split("\n")[-1]
60+
61+
# Extract exception type and message
62+
match = re.match(r"([\w.]+): (.*)", last_line)
63+
if match:
64+
return match.group(1), match.group(2)
65+
66+
return None, None
67+
68+
69+
def extract_all_exceptions_from_log(log_text):
70+
"""
71+
Extract all Python exceptions from raw Airflow logs.
72+
Returns list of tuples (exception_type, exception_message, source).
73+
Source is "ETL" for exceptions before "Task failed with exception" line,
74+
"Airflow" for exceptions after that line.
75+
"""
76+
77+
exceptions = []
78+
lines = log_text.split("\n")
79+
80+
# Find the "Task failed with exception" dividing line
81+
task_failed_line_idx = None
82+
for i, line in enumerate(lines):
83+
if "Task failed with exception" in line:
84+
task_failed_line_idx = i
85+
break
86+
87+
# Look for all traceback sections
88+
traceback_starts = []
89+
for i, line in enumerate(lines):
90+
if "Traceback (most recent call last):" in line:
91+
traceback_starts.append(i)
92+
93+
# Process each traceback section
94+
for start_idx in traceback_starts:
95+
# Determine source based on position relative to "Task failed with exception"
96+
if task_failed_line_idx is None:
97+
source = "ETL" # Default to ETL if no dividing line found
98+
elif start_idx < task_failed_line_idx:
99+
source = "ETL"
100+
else:
101+
source = "Airflow"
102+
103+
# Find the exception line for this traceback
104+
exception_line = _find_exception_line_in_traceback(lines, start_idx)
105+
106+
if exception_line:
107+
exc_type, exc_msg = _parse_exception_line(exception_line)
108+
if exc_type:
109+
exceptions.append((exc_type, exc_msg, source))
110+
111+
return exceptions if exceptions else [(None, None, "Unknown")]
112+
113+
114+
def _find_exception_line_in_traceback(lines, traceback_start_idx):
115+
"""
116+
Find the actual exception line (final line) in a traceback section.
117+
"""
118+
# Start from the traceback line and look forward
119+
i = traceback_start_idx + 1
120+
121+
while i < len(lines):
122+
line = lines[i].strip()
123+
124+
# Stop if we hit another traceback
125+
if "Traceback (most recent call last):" in line:
126+
break
127+
128+
# Stop if we hit certain log markers that indicate end of traceback
129+
if line.startswith("During handling of the above exception") or line.startswith(
130+
"The above exception was the direct cause"
131+
):
132+
i += 1
133+
continue
134+
135+
# Check if this is an exception line
136+
if _is_exception_line(line):
137+
return line
138+
139+
i += 1
140+
141+
return None
142+
143+
144+
def _is_exception_line(line):
145+
"""Check if a line contains a Python exception"""
146+
import re
147+
148+
line = line.strip()
149+
150+
# Skip obvious non-exception lines
151+
if (
152+
not line
153+
or line.startswith("File ")
154+
or line.startswith(" ")
155+
or line.startswith("^")
156+
or "Traceback" in line
157+
or line.startswith("During handling")
158+
or line.startswith("The above exception")
159+
):
160+
return False
161+
162+
# Look for Python exception patterns
163+
# Must start with a capital letter followed by word characters, dots, underscores
164+
# Common exception endings but not required
165+
exception_pattern = (
166+
r"^[A-Z][A-Za-z0-9_.]*(?:Error|Exception|Warning|Timeout)?(?::\s|$)"
167+
)
168+
return re.match(exception_pattern, line) is not None
169+
170+
171+
def _parse_exception_line(line):
172+
"""Parse an exception line into (type, message)"""
173+
import re
174+
175+
line = line.strip()
176+
177+
# Pattern for ExceptionType: message
178+
match = re.match(r"^([A-Z][A-Za-z0-9_.]*)\s*:\s*(.*)", line)
179+
if match:
180+
return match.group(1), match.group(2)
181+
182+
# Pattern for just ExceptionType (no colon/message)
183+
match = re.match(r"^([A-Z][A-Za-z0-9_.]*)$", line)
184+
if match:
185+
return match.group(1), ""
186+
187+
return None, None

dags/utils/slack_operator.py

Lines changed: 26 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,15 @@
33
from cron_descriptor import get_description
44
from airflow.hooks.base import BaseHook
55
from airflow.providers.slack.operators.slack_webhook import SlackWebhookOperator
6-
6+
from utils.log_parsing import extract_all_exceptions_from_log, extract_all_exceptions
77

88
# This is the Conn Id that we set when creating the connection in the Airflow dashboard
99
# in Admin > Connections.
1010
SLACK_CONN_ID = "slack"
1111

1212
DEPLOYMENT_ENVIRONMENT = getenv("ENVIRONMENT", "development")
1313

14+
1415
slack_member_ids = {
1516
"Frank": "<@UMS32US1E>",
1617
"Amenity": "<@U0PQDEMRD>",
@@ -87,70 +88,30 @@ def get_central_time_exec_data(context):
8788
return local_tz.convert(execution_date_timestamp).format("MM/DD/YYYY hh:mm:ss A")
8889

8990

90-
def task_fail_slack_alert_critical(context):
91-
slack_msg = """
92-
<!channel> :red_circle: Critical Failure
93-
*Task*: {task}
94-
*DAG*: {dag}
95-
*Execution Time*: {exec_date}
96-
*Log URL*: {log_url}
97-
""".format(
98-
task=context.get("task_instance").task_id,
99-
dag=context.get("task_instance").dag_id,
100-
exec_date=get_central_time_exec_data(context),
101-
log_url=context.get("task_instance").log_url,
102-
)
103-
failed_alert = SlackWebhookOperator(
104-
task_id="slack_critical_failure",
105-
slack_webhook_conn_id=SLACK_CONN_ID,
106-
message=slack_msg,
107-
username="airflow",
108-
)
109-
return failed_alert.execute(context=context)
110-
111-
112-
def extract_exception_from_log(log_text):
113-
import re
114-
115-
# Find the last occurrence of 'Traceback (most recent call last):'
116-
traceback_start = log_text.rfind("Traceback (most recent call last):")
117-
if traceback_start == -1:
118-
return None, None # No traceback found
119-
120-
# Extract the traceback portion
121-
traceback_text = log_text[traceback_start:]
122-
123-
# Find the last line (which usually contains the exception type and message)
124-
last_line = traceback_text.strip().split("\n")[-1]
125-
126-
# Extract exception type and message
127-
match = re.match(r"([\w.]+): (.*)", last_line)
128-
if match:
129-
return match.group(1), match.group(2)
130-
131-
return None, None
91+
def build_exception_text(all_exceptions):
92+
# Format all exceptions for display
93+
exceptions_text = ""
94+
if len(all_exceptions) == 1:
95+
# Single exception - use original format with source
96+
exception_type = all_exceptions[-1][0]
97+
exception_message = all_exceptions[-1][1]
98+
source = all_exceptions[0][2] if len(all_exceptions[0]) > 2 else "Unknown"
99+
exceptions_text = f"*Exception Type*: `{exception_type}` _(from {source})_\n *Exception Message*: `{exception_message}`"
100+
else:
101+
# Multiple exceptions - list them all with sources
102+
exceptions_text = f"*Exceptions Found ({len(all_exceptions)} total)*:"
103+
for i, exc_tuple in enumerate(all_exceptions, 1):
104+
exc_type = exc_tuple[0]
105+
exc_msg = exc_tuple[1]
106+
source = exc_tuple[2] if len(exc_tuple) > 2 else "Unknown"
107+
exceptions_text += (
108+
f"\n {i}. *{exc_type}* _(from {source})_: `{exc_msg}`"
109+
)
110+
return exceptions_text
132111

133112

134113
def task_fail_slack_alert(context):
135-
136114
task_instance = context.get("task_instance")
137-
task = context.get("task")
138-
exception = context.get("exception")
139-
exception_type = type(exception).__name__ if exception else "Unknown"
140-
exception_message = (
141-
str(exception) if exception else "No exception message available"
142-
)
143-
144-
if exception and hasattr(exception, "logs") and exception.logs:
145-
logs = exception.logs
146-
parsed_exception_type, parsed_exception_message = extract_exception_from_log(
147-
"\n".join(logs)
148-
)
149-
if parsed_exception_message and parsed_exception_type:
150-
exception_type = parsed_exception_type
151-
exception_message = parsed_exception_message
152-
153-
# Extract additional information
154115
dag = context.get("dag")
155116
dag_id = task_instance.dag_id
156117
task_id = task_instance.task_id
@@ -159,9 +120,11 @@ def task_fail_slack_alert(context):
159120
duration = getattr(task_instance, "duration", "Not available")
160121

161122
schedule_interval = dag.schedule_interval if dag else None
162-
163123
schedule_description = format_schedule(schedule_interval)
164124

125+
all_exceptions = extract_all_exceptions(context)
126+
exceptions_text = build_exception_text(all_exceptions)
127+
165128
byline = getattr(dag, "byline", "")
166129
icon = getattr(dag, "icon", ":red_circle:")
167130

@@ -178,8 +141,7 @@ def task_fail_slack_alert(context):
178141
*Execution Time*: `{exec_date}`
179142
*Schedule*: `{schedule_description}`
180143
*Duration*: `{duration} seconds`
181-
*Exception Type*: `{exception_type}`
182-
*Exception Message*: `{exception_message}`
144+
{exceptions_text}
183145
<{log_url}|*View Task Log*>
184146
"""
185147

0 commit comments

Comments
 (0)