Skip to content
This repository was archived by the owner on Sep 17, 2025. It is now read-only.

Commit acab734

Browse files
wkiserliyanhui1228
authored andcommitted
Add error data to span when an exception is raised in a span context (#122)
1 parent 1ef5ab6 commit acab734

6 files changed

Lines changed: 174 additions & 6 deletions

File tree

opencensus/trace/span.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@
1717

1818
from opencensus.trace import attributes
1919
from opencensus.trace import link as link_module
20+
from opencensus.trace import stack_trace
21+
from opencensus.trace import status
2022
from opencensus.trace import time_event as time_event_module
2123
from opencensus.trace.span_context import generate_span_id
2224
from opencensus.trace.tracers import base
@@ -206,6 +208,10 @@ def __enter__(self):
206208

207209
def __exit__(self, exception_type, exception_value, traceback):
208210
"""Finish a span."""
211+
if traceback is not None:
212+
self.stack_trace = stack_trace.StackTrace.from_traceback(traceback)
213+
if exception_value is not None:
214+
self.status = status.Status.from_exception(exception_value)
209215
if self.context_tracer is not None:
210216
self.context_tracer.end_span()
211217
return

opencensus/trace/stack_trace.py

Lines changed: 54 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,18 @@
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
1414

15+
import hashlib
16+
import os
1517
import random
18+
import traceback
1619

1720
from opencensus.trace.utils import _get_truncatable_str
1821

22+
MAX_FRAMES = 128
23+
24+
BUILD_ID = os.environ.get('BUILD_ID', 'unknown')
25+
SOURCE_VERSION = os.environ.get('SOURCE_VERSION', 'unknown')
26+
1927

2028
class StackFrame(object):
2129
"""Represents a single stack frame in a stack trace.
@@ -84,7 +92,7 @@ def format_stack_frame_json(self):
8492
self.original_func_name)
8593
stack_frame_json['file_name'] = _get_truncatable_str(self.file_name)
8694
stack_frame_json['line_number'] = self.line_num
87-
stack_frame_json['col_number'] = self.col_num
95+
stack_frame_json['column_number'] = self.col_num
8896
stack_frame_json['load_module'] = {
8997
'module': _get_truncatable_str(self.load_module),
9098
'build_id': _get_truncatable_str(self.build_id),
@@ -110,23 +118,57 @@ class StackTrace(object):
110118
def __init__(self, stack_frames=None, stack_trace_hash_id=None):
111119
if stack_frames is None:
112120
stack_frames = []
121+
if len(stack_frames) > MAX_FRAMES:
122+
self.dropped_frames_count = len(stack_frames) - MAX_FRAMES
123+
stack_frames = stack_frames[-MAX_FRAMES:]
124+
else:
125+
self.dropped_frames_count = 0
113126

114127
if stack_trace_hash_id is None:
115128
stack_trace_hash_id = generate_hash_id()
116129

117130
self.stack_frames = stack_frames
118131
self.stack_trace_hash_id = stack_trace_hash_id
119132

133+
@classmethod
134+
def from_traceback(cls, tb):
135+
"""Initializes a StackTrace from a python traceback instance"""
136+
stack_trace = cls(
137+
stack_trace_hash_id=generate_hash_id_from_traceback(tb)
138+
)
139+
# use the add_stack_frame so that json formatting is applied
140+
for tb_frame_info in traceback.extract_tb(tb):
141+
filename, line_num, fn_name, _ = tb_frame_info
142+
stack_trace.add_stack_frame(
143+
StackFrame(
144+
func_name=fn_name,
145+
original_func_name=fn_name,
146+
file_name=filename,
147+
line_num=line_num,
148+
col_num=0, # I don't think this is available in python
149+
load_module=filename,
150+
build_id=BUILD_ID,
151+
source_version=SOURCE_VERSION
152+
)
153+
)
154+
return stack_trace
155+
120156
def add_stack_frame(self, stack_frame):
121157
"""Add StackFrame to frames list."""
122-
self.stack_frames.append(stack_frame.format_stack_frame_json())
158+
if len(self.stack_frames) >= MAX_FRAMES:
159+
self.dropped_frames_count += 1
160+
else:
161+
self.stack_frames.append(stack_frame.format_stack_frame_json())
123162

124163
def format_stack_trace_json(self):
125164
"""Convert a StackTrace object to json format."""
126165
stack_trace_json = {}
127166

128167
if self.stack_frames:
129-
stack_trace_json['stack_frames'] = self.stack_frames
168+
stack_trace_json['stack_frames'] = {
169+
'frame': self.stack_frames,
170+
'dropped_frames_count': self.dropped_frames_count
171+
}
130172

131173
stack_trace_json['stack_trace_hash_id'] = self.stack_trace_hash_id
132174

@@ -136,3 +178,12 @@ def format_stack_trace_json(self):
136178
def generate_hash_id():
137179
"""Generate a hash id."""
138180
return random.getrandbits(64)
181+
182+
183+
def generate_hash_id_from_traceback(tb):
184+
m = hashlib.md5()
185+
for tb_line in traceback.format_tb(tb):
186+
m.update(tb_line.encode('utf-8'))
187+
# truncate the hash for easier compatibility with StackDriver,
188+
# should still be unique enough to avoid collisions
189+
return int(m.hexdigest()[:12], 16)

opencensus/trace/status.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
1414

15+
from google.rpc import code_pb2
16+
1517

1618
class Status(object):
1719
"""The Status type defines a logical error model that is suitable for
@@ -53,3 +55,10 @@ def format_status_json(self):
5355
status_json['details'] = self.details
5456

5557
return status_json
58+
59+
@classmethod
60+
def from_exception(cls, exc):
61+
return cls(
62+
code=code_pb2.UNKNOWN,
63+
message=str(exc)
64+
)

tests/unit/trace/test_span.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@
1616

1717
import mock
1818

19+
from google.rpc import code_pb2
20+
1921
from opencensus.trace.stack_trace import StackTrace
2022
from opencensus.trace.status import Status
2123
from opencensus.trace.time_event import TimeEvent
@@ -219,6 +221,39 @@ def test___iter__(self):
219221
span_iter_list,
220222
[child1_child1_span, child1_span, child2_span, root_span])
221223

