Skip to content

Commit 2a4e3aa

Browse files
authored
AGE-148 : Function Body Recording For Python app exceptions - opsAI (#59)
* AGE-148 : Get full function body details, when exception occurs (#56) * recording function body * Updated Record Exception flow to collect logical unit of exception * Collecting Function Body and Metadata when exception occurs (#57) * Updated exception handling logic to handle more generic errors * Added support for MW_GIT_COMMIT_SHA and MW_REPOSITORY_URL env variables * Adding function line, code etc. for whole traceback * Removed print statements * reverted extra change * Added function start_line, end_line details * Generalizing naming for VCS properties * Automatic exception handling for flask apps * Updated stack trace sequence + Added internal file flag * Added support to catch FastAPI exceptions * Removing flask dependency from package * exception.language added * Updated event name for exception * Reverted event name back to exception * For site-packages flag will be is_file_external=true * Resolved issue where exception was being recorded twice * Fixed line highlighting issue in stack trace function body * Directly overriding existing opentelemetry exception for function body * Code cleanup * Updated logic for function code scraping * Removed commented code * Updated pypi package version
1 parent 316c5bc commit 2a4e3aa

File tree

4 files changed

+181
-7
lines changed

4 files changed

+181
-7
lines changed

middleware/__init__.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
from middleware.distro import mw_tracker, record_exception
1+
from middleware.distro import mw_tracker, custom_record_exception_wrapper, record_exception
2+
from opentelemetry.sdk.trace import Span
23
from middleware.options import (
34
MWOptions,
45
DETECT_AWS_BEANSTALK,
@@ -28,3 +29,5 @@
2829
"DETECT_GCP",
2930
"DETECT_ENVVARS",
3031
]
32+
33+
Span.record_exception = custom_record_exception_wrapper

middleware/distro.py

Lines changed: 138 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
11
import logging
2+
import inspect
3+
import traceback
4+
from typing import Optional, Type
5+
import sys
26
from logging import getLogger
37
from typing import Optional
48
from opentelemetry.instrumentation.distro import BaseDistro
@@ -9,7 +13,10 @@
913
from middleware.log import create_logger_handler
1014
from middleware.profiler import collect_profiling
1115
from opentelemetry import trace
12-
from opentelemetry.trace import Tracer, get_current_span, get_tracer
16+
from opentelemetry.trace import Tracer, get_current_span, get_tracer, get_tracer, Status, StatusCode
17+
from opentelemetry.sdk.trace import Span
18+
import os
19+
import json
1320

1421
_logger = getLogger(__name__)
1522

@@ -80,7 +87,6 @@ def mw_tracker(
8087

8188
mw_tracker_called = True
8289

83-
8490
def record_exception(exc: Exception, span_name: Optional[str] = None) -> None:
8591
"""
8692
Reports an exception as a span event creating a dummy span if necessary.
@@ -114,6 +120,136 @@ def record_exception(exc: Exception, span_name: Optional[str] = None) -> None:
114120
span.set_status(trace.Status(trace.StatusCode.ERROR, str(exc)))
115121
span.end()
116122

123+
def extract_function_code(tb_frame, lineno):
124+
"""Extracts the full function body where the exception occurred."""
125+
try:
126+
# Get the source lines and the starting line number of the function
127+
source_lines, start_line = inspect.getsourcelines(tb_frame)
128+
end_line = start_line + len(source_lines) - 1
129+
130+
# If the function body is too long, limit the number of lines
131+
if len(source_lines) > 20:
132+
# Define the number of lines to show before and after the exception line
133+
lines_before = 10
134+
lines_after = 10
135+
136+
# Calculate the start and end indices for slicing
137+
start_idx = max(0, lineno - start_line - lines_before)
138+
end_idx = min(len(source_lines), lineno - start_line + lines_after)
139+
140+
# Extract the relevant lines
141+
source_lines = source_lines[start_idx:end_idx]
142+
143+
# Adjust the start and end line numbers
144+
start_line += start_idx
145+
end_line = start_line + len(source_lines) - 1
146+
147+
# Convert the list of lines to a single string
148+
function_code = "".join(source_lines)
149+
150+
return {
151+
"function_code": function_code,
152+
"function_start_line": start_line,
153+
"function_end_line": end_line,
154+
}
155+
156+
except Exception as e:
157+
# Handle cases where the source code cannot be extracted
158+
return {
159+
"function_code": f"Error extracting function code: {e}",
160+
"function_start_line": None,
161+
"function_end_line": None,
162+
}
163+
164+
def custom_record_exception_wrapper(self: Span,
165+
exception: BaseException,
166+
attributes=None,
167+
timestamp: int = None,
168+
escaped: bool = False) -> None:
169+
"""
170+
Custom wrapper for Span.record_exception.
171+
This calls our custom_record_exception to add extra details before delegating
172+
to the original record_exception method.
173+
"""
174+
# Check for a recursion marker
175+
if self.attributes.get("exception.is_recursion") == "true":
176+
return _original_record_exception(self, exception, attributes, timestamp, escaped)
177+
178+
# Mark the span to prevent infinite recursion.
179+
self.set_attribute("exception.is_recursion", "true")
180+
181+
# Call our custom exception recording logic.
182+
custom_record_exception(self, exception)
183+
184+
# Optionally, call the original record_exception for default behavior.
185+
return _original_record_exception(self, exception, attributes, timestamp, escaped)
186+
187+
# Replacement of span.record_exception to include function source code
188+
def custom_record_exception(span: Span, exc: Exception):
189+
"""Custom exception recording that captures function source code."""
190+
exc_type, exc_value, exc_tb = exc.__class__, str(exc), exc.__traceback__
191+
192+
if exc_tb is None:
193+
# span.set_attribute("exception.warning", "No traceback available")
194+
span.record_exception(exc)
195+
return
196+
197+
tb_details = traceback.extract_tb(exc_tb)
198+
199+
if not tb_details:
200+
# span.set_attribute("exception.warning", "Traceback is empty")
201+
span.record_exception(exc)
202+
return
203+
204+
stack_info = []
205+
206+
for (frame, _), (filename, lineno, func_name, _) in zip(traceback.walk_tb(exc_tb), tb_details):
207+
function_details = extract_function_code(frame, lineno) if frame else "Function source not found."
208+
209+
stack_entry = {
210+
"exception.file": filename,
211+
"exception.line": lineno,
212+
"exception.function_name": func_name,
213+
"exception.function_body": function_details["function_code"],
214+
"exception.start_line": function_details["function_start_line"],
215+
"exception.end_line": function_details["function_end_line"],
216+
}
217+
218+
# Check if the file is from site-packages
219+
if "site-packages" in filename:
220+
stack_entry["exception.is_file_external"] = "true"
221+
else:
222+
stack_entry["exception.is_file_external"] = "false"
223+
224+
stack_info.insert(0, stack_entry) # Prepend instead of append
225+
226+
# Determine if the exception is escaping
227+
current_exc = sys.exc_info()[1] # Get the currently active exception
228+
exception_escaped = current_exc is exc # True if it's still propagating
229+
230+
mw_vcs_repository_url = os.getenv("MW_VCS_REPOSITORY_URL")
231+
mw_vcs_commit_sha = os.getenv("MW_VCS_COMMIT_SHA")
232+
233+
# Serialize stack info as JSON string since OpenTelemetry only supports string values
234+
stack_info_str = json.dumps(stack_info, indent=2)
235+
236+
# Add extra details in the existing "exception" event
237+
span.add_event(
238+
"exception",
239+
{
240+
"exception.type": str(exc_type.__name__),
241+
"exception.message": exc_value,
242+
"exception.language": "python",
243+
"exception.stacktrace": traceback.format_exc(),
244+
"exception.escaped": exception_escaped,
245+
"exception.vcs.commit_sha": mw_vcs_commit_sha or "",
246+
"exception.vcs.repository_url": mw_vcs_repository_url or "",
247+
"exception.stack_details": stack_info_str, # Attach full stacktrace details
248+
}
249+
)
250+
251+
252+
117253

118254
# pylint: disable=too-few-public-methods
119255
class MiddlewareDistro(BaseDistro):

middleware/trace.py

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import sys
33
import logging
44
from opentelemetry.sdk.resources import Resource
5-
from opentelemetry.sdk.trace import TracerProvider
5+
from opentelemetry.sdk.trace import TracerProvider, SpanProcessor, ReadableSpan
66
from opentelemetry.sdk.trace.export import (
77
BatchSpanProcessor,
88
SimpleSpanProcessor,
@@ -11,11 +11,44 @@
1111
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
1212
from opentelemetry.processor.baggage import ALLOW_ALL_BAGGAGE_KEYS, BaggageSpanProcessor
1313
from middleware.options import MWOptions
14-
from opentelemetry.trace import set_tracer_provider
14+
from opentelemetry.trace import set_tracer_provider, Span
1515
from middleware.sampler import configure_sampler
1616

1717
_logger = logging.getLogger(__name__)
1818

19+
class ExceptionFilteringSpanProcessor(SpanProcessor):
20+
def on_start(self, span: ReadableSpan, parent_context):
21+
pass
22+
23+
def on_end(self, span: ReadableSpan):
24+
# Check if there is any "exception" event with "exception.stack_details"
25+
has_stack_details = any(
26+
event.name == "exception" and "exception.stack_details" in event.attributes
27+
for event in span.events
28+
)
29+
30+
if has_stack_details:
31+
# Keep only the unique "exception" events based on "exception.stack_trace"
32+
seen_stack_traces = set()
33+
filtered_events = []
34+
for event in span.events:
35+
if event.name == "exception" and "exception.stack_details" in event.attributes:
36+
stack_trace = event.attributes.get("exception.stack_trace")
37+
seen_stack_traces.add(stack_trace)
38+
filtered_events.append(event)
39+
elif event.name == "exception":
40+
stack_trace = event.attributes.get("exception.stack_trace")
41+
if stack_trace not in seen_stack_traces:
42+
filtered_events.append(event)
43+
elif event.name != "exception":
44+
filtered_events.append(event)
45+
span._events = filtered_events
46+
47+
def shutdown(self):
48+
pass
49+
50+
def force_flush(self, timeout_millis=None):
51+
pass
1952

2053
def create_tracer_provider(options: MWOptions, resource: Resource) -> TracerProvider:
2154
"""
@@ -28,6 +61,7 @@ def create_tracer_provider(options: MWOptions, resource: Resource) -> TracerProv
2861
Returns:
2962
TracerProvider: the new tracer provider
3063
"""
64+
3165
exporter = OTLPSpanExporter(
3266
endpoint=options.target,
3367
compression=grpc.Compression.Gzip,
@@ -41,6 +75,7 @@ def create_tracer_provider(options: MWOptions, resource: Resource) -> TracerProv
4175
exporter,
4276
)
4377
)
78+
trace_provider.add_span_processor(ExceptionFilteringSpanProcessor())
4479
if options.console_exporter:
4580
output = sys.stdout
4681
if options.debug_log_file:
@@ -58,4 +93,4 @@ def create_tracer_provider(options: MWOptions, resource: Resource) -> TracerProv
5893
)
5994
)
6095
set_tracer_provider(tracer_provider=trace_provider)
61-
return trace_provider
96+
return trace_provider

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "middleware-io"
7-
version = "2.1.1"
7+
version = "2.2.0"
88
requires-python = ">=3.8"
99
description = "Middleware's APM tool enables Python developers to effortlessly monitor their applications, gathering distributed tracing, metrics, logs, and profiling data for valuable insights and performance optimization."
1010
authors = [{ name = "middleware-dev" }]

0 commit comments

Comments
 (0)