Skip to content

Commit 263a936

Browse files
authored
fix: support module-level @Runnable with continue_tracing() (#391)
## Summary `@runnable` previously captured the trace context snapshot at decoration time, which only worked when applied **inline** during an active request. Module-level `@runnable` (the natural Python pattern) silently broke cross-thread trace linking because the snapshot was `None` at import time. ### Changes - `@runnable` now returns a `_RunnableWrapper` object with a `continue_tracing()` method - `continue_tracing()` captures the snapshot on the calling (parent) thread and returns a callable for use as `Thread` target — this enables module-level `@runnable` - `__call__` preserves the original behavior: uses the decoration-time snapshot for inline `@runnable` — **no breaking changes** ### Usage **Module-level (new, previously broken):** ```python @Runnable(op='/post') def post(): requests.post(...) @app.route('/') def hello(): thread = Thread(target=post.continue_tracing()) # snapshot captured here thread.start() ``` **Inline (unchanged, backward compatible):** ```python @app.route('/') def hello(): @Runnable(op='/post') # snapshot captured at decoration time def post(): requests.post(...) thread = Thread(target=post) thread.start() ``` Closes apache/skywalking#11605
1 parent 71640af commit 263a936

File tree

9 files changed

+375
-16
lines changed

9 files changed

+375
-16
lines changed

skywalking/decorators.py

Lines changed: 55 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -66,28 +66,67 @@ def wrapper(*args, **kwargs):
6666
return decorator
6767

6868

69+
class _RunnableWrapper:
70+
"""Wrapper returned by @runnable. Call continue_tracing() on the parent thread
71+
to capture the current trace context, then pass the result as Thread target."""
72+
73+
def __init__(self, func, op, layer, component, tags):
74+
self._func = func
75+
self._op = op
76+
self._layer = layer
77+
self._component = component
78+
self._tags = tags
79+
# Capture snapshot at decoration time — supports inline @runnable usage
80+
self._snapshot = get_context().capture()
81+
# Preserve original function attributes
82+
self.__name__ = func.__name__
83+
self.__doc__ = func.__doc__
84+
self.__module__ = getattr(func, '__module__', None)
85+
self.__wrapped__ = func
86+
87+
def __call__(self, *args, **kwargs):
88+
"""Direct call — creates a local span with cross-thread propagation
89+
using the snapshot captured at decoration time (inline @runnable pattern)."""
90+
context = get_context()
91+
with context.new_local_span(op=self._op) as span:
92+
if self._snapshot is not None:
93+
context.continued(self._snapshot)
94+
span.layer = self._layer
95+
span.component = self._component
96+
if self._tags:
97+
for tag in self._tags:
98+
span.tag(tag)
99+
return self._func(*args, **kwargs)
100+
101+
def continue_tracing(self):
102+
"""Capture the current trace context snapshot on the calling thread.
103+
Returns a callable to be used as Thread target that will propagate
104+
the trace context to the child thread via CrossThread reference."""
105+
snapshot = get_context().capture()
106+
107+
def _continued_wrapper(*args, **kwargs):
108+
context = get_context()
109+
with context.new_local_span(op=self._op) as span:
110+
if snapshot is not None:
111+
context.continued(snapshot)
112+
span.layer = self._layer
113+
span.component = self._component
114+
if self._tags:
115+
for tag in self._tags:
116+
span.tag(tag)
117+
return self._func(*args, **kwargs)
118+
119+
return _continued_wrapper
120+
121+
69122
def runnable(
70123
op: str = None,
71124
layer: Layer = Layer.Unknown,
72125
component: Component = Component.Unknown,
73126
tags: List[Tag] = None,
74127
):
75128
def decorator(func):
76-
snapshot = get_context().capture()
77-
78-
@wraps(func)
79-
def wrapper(*args, **kwargs):
80-
_op = op or f'Thread/{func.__name__}'
81-
context = get_context()
82-
with context.new_local_span(op=_op) as span:
83-
context.continued(snapshot)
84-
span.layer = layer
85-
span.component = component
86-
if tags:
87-
for tag in tags:
88-
span.tag(tag)
89-
func(*args, **kwargs)
90-
91-
return wrapper
129+
_op = op or f'Thread/{func.__name__}'
130+
return _RunnableWrapper(func, _op, layer, component, tags)
92131

93132
return decorator
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
#
2+
# Licensed to the Apache Software Foundation (ASF) under one or more
3+
# contributor license agreements. See the NOTICE file distributed with
4+
# this work for additional information regarding copyright ownership.
5+
# The ASF licenses this file to You under the Apache License, Version 2.0
6+
# (the "License"); you may not use this file except in compliance with
7+
# the License. You may obtain a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing, software
12+
# distributed under the License is distributed on an "AS IS" BASIS,
13+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
# See the License for the specific language governing permissions and
15+
# limitations under the License.
16+
#
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
#
2+
# Licensed to the Apache Software Foundation (ASF) under one or more
3+
# contributor license agreements. See the NOTICE file distributed with
4+
# this work for additional information regarding copyright ownership.
5+
# The ASF licenses this file to You under the Apache License, Version 2.0
6+
# (the "License"); you may not use this file except in compliance with
7+
# the License. You may obtain a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing, software
12+
# distributed under the License is distributed on an "AS IS" BASIS,
13+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
# See the License for the specific language governing permissions and
15+
# limitations under the License.
16+
#
17+
18+
version: '2.1'
19+
20+
services:
21+
collector:
22+
extends:
23+
service: collector
24+
file: ../../docker-compose.base.yml
25+
26+
provider:
27+
extends:
28+
service: agent
29+
file: ../../docker-compose.base.yml
30+
ports:
31+
- 9091:9091
32+
volumes:
33+
- .:/app
34+
command: ['bash', '-c', 'pip install flask && pip install -r /app/requirements.txt && sw-python run python3 /app/services/provider.py']
35+
depends_on:
36+
collector:
37+
condition: service_healthy
38+
healthcheck:
39+
test: ["CMD", "bash", "-c", "cat < /dev/null > /dev/tcp/127.0.0.1/9091"]
40+
interval: 5s
41+
timeout: 60s
42+
retries: 120
43+
environment:
44+
SW_AGENT_NAME: provider
45+
SW_AGENT_LOGGING_LEVEL: DEBUG
46+
47+
consumer:
48+
extends:
49+
service: agent
50+
file: ../../docker-compose.base.yml
51+
ports:
52+
- 9090:9090
53+
volumes:
54+
- .:/app
55+
command: ['bash', '-c', 'pip install flask && pip install -r /app/requirements.txt && sw-python run python3 /app/services/consumer.py']
56+
depends_on:
57+
collector:
58+
condition: service_healthy
59+
provider:
60+
condition: service_healthy
61+
environment:
62+
SW_AGENT_NAME: consumer
63+
SW_AGENT_LOGGING_LEVEL: DEBUG
64+
networks:
65+
beyond:
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
#
2+
# Licensed to the Apache Software Foundation (ASF) under one or more
3+
# contributor license agreements. See the NOTICE file distributed with
4+
# this work for additional information regarding copyright ownership.
5+
# The ASF licenses this file to You under the Apache License, Version 2.0
6+
# (the "License"); you may not use this file except in compliance with
7+
# the License. You may obtain a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing, software
12+
# distributed under the License is distributed on an "AS IS" BASIS,
13+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
# See the License for the specific language governing permissions and
15+
# limitations under the License.
16+
#
17+
18+
segmentItems:
19+
- serviceName: provider
20+
segmentSize: 1
21+
segments:
22+
- segmentId: not null
23+
spans:
24+
- operationName: /users
25+
parentSpanId: -1
26+
spanId: 0
27+
spanLayer: Http
28+
tags:
29+
- key: http.method
30+
value: POST
31+
- key: http.url
32+
value: http://provider:9091/users
33+
- key: http.status_code
34+
value: '200'
35+
refs:
36+
- parentEndpoint: /users
37+
networkAddress: 'provider:9091'
38+
refType: CrossProcess
39+
parentSpanId: 1
40+
parentTraceSegmentId: not null
41+
parentServiceInstance: not null
42+
parentService: consumer
43+
traceId: not null
44+
startTime: gt 0
45+
endTime: gt 0
46+
componentId: 7001
47+
spanType: Entry
48+
peer: not null
49+
skipAnalysis: false
50+
- serviceName: consumer
51+
segmentSize: 2
52+
segments:
53+
- segmentId: not null
54+
spans:
55+
- operationName: /users
56+
parentSpanId: 0
57+
spanId: 1
58+
spanLayer: Http
59+
startTime: gt 0
60+
endTime: gt 0
61+
componentId: 7002
62+
isError: false
63+
spanType: Exit
64+
peer: provider:9091
65+
skipAnalysis: false
66+
tags:
67+
- key: http.method
68+
value: POST
69+
- key: http.url
70+
value: 'http://provider:9091/users'
71+
- key: http.status_code
72+
value: '200'
73+
- operationName: /post
74+
operationId: 0
75+
parentSpanId: -1
76+
spanId: 0
77+
spanLayer: Unknown
78+
startTime: gt 0
79+
endTime: gt 0
80+
componentId: 0
81+
isError: false
82+
spanType: Local
83+
peer: ''
84+
skipAnalysis: false
85+
refs:
86+
- parentEndpoint: /users
87+
networkAddress: ''
88+
refType: CrossThread
89+
parentSpanId: 0
90+
parentTraceSegmentId: not null
91+
parentServiceInstance: not null
92+
parentService: consumer
93+
traceId: not null
94+
- segmentId: not null
95+
spans:
96+
- operationName: /users
97+
operationId: 0
98+
parentSpanId: -1
99+
spanId: 0
100+
spanLayer: Http
101+
tags:
102+
- key: http.method
103+
value: GET
104+
- key: http.url
105+
value: http://0.0.0.0:9090/users
106+
- key: http.status_code
107+
value: '200'
108+
startTime: gt 0
109+
endTime: gt 0
110+
componentId: 7001
111+
spanType: Entry
112+
peer: not null
113+
skipAnalysis: false
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
#
2+
# Licensed to the Apache Software Foundation (ASF) under one or more
3+
# contributor license agreements. See the NOTICE file distributed with
4+
# this work for additional information regarding copyright ownership.
5+
# The ASF licenses this file to You under the Apache License, Version 2.0
6+
# (the "License"); you may not use this file except in compliance with
7+
# the License. You may obtain a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing, software
12+
# distributed under the License is distributed on an "AS IS" BASIS,
13+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
# See the License for the specific language governing permissions and
15+
# limitations under the License.
16+
#
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
#
2+
# Licensed to the Apache Software Foundation (ASF) under one or more
3+
# contributor license agreements. See the NOTICE file distributed with
4+
# this work for additional information regarding copyright ownership.
5+
# The ASF licenses this file to You under the Apache License, Version 2.0
6+
# (the "License"); you may not use this file except in compliance with
7+
# the License. You may obtain a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing, software
12+
# distributed under the License is distributed on an "AS IS" BASIS,
13+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
# See the License for the specific language governing permissions and
15+
# limitations under the License.
16+
#
17+
18+
import requests
19+
20+
from skywalking.decorators import runnable
21+
22+
23+
# Module-level @runnable — this is the pattern from issue #11605
24+
@runnable(op='/post')
25+
def post():
26+
requests.post('http://provider:9091/users', timeout=5)
27+
28+
29+
if __name__ == '__main__':
30+
from flask import Flask, jsonify
31+
32+
app = Flask(__name__)
33+
34+
@app.route('/users', methods=['POST', 'GET'])
35+
def application():
36+
from threading import Thread
37+
t = Thread(target=post.continue_tracing())
38+
t.start()
39+
t.join()
40+
41+
return jsonify({'status': 'ok'})
42+
43+
PORT = 9090
44+
app.run(host='0.0.0.0', port=PORT, debug=True)
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
#
2+
# Licensed to the Apache Software Foundation (ASF) under one or more
3+
# contributor license agreements. See the NOTICE file distributed with
4+
# this work for additional information regarding copyright ownership.
5+
# The ASF licenses this file to You under the Apache License, Version 2.0
6+
# (the "License"); you may not use this file except in compliance with
7+
# the License. You may obtain a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing, software
12+
# distributed under the License is distributed on an "AS IS" BASIS,
13+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
# See the License for the specific language governing permissions and
15+
# limitations under the License.
16+
#
17+
18+
import time
19+
20+
21+
if __name__ == '__main__':
22+
from flask import Flask, jsonify
23+
24+
app = Flask(__name__)
25+
26+
@app.route('/users', methods=['POST', 'GET'])
27+
def application():
28+
time.sleep(0.5)
29+
return jsonify({'status': 'ok'})
30+
31+
PORT = 9091
32+
app.run(host='0.0.0.0', port=PORT, debug=True)

0 commit comments

Comments
 (0)