224+
def test_exception_in_span(self):
225+
"""Make sure that an exception within a span context is
226+
attached to the span"""
227+
root_span = self._make_one('root_span')
228+
exception_message = 'error'
229+
with self.assertRaises(AssertionError):
230+
with root_span:
231+
raise AssertionError(exception_message)
232+
stack_trace = root_span.stack_trace
233+
# make sure a stack trace has been attached and populated
234+
self.assertIsNotNone(stack_trace)
235+
self.assertIsNotNone(stack_trace.stack_trace_hash_id)
236+
self.assertEqual(len(stack_trace.stack_frames), 1)
237+
238+
stack_frame = stack_trace.stack_frames[0]
239+
self.assertEqual(stack_frame['file_name']['value'], __file__)
240+
self.assertEqual(
241+
stack_frame['function_name']['value'], 'test_exception_in_span'
242+
)
243+
self.assertEqual(
244+
stack_frame['load_module']['module']['value'], __file__
245+
)
246+
self.assertEqual(
247+
stack_frame['original_function_name']['value'],
248+
'test_exception_in_span'
249+
)
250+
self.assertIsNotNone(stack_frame['source_version']['value'])
251+
self.assertIsNotNone(stack_frame['load_module']['build_id']['value'])
252+
253+
self.assertIsNotNone(root_span.status)
254+
self.assertEqual(root_span.status.message, exception_message)
255+
self.assertEqual(root_span.status.code, code_pb2.UNKNOWN)
256+
222257

223258
class Test_format_span_json(unittest.TestCase):
224259

tests/unit/trace/test_stack_trace.py

Lines changed: 62 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
1414

15+
import sys
1516
import unittest
1617

