Skip to content

Commit e5c21e9

Browse files
committed
[core] add support for integration span hooks (#679)
* [core] add Tracer.on and Tracer.emit for span hooks * [core] rename Tracer.emit to Tracer._emit * [core] fix mispelling of Tracer._hooks * [core] do not raise an exception or error log * [falcon] add span hook documentation for Falcon * [core] add tests for Tracer.on/Tracer._emit * [core] add tracer hook argument tests * [core] register span hooks on config object instead * [core] fix flake8 issues * Update ddtrace/settings.py * Update ddtrace/settings.py * Update ddtrace/settings.py * [core] remove Hooks.__getattr__, add Hooks.on alias for Hooks.register
1 parent be36ebb commit e5c21e9

File tree

5 files changed

+318
-1
lines changed

5 files changed

+318
-1
lines changed

ddtrace/contrib/falcon/__init__.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,28 @@
1919
2020
To enable distributed tracing when using autopatching, set the
2121
``DATADOG_FALCON_DISTRIBUTED_TRACING`` environment variable to ``True``.
22+
23+
**Supported span hooks**
24+
25+
The following is a list of available tracer hooks that can be used to intercept
26+
and modify spans created by this integration.
27+
28+
- ``request``
29+
- Called before the response has been finished
30+
- ``def on_falcon_request(span, request, response)``
31+
32+
33+
Example::
34+
35+
import falcon
36+
from ddtrace import config, patch_all
37+
patch_all()
38+
39+
app = falcon.API()
40+
41+
@config.falcon.hooks.on('request')
42+
def on_falcon_request(span, request, response):
43+
span.set_tag('my.custom', 'tag')
2244
"""
2345
from ...utils.importlib import require_modules
2446

ddtrace/contrib/falcon/middleware.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from ddtrace.propagation.http import HTTPPropagator
55
from ...compat import iteritems
66
from ...ext import AppTypes
7+
from ...settings import config
78

89

910
class TraceMiddleware(object):
@@ -76,6 +77,12 @@ def process_response(self, req, resp, resource, req_succeeded=None):
7677
status = _detect_and_set_status_error(err_type, span)
7778

7879
span.set_tag(httpx.STATUS_CODE, status)
80+
81+
# Emit span hook for this response
82+
# DEV: Emit before closing so they can overwrite `span.resource` if they want
83+
config.falcon.hooks._emit('request', span, req, resp)
84+
85+
# Close the span
7986
span.finish()
8087

8188

ddtrace/settings.py

Lines changed: 119 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
1+
import collections
12
import logging
23

34
from copy import deepcopy
45

6+
from .span import Span
57
from .pin import Pin
68
from .utils.merge import deepmerge
79

@@ -102,11 +104,12 @@ def __init__(self, global_config, *args, **kwargs):
102104
:param kwargs:
103105
"""
104106
super(IntegrationConfig, self).__init__(*args, **kwargs)
105-
106107
self.global_config = global_config
108+
self.hooks = Hooks()
107109

108110
def __deepcopy__(self, memodict=None):
109111
new = IntegrationConfig(self.global_config, deepcopy(dict(self)))
112+
new.hooks = deepcopy(self.hooks)
110113
return new
111114

112115
def __repr__(self):
@@ -115,5 +118,120 @@ def __repr__(self):
115118
return '{}.{}({})'.format(cls.__module__, cls.__name__, keys)
116119

117120

121+
class Hooks(object):
122+
"""
123+
Hooks configuration object is used for registering and calling hook functions
124+
125+
Example::
126+
127+
@config.falcon.hooks.on('request')
128+
def on_request(span, request, response):
129+
pass
130+
"""
131+
__slots__ = ['_hooks']
132+
133+
def __init__(self):
134+
self._hooks = collections.defaultdict(set)
135+
136+
def __deepcopy__(self, memodict=None):
137+
hooks = Hooks()
138+
hooks._hooks = deepcopy(self._hooks)
139+
return hooks
140+
141+
def register(self, hook, func=None):
142+
"""
143+
Function used to register a hook for the provided name.
144+
145+
Example::
146+
147+
def on_request(span, request, response):
148+
pass
149+
150+
config.falcon.hooks.register('request', on_request)
151+
152+
153+
If no function is provided then a decorator is returned::
154+
155+
@config.falcon.hooks.register('request')
156+
def on_request(span, request, response):
157+
pass
158+
159+
:param hook: The name of the hook to register the function for
160+
:type hook: str
161+
:param func: The function to register, or ``None`` if a decorator should be returned
162+
:type func: function, None
163+
:returns: Either a function decorator if ``func is None``, otherwise ``None``
164+
:rtype: function, None
165+
"""
166+
# If they didn't provide a function, then return a decorator
167+
if not func:
168+
def wrapper(func):
169+
self.register(hook, func)
170+
return func
171+
return wrapper
172+
self._hooks[hook].add(func)
173+
174+
# Provide shorthand `on` method for `register`
175+
# >>> @config.falcon.hooks.on('request')
176+
# def on_request(span, request, response):
177+
# pass
178+
on = register
179+
180+
def deregister(self, func):
181+
"""
182+
Function to deregister a function from all hooks it was registered under
183+
184+
Example::
185+
186+
@config.falcon.hooks.on('request')
187+
def on_request(span, request, response):
188+
pass
189+
190+
config.falcon.hooks.deregister(on_request)
191+
192+
193+
:param func: Function hook to register
194+
:type func: function
195+
"""
196+
for funcs in self._hooks.values():
197+
if func in funcs:
198+
funcs.remove(func)
199+
200+
def _emit(self, hook, span, *args, **kwargs):
201+
"""
202+
Function used to call registered hook functions.
203+
204+
:param hook: The hook to call functions for
205+
:type hook: str
206+
:param span: The span to call the hook with
207+
:type span: :class:`ddtrace.span.Span`
208+
:param *args: Positional arguments to pass to the hook functions
209+
:type args: list
210+
:param **kwargs: Keyword arguments to pass to the hook functions
211+
:type kwargs: dict
212+
"""
213+
# Return early if no hooks are registered
214+
if hook not in self._hooks:
215+
return
216+
217+
# Return early if we don't have a Span
218+
if not isinstance(span, Span):
219+
return
220+
221+
# Call registered hooks
222+
for func in self._hooks[hook]:
223+
try:
224+
func(span, *args, **kwargs)
225+
except Exception as e:
226+
# DEV: Use log.debug instead of log.error until we have a throttled logger
227+
log.debug('Failed to run hook {} function {}: {}'.format(hook, func, e))
228+
229+
def __repr__(self):
230+
"""Return string representation of this class instance"""
231+
cls = self.__class__
232+
hooks = ','.join(self._hooks.keys())
233+
return '{}.{}({})'.format(cls.__module__, cls.__name__, hooks)
234+
235+
118236
# Configure our global configuration object
119237
config = Config()

tests/contrib/falcon/test_suite.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from nose.tools import eq_, ok_
22

3+
from ddtrace import config
34
from ddtrace.ext import errors as errx, http as httpx
45

56
from tests.opentracer.utils import init_tracer
@@ -158,3 +159,20 @@ def test_200_ot(self):
158159
eq_(dd_span.resource, 'GET tests.contrib.falcon.app.resources.Resource200')
159160
eq_(dd_span.get_tag(httpx.STATUS_CODE), '200')
160161
eq_(dd_span.get_tag(httpx.URL), 'http://falconframework.org/200')
162+
163+
def test_falcon_request_hook(self):
164+
@config.falcon.hooks.on('request')
165+
def on_falcon_request(span, request, response):
166+
span.set_tag('my.custom', 'tag')
167+
168+
out = self.simulate_get('/200')
169+
eq_(out.status_code, 200)
170+
eq_(out.content.decode('utf-8'), 'Success')
171+
172+
traces = self.tracer.writer.pop_traces()
173+
eq_(len(traces), 1)
174+
eq_(len(traces[0]), 1)
175+
span = traces[0][0]
176+
eq_(span.name, 'falcon.request')
177+
178+
eq_(span.get_tag('my.custom'), 'tag')

tests/test_global_config.py

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,19 @@
1+
import mock
12
from unittest import TestCase
23

34
from nose.tools import eq_, ok_, assert_raises
45

56
from ddtrace import config as global_config
67
from ddtrace.settings import Config, ConfigException
78

9+
from .test_tracer import get_dummy_tracer
10+
811

912
class GlobalConfigTestCase(TestCase):
1013
"""Test the `Configuration` class that stores integration settings"""
1114
def setUp(self):
1215
self.config = Config()
16+
self.tracer = get_dummy_tracer()
1317

1418
def test_registration(self):
1519
# ensure an integration can register a new list of settings
@@ -90,3 +94,151 @@ def test_settings_merge_deep(self):
9094
))
9195
eq_(self.config.requests['a']['b']['c'], True)
9296
eq_(self.config.requests['a']['b']['d'], True)
97+
98+
def test_settings_hook(self):
99+
"""
100+
When calling `Hooks._emit()`
101+
When there is a hook registered
102+
we call the hook as expected
103+
"""
104+
# Setup our hook
105+
@self.config.web.hooks.on('request')
106+
def on_web_request(span):
107+
span.set_tag('web.request', '/')
108+
109+
# Create our span
110+
span = self.tracer.start_span('web.request')
111+
ok_('web.request' not in span.meta)
112+
113+
# Emit the span
114+
self.config.web.hooks._emit('request', span)
115+
116+
# Assert we updated the span as expected
117+
eq_(span.get_tag('web.request'), '/')
118+
119+
def test_settings_hook_args(self):
120+
"""
121+
When calling `Hooks._emit()` with arguments
122+
When there is a hook registered
123+
we call the hook as expected
124+
"""
125+
# Setup our hook
126+
@self.config.web.hooks.on('request')
127+
def on_web_request(span, request, response):
128+
span.set_tag('web.request', request)
129+
span.set_tag('web.response', response)
130+
131+
# Create our span
132+
span = self.tracer.start_span('web.request')
133+
ok_('web.request' not in span.meta)
134+
135+
# Emit the span
136+
# DEV: The actual values don't matter, we just want to test args + kwargs usage
137+
self.config.web.hooks._emit('request', span, 'request', response='response')
138+
139+
# Assert we updated the span as expected
140+
eq_(span.get_tag('web.request'), 'request')
141+
eq_(span.get_tag('web.response'), 'response')
142+
143+
def test_settings_hook_args_failure(self):
144+
"""
145+
When calling `Hooks._emit()` with arguments
146+
When there is a hook registered that is missing parameters
147+
we do not raise an exception
148+
"""
149+
# Setup our hook
150+
# DEV: We are missing the required "response" argument
151+
@self.config.web.hooks.on('request')
152+
def on_web_request(span, request):
153+
span.set_tag('web.request', request)
154+
155+
# Create our span
156+
span = self.tracer.start_span('web.request')
157+
ok_('web.request' not in span.meta)
158+
159+
# Emit the span
160+
# DEV: This also asserts that no exception was raised
161+
self.config.web.hooks._emit('request', span, 'request', response='response')
162+
163+
# Assert we did not update the span
164+
ok_('web.request' not in span.meta)
165+
166+
def test_settings_multiple_hooks(self):
167+
"""
168+
When calling `Hooks._emit()`
169+
When there are multiple hooks registered
170+
we do not raise an exception
171+
"""
172+
# Setup our hooks
173+
@self.config.web.hooks.on('request')
174+
def on_web_request(span):
175+
span.set_tag('web.request', '/')
176+
177+
@self.config.web.hooks.on('request')
178+
def on_web_request2(span):
179+
span.set_tag('web.status', 200)
180+
181+
@self.config.web.hooks.on('request')
182+
def on_web_request3(span):
183+
span.set_tag('web.method', 'GET')
184+
185+
# Create our span
186+
span = self.tracer.start_span('web.request')
187+
ok_('web.request' not in span.meta)
188+
ok_('web.status' not in span.meta)
189+
ok_('web.method' not in span.meta)
190+
191+
# Emit the span
192+
self.config.web.hooks._emit('request', span)
193+
194+
# Assert we updated the span as expected
195+
eq_(span.get_tag('web.request'), '/')
196+
eq_(span.get_tag('web.status'), '200')
197+
eq_(span.get_tag('web.method'), 'GET')
198+
199+
def test_settings_hook_failure(self):
200+
"""
201+
When calling `Hooks._emit()`
202+
When the hook raises an exception
203+
we do not raise an exception
204+
"""
205+
# Setup our failing hook
206+
on_web_request = mock.Mock(side_effect=Exception)
207+
self.config.web.hooks.register('request')(on_web_request)
208+
209+
# Create our span
210+
span = self.tracer.start_span('web.request')
211+
212+
# Emit the span
213+
# DEV: This is the test, to ensure no exceptions are raised
214+
self.config.web.hooks._emit('request', span)
215+
on_web_request.assert_called()
216+
217+
def test_settings_no_hook(self):
218+
"""
219+
When calling `Hooks._emit()`
220+
When no hook is registered
221+
we do not raise an exception
222+
"""
223+
# Create our span
224+
span = self.tracer.start_span('web.request')
225+
226+
# Emit the span
227+
# DEV: This is the test, to ensure no exceptions are raised
228+
self.config.web.hooks._emit('request', span)
229+
on_web_request.assert_called()
230+
231+
def test_settings_no_hook(self):
232+
"""
233+
When calling `Hooks._emit()`
234+
When no span is provided
235+
we do not raise an exception
236+
"""
237+
# Setup our hooks
238+
@self.config.web.hooks.on('request')
239+
def on_web_request(span):
240+
span.set_tag('web.request', '/')
241+
242+
# Emit the span
243+
# DEV: This is the test, to ensure no exceptions are raised
244+
self.config.web.hooks._emit('request', None)

0 commit comments

Comments
 (0)