Skip to content

Commit ee930e0

Browse files
committed
refactored span stack and added contextvars as alternative for threadlocals (#291)
this is a partial "import" of the asyncio work done in #252, sans the asyncio specific extensions closes #291
1 parent 6afd5df commit ee930e0

File tree

9 files changed

+133
-101
lines changed

9 files changed

+133
-101
lines changed

CHANGELOG.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,15 @@ Further breaking changes:
1313
* Some settings now require a unit for duration or size. See documentation on
1414
configuration for more information.
1515

16+
17+
Other changes:
18+
* on Python 3.7, use [contextvars](https://docs.python.org/3/library/contextvars.html) instead of threadlocals for storing
19+
current transaction and span. This is a necessary precursor for full asyncio support. (#291)
20+
1621
## Unreleased
1722

1823
* fixed an issue with detecting names of wrapped functions that are partials (#294)
19-
24+
2025
## v3.0.1
2126

2227
[Check the diff](https://github.com/elastic/apm-agent-python/compare/v3.0.0...v3.0.1)

elasticapm/base.py

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from __future__ import absolute_import
1313

1414
import datetime
15+
import inspect
1516
import logging
1617
import os
1718
import platform
@@ -112,9 +113,12 @@ def __init__(self, config=None, **inline):
112113
else:
113114
skip_modules = ("elasticapm.",)
114115

115-
def frames_collector_func():
116-
return self._get_stack_info_for_trace(
117-
stacks.iter_stack_frames(skip_top_modules=skip_modules),
116+
self.transaction_store = TransactionsStore(
117+
frames_collector_func=lambda: list(
118+
stacks.iter_stack_frames(start_frame=inspect.currentframe(), skip_top_modules=skip_modules)
119+
),
120+
frames_processing_func=lambda frames: self._get_stack_info_for_trace(
121+
frames,
118122
library_frame_context_lines=self.config.source_lines_span_library_frames,
119123
in_app_frame_context_lines=self.config.source_lines_span_app_frames,
120124
with_locals=self.config.collect_local_variables in ("all", "transactions"),
@@ -126,12 +130,8 @@ def frames_collector_func():
126130
),
127131
local_var,
128132
),
129-
)
130-
131-
self.transaction_store = TransactionsStore(
132-
frames_collector_func=frames_collector_func,
133+
),
133134
queue_func=self.queue,
134-
collect_frequency=self.config.api_request_time,
135135
sample_rate=self.config.transaction_sample_rate,
136136
max_spans=self.config.transaction_max_spans,
137137
span_frames_min_duration=self.config.span_frames_min_duration,

elasticapm/context/__init__.py

Whitespace-only changes.

elasticapm/context/contextvars.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
from __future__ import absolute_import
2+
3+
import contextvars
4+
5+
elasticapm_transaction_var = contextvars.ContextVar("elasticapm_transaction_var")
6+
elasticapm_span_var = contextvars.ContextVar("elasticapm_span_var")
7+
8+
9+
def get_transaction(clear=False):
10+
try:
11+
transaction = elasticapm_transaction_var.get()
12+
if clear:
13+
set_transaction(None)
14+
return transaction
15+
except LookupError:
16+
return None
17+
18+
19+
def set_transaction(transaction):
20+
elasticapm_transaction_var.set(transaction)
21+
22+
23+
def get_span():
24+
try:
25+
return elasticapm_span_var.get()
26+
except LookupError:
27+
return None
28+
29+
30+
def set_span(span):
31+
elasticapm_span_var.set(span)

elasticapm/context/threadlocal.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import threading
2+
3+
thread_local = threading.local()
4+
thread_local.transaction = None
5+
elasticapm_span_var = None
6+
7+
8+
def get_transaction(clear=False):
9+
"""
10+
Get the transaction registered for the current thread.
11+
12+
:return:
13+
:rtype: Transaction
14+
"""
15+
transaction = getattr(thread_local, "transaction", None)
16+
if clear:
17+
thread_local.transaction = None
18+
return transaction
19+
20+
21+
def set_transaction(transaction):
22+
thread_local.transaction = transaction
23+
24+
25+
def get_span():
26+
return getattr(thread_local, "span", None)
27+
28+
29+
def set_span(span):
30+
thread_local.span = span

elasticapm/traces.py

Lines changed: 49 additions & 81 deletions
Original file line numberDiff line numberDiff line change
@@ -15,43 +15,20 @@
1515

1616
error_logger = logging.getLogger("elasticapm.errors")
1717

18-
thread_local = threading.local()
19-
thread_local.transaction = None
20-
21-
2218
_time_func = timeit.default_timer
2319

2420

2521
TAG_RE = re.compile('^[^.*"]+$')
2622

2723

28-
DROPPED_SPAN = object()
29-
IGNORED_SPAN = object()
30-
31-
32-
def get_transaction(clear=False):
33-
"""
34-
Get the transaction registered for the current thread.
35-
36-
:return:
37-
:rtype: Transaction
38-
"""
39-
transaction = getattr(thread_local, "transaction", None)
40-
if clear:
41-
thread_local.transaction = None
42-
return transaction
24+
try:
25+
from elasticapm.context.contextvars import get_transaction, set_transaction, get_span, set_span
26+
except ImportError:
27+
from elasticapm.context.threadlocal import get_transaction, set_transaction, get_span, set_span
4328

4429

4530
class Transaction(object):
46-
def __init__(
47-
self,
48-
frames_collector_func,
49-
queue_func,
50-
transaction_type="custom",
51-
is_sampled=True,
52-
max_spans=None,
53-
span_frames_min_duration=None,
54-
):
31+
def __init__(self, store, transaction_type="custom", is_sampled=True):
5532
self.id = str(uuid.uuid4())
5633
self.trace_id = None # for later use in distributed tracing
5734
self.timestamp = datetime.datetime.utcnow()
@@ -60,15 +37,10 @@ def __init__(
6037
self.duration = None
6138
self.result = None
6239
self.transaction_type = transaction_type
63-
self._frames_collector_func = frames_collector_func
64-
self._queue_func = queue_func
40+
self._store = store
6541

6642
self.spans = []
67-
self.span_stack = []
68-
self.max_spans = max_spans
69-
self.span_frames_min_duration = span_frames_min_duration
7043
self.dropped_spans = 0
71-
self.ignore_subtree = False
7244
self.context = {}
7345
self.tags = {}
7446

@@ -79,45 +51,40 @@ def end_transaction(self, skip_frames=8):
7951
self.duration = _time_func() - self.start_time
8052

8153
def begin_span(self, name, span_type, context=None, leaf=False):
82-
# If we were already called with `leaf=True`, we'll just push
83-
# a placeholder on the stack.
84-
if self.ignore_subtree:
85-
self.span_stack.append(IGNORED_SPAN)
86-
return None
87-
88-
if leaf:
89-
self.ignore_subtree = True
90-
91-
self._span_counter += 1
92-
93-
if self.max_spans and self._span_counter > self.max_spans:
54+
parent_span = get_span()
55+
store = self._store
56+
if parent_span and parent_span.leaf:
57+
span = DroppedSpan(parent_span, leaf=True)
58+
elif store.max_spans and self._span_counter > store.max_spans - 1:
9459
self.dropped_spans += 1
95-
self.span_stack.append(DROPPED_SPAN)
96-
return None
97-
98-
start = _time_func() - self.start_time
99-
span = Span(self._span_counter - 1, self.id, self.trace_id, name, span_type, start, context)
100-
self.span_stack.append(span)
60+
span = DroppedSpan(parent_span)
61+
self._span_counter += 1
62+
else:
63+
start = _time_func() - self.start_time
64+
span = Span(self._span_counter, self.id, self.trace_id, name, span_type, start, context, leaf)
65+
span.frames = store.frames_collector_func()
66+
span.parent = parent_span
67+
self._span_counter += 1
68+
set_span(span)
10169
return span
10270

10371
def end_span(self, skip_frames):
104-
span = self.span_stack.pop()
105-
if span is IGNORED_SPAN:
106-
return None
107-
108-
self.ignore_subtree = False
109-
110-
if span is DROPPED_SPAN:
72+
span = get_span()
73+
if span is None:
74+
raise LookupError()
75+
if isinstance(span, DroppedSpan):
76+
set_span(span.parent)
11177
return
11278

11379
span.duration = _time_func() - span.start_time - self.start_time
11480

115-
if self.span_stack:
116-
span.parent = self.span_stack[-1].idx
117-
118-
if not self.span_frames_min_duration or span.duration >= self.span_frames_min_duration:
119-
span.frames = self._frames_collector_func()[skip_frames:]
120-
self._queue_func(SPAN, span.to_dict())
81+
if not self._store.span_frames_min_duration or span.duration >= self._store.span_frames_min_duration:
82+
span.frames = self._store.frames_processing_func(span.frames)[skip_frames:]
83+
else:
84+
span.frames = None
85+
self.spans.append(span)
86+
set_span(span.parent)
87+
self._store.queue_func(SPAN, span.to_dict())
12188
return span
12289

12390
def to_dict(self):
@@ -185,30 +152,38 @@ def to_dict(self):
185152
"type": encoding.keyword_field(self.type),
186153
"start": self.start_time * 1000, # milliseconds
187154
"duration": self.duration * 1000, # milliseconds
188-
"parent": self.parent,
155+
"parent": self.parent.idx if self.parent else None,
189156
"context": self.context,
190157
}
191158
if self.frames:
192159
result["stacktrace"] = self.frames
193160
return result
194161

195162

163+
class DroppedSpan(object):
164+
__slots__ = ("leaf", "parent")
165+
166+
def __init__(self, parent, leaf=False):
167+
self.parent = parent
168+
self.leaf = leaf
169+
170+
196171
class TransactionsStore(object):
197172
def __init__(
198173
self,
199174
frames_collector_func,
175+
frames_processing_func,
200176
queue_func,
201-
collect_frequency,
202177
sample_rate=1.0,
203178
max_spans=0,
204179
span_frames_min_duration=None,
205180
ignore_patterns=None,
206181
):
207182
self.cond = threading.Condition()
208-
self.collect_frequency = collect_frequency
209183
self.max_spans = max_spans
210-
self._queue_func = queue_func
211-
self._frames_collector_func = frames_collector_func
184+
self.queue_func = queue_func
185+
self.frames_processing_func = frames_processing_func
186+
self.frames_collector_func = frames_collector_func
212187
self._transactions = []
213188
self._last_collect = _time_func()
214189
self._ignore_patterns = [re.compile(p) for p in ignore_patterns or []]
@@ -244,15 +219,8 @@ def begin_transaction(self, transaction_type):
244219
:returns the Transaction object
245220
"""
246221
is_sampled = self._sample_rate == 1.0 or self._sample_rate > random.random()
247-
transaction = Transaction(
248-
self._frames_collector_func,
249-
self._queue_func,
250-
transaction_type,
251-
max_spans=self.max_spans,
252-
span_frames_min_duration=self.span_frames_min_duration,
253-
is_sampled=is_sampled,
254-
)
255-
thread_local.transaction = transaction
222+
transaction = Transaction(self, transaction_type, is_sampled=is_sampled)
223+
set_transaction(transaction)
256224
return transaction
257225

258226
def _should_ignore(self, transaction_name):
@@ -271,7 +239,7 @@ def end_transaction(self, result=None, transaction_name=None):
271239
return
272240
if transaction.result is None:
273241
transaction.result = result
274-
self._queue_func(TRANSACTION, transaction.to_dict())
242+
self.queue_func(TRANSACTION, transaction.to_dict())
275243
return transaction
276244

277245

@@ -303,7 +271,7 @@ def __exit__(self, exc_type, exc_val, exc_tb):
303271
if transaction and transaction.is_sampled:
304272
try:
305273
transaction.end_span(self.skip_frames)
306-
except IndexError:
274+
except LookupError:
307275
error_logger.info("ended non-existing span %s of type %s", self.name, self.type)
308276

309277

elasticapm/utils/stacks.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -158,7 +158,7 @@ def iter_traceback_frames(tb):
158158
tb = tb.tb_next
159159

160160

161-
def iter_stack_frames(frames=None, skip=0, skip_top_modules=()):
161+
def iter_stack_frames(frames=None, start_frame=None, skip=0, skip_top_modules=()):
162162
"""
163163
Given an optional list of frames (defaults to current stack),
164164
iterates over all frames that do not contain the ``__traceback_hide__``
@@ -173,12 +173,13 @@ def iter_stack_frames(frames=None, skip=0, skip_top_modules=()):
173173
itself.
174174
175175
:param frames: a list of frames, or None
176+
:param start_frame: a Frame object or None
176177
:param skip: number of frames to skip from the beginning
177178
:param skip_top_modules: tuple of strings
178179
179180
"""
180181
if not frames:
181-
frame = inspect.currentframe().f_back
182+
frame = start_frame if start_frame is not None else inspect.currentframe().f_back
182183
frames = _walk_stack(frame)
183184
stop_ignoring = False
184185
for i, frame in enumerate(frames):

tests/client/client_tests.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -586,7 +586,7 @@ def test_transaction_max_spans(elasticapm_client):
586586
spans = elasticapm_client.events[SPAN]
587587
assert all(span["transaction_id"] == transaction["id"] for span in spans)
588588

589-
assert transaction_obj.max_spans == 5
589+
assert transaction_obj._store.max_spans == 5
590590
assert transaction_obj.dropped_spans == 10
591591
assert len(spans) == 5
592592
for span in spans:

0 commit comments

Comments
 (0)