1718
import mock
@@ -81,7 +82,7 @@ def mock_get_truncatable_str(str):
8182
'original_function_name': original_func_name,
8283
'file_name': file_name,
8384
'line_number': line_num,
84-
'col_number': col_num,
85+
'column_number': col_num,
8586
'load_module': {
8687
'module': load_module,
8788
'build_id': build_id
@@ -116,6 +117,15 @@ def test_constructor_explicit(self):
116117
self.assertEqual(stack_trace.stack_frames, stack_frames)
117118
self.assertEqual(stack_trace.stack_trace_hash_id, hash_id)
118119

120+
def test_constructor_max_frames(self):
121+
stack_frames = [mock.Mock()] * (stack_trace_module.MAX_FRAMES + 1)
122+
stack_trace = stack_trace_module.StackTrace(stack_frames, 100)
123+
self.assertEqual(stack_trace.dropped_frames_count, 1)
124+
self.assertEqual(
125+
len(stack_trace.stack_frames),
126+
stack_trace_module.MAX_FRAMES
127+
)
128+
119129
def test_add_stack_frame(self):
120130
stack_trace = stack_trace_module.StackTrace()
121131
stack_frame = mock.Mock()
@@ -136,7 +146,10 @@ def test_format_stack_trace_json_with_stack_frame(self):
136146
stack_trace_json = stack_trace.format_stack_trace_json()
137147

138148
expected_stack_trace_json = {
139-
'stack_frames': stack_frame,
149+
'stack_frames': {
150+
'frame': stack_frame,
151+
'dropped_frames_count': 0
152+
},
140153
'stack_trace_hash_id': hash_id
141154
}
142155

@@ -155,3 +168,50 @@ def test_format_stack_trace_json_without_stack_frame(self):
155168
}
156169

157170
self.assertEqual(expected_stack_trace_json, stack_trace_json)
171+
172+
def test_create_from_traceback(self):
173+
try:
174+
raise AssertionError('something went wrong')
175+
except AssertionError:
176+
_, _, tb = sys.exc_info()
177+
178+
stack_trace = stack_trace_module.StackTrace.from_traceback(tb)
179+
self.assertIsNotNone(stack_trace)
180+
self.assertIsNotNone(stack_trace.stack_trace_hash_id)
181+
self.assertEqual(len(stack_trace.stack_frames), 1)
182+
183+
stack_frame = stack_trace.stack_frames[0]
184+
self.assertEqual(stack_frame['file_name']['value'], __file__)
185+
self.assertEqual(
186+
stack_frame['function_name']['value'], 'test_create_from_traceback'
187+
)
188+
self.assertEqual(
189+
stack_frame['load_module']['module']['value'], __file__
190+
)
191+
self.assertEqual(
192+
stack_frame['original_function_name']['value'],
193+
'test_create_from_traceback'
194+
)
195+
self.assertIsNotNone(stack_frame['source_version']['value'])
196+
self.assertIsNotNone(stack_frame['load_module']['build_id']['value'])
197+
198+
def test_dropped_frames(self):
199+
"""Make sure the limit of 128 frames is enforced"""
200+
def recur(max_depth):
201+
def _recur_helper(depth):
202+
if depth >= max_depth:
203+
raise AssertionError('reached max depth')
204+
_recur_helper(depth+1)
205+
_recur_helper(0)
206+
try:
207+
recur(stack_trace_module.MAX_FRAMES)
208+
except AssertionError:
209+
_, _, tb = sys.exc_info()
210+
211+
stack_trace = stack_trace_module.StackTrace.from_traceback(tb)
212+
# total frames should be MAX_FRAMES + 3 (1 for test function,
213+
# 1 for recursion start, one for exception, MAX_FRAMES in helper)
214+
self.assertEqual(stack_trace.dropped_frames_count, 3)
215+
216+
217+

tests/unit/trace/test_status.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414

1515
import unittest
1616

17-
import mock
17+
from google.rpc import code_pb2
1818

1919
from opencensus.trace import status as status_module
2020

@@ -63,3 +63,10 @@ def test_format_status_json_without_details(self):
6363
}
6464

6565
self.assertEqual(expected_status_json, status_json)
66+
67+
def test_create_from_exception(self):
68+
message = 'test message'
69+
exc = ValueError(message)
70+
status = status_module.Status.from_exception(exc)
71+
self.assertEqual(status.message, message)
72+
self.assertEqual(status.code, code_pb2.UNKNOWN)

0 commit comments

Comments
 (0)