Skip to content

Commit 80748f4

Browse files
author
Emanuele Palazzetti
authored
Merge pull request #441 from palazzem/config-system
[core] add a Configuration system for integrations
2 parents 23ac123 + 885f4f9 commit 80748f4

File tree

6 files changed

+344
-102
lines changed

6 files changed

+344
-102
lines changed

ddtrace/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
from .pin import Pin
33
from .span import Span
44
from .tracer import Tracer
5-
from .configuration import Config
5+
from .settings import Config
66

77
__version__ = '0.11.1'
88

ddtrace/pin.py

Lines changed: 60 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,42 +1,54 @@
11
import logging
2-
import wrapt
32

3+
import wrapt
44
import ddtrace
55

6+
67
log = logging.getLogger(__name__)
78

8-
_DD_PIN_NAME = '_datadog_pin'
99

1010
# To set attributes on wrapt proxy objects use this prefix:
1111
# http://wrapt.readthedocs.io/en/latest/wrappers.html
12+
_DD_PIN_NAME = '_datadog_pin'
1213
_DD_PIN_PROXY_NAME = '_self_' + _DD_PIN_NAME
1314

1415

1516
class Pin(object):
16-
""" Pin (a.k.a Patch INfo) is a small class which is used to
17-
set tracing metadata on a particular traced connection.
18-
This is useful if you wanted to, say, trace two different
19-
database clusters.
17+
"""Pin (a.k.a Patch INfo) is a small class which is used to
18+
set tracing metadata on a particular traced connection.
19+
This is useful if you wanted to, say, trace two different
20+
database clusters.
2021
2122
>>> conn = sqlite.connect("/tmp/user.db")
2223
>>> # Override a pin for a specific connection
2324
>>> pin = Pin.override(conn, service="user-db")
2425
>>> conn = sqlite.connect("/tmp/image.db")
2526
"""
27+
__slots__ = ['app', 'app_type', 'tags', 'tracer', '_target', '_config', '_initialized']
2628

27-
__slots__ = ['app', 'app_type', 'service', 'tags', 'tracer', '_initialized']
28-
29-
def __init__(self, service, app=None, app_type=None, tags=None, tracer=None):
29+
def __init__(self, service, app=None, app_type=None, tags=None, tracer=None, _config=None):
3030
tracer = tracer or ddtrace.tracer
31-
self.service = service
3231
self.app = app
3332
self.app_type = app_type
3433
self.tags = tags
3534
self.tracer = tracer
35+
self._target = None
36+
# keep the configuration attribute internal because the
37+
# public API to access it is not the Pin class
38+
self._config = _config or {}
39+
# [Backward compatibility]: service argument updates the `Pin` config
40+
self._config['service_name'] = service
3641
self._initialized = True
3742

43+
@property
44+
def service(self):
45+
"""Backward compatibility: accessing to `pin.service` returns the underlying
46+
configuration value.
47+
"""
48+
return self._config['service_name']
49+
3850
def __setattr__(self, name, value):
39-
if hasattr(self, '_initialized'):
51+
if getattr(self, '_initialized', False) and name is not '_target':
4052
raise AttributeError("can't mutate a pin, use override() or clone() instead")
4153
super(Pin, self).__setattr__(name, value)
4254

@@ -46,15 +58,23 @@ def __repr__(self):
4658

4759
@staticmethod
4860
def get_from(obj):
49-
""" Return the pin associated with the given object.
61+
"""Return the pin associated with the given object. If a pin is attached to
62+
`obj` but the instance is not the owner of the pin, a new pin is cloned and
63+
attached. This ensures that a pin inherited from a class is a copy for the new
64+
instance, avoiding that a specific instance overrides other pins values.
5065
5166
>>> pin = Pin.get_from(conn)
5267
"""
5368
if hasattr(obj, '__getddpin__'):
5469
return obj.__getddpin__()
5570

5671
pin_name = _DD_PIN_PROXY_NAME if isinstance(obj, wrapt.ObjectProxy) else _DD_PIN_NAME
57-
return getattr(obj, pin_name, None)
72+
pin = getattr(obj, pin_name, None)
73+
# detect if the PIN has been inherited from a class
74+
if pin is not None and pin._target != id(obj):
75+
pin = pin.clone()
76+
pin.onto(obj)
77+
return pin
5878

5979
@classmethod
6080
def override(cls, obj, service=None, app=None, app_type=None, tags=None, tracer=None):
@@ -63,9 +83,9 @@ def override(cls, obj, service=None, app=None, app_type=None, tags=None, tracer=
6383
That's the recommended way to customize an already instrumented client, without
6484
losing existing attributes.
6585
66-
>>> conn = sqlite.connect("/tmp/user.db")
67-
>>> # Override a pin for a specific connection
68-
>>> pin = Pin.override(conn, service="user-db")
86+
>>> conn = sqlite.connect("/tmp/user.db")
87+
>>> # Override a pin for a specific connection
88+
>>> Pin.override(conn, service="user-db")
6989
"""
7090
if not obj:
7191
return
@@ -79,15 +99,16 @@ def override(cls, obj, service=None, app=None, app_type=None, tags=None, tracer=
7999
app=app,
80100
app_type=app_type,
81101
tags=tags,
82-
tracer=tracer).onto(obj)
102+
tracer=tracer,
103+
).onto(obj)
83104

84105
def enabled(self):
85-
""" Return true if this pin's tracer is enabled. """
106+
"""Return true if this pin's tracer is enabled. """
86107
return bool(self.tracer) and self.tracer.enabled
87108

88109
def onto(self, obj, send=True):
89-
""" Patch this pin onto the given object. If send is true, it will also
90-
queue the metadata to be sent to the server.
110+
"""Patch this pin onto the given object. If send is true, it will also
111+
queue the metadata to be sent to the server.
91112
"""
92113
# pinning will also queue the metadata for service submission. this
93114
# feels a bit side-effecty, but bc it's async and pretty clearly
@@ -104,25 +125,39 @@ def onto(self, obj, send=True):
104125
return obj.__setddpin__(self)
105126

106127
pin_name = _DD_PIN_PROXY_NAME if isinstance(obj, wrapt.ObjectProxy) else _DD_PIN_NAME
128+
129+
# set the target reference; any get_from, clones and retarget the new PIN
130+
self._target = id(obj)
107131
return setattr(obj, pin_name, self)
108132
except AttributeError:
109133
log.debug("can't pin onto object. skipping", exc_info=True)
110134

111135
def clone(self, service=None, app=None, app_type=None, tags=None, tracer=None):
112-
""" Return a clone of the pin with the given attributes replaced. """
136+
"""Return a clone of the pin with the given attributes replaced."""
137+
# do a shallow copy of Pin dicts
113138
if not tags and self.tags:
114-
# do a shallow copy of the tags if needed.
115-
tags = {k:v for k, v in self.tags.items()}
139+
tags = self.tags.copy()
140+
141+
# we use a copy instead of a deepcopy because we expect configurations
142+
# to have only a root level dictionary without nested objects. Using
143+
# deepcopy introduces a big overhead:
144+
#
145+
# copy: 0.00654911994934082
146+
# deepcopy: 0.2787208557128906
147+
config = self._config.copy()
116148

117149
return Pin(
118150
service=service or self.service,
119151
app=app or self.app,
120152
app_type=app_type or self.app_type,
121153
tags=tags,
122-
tracer=tracer or self.tracer) # no copy of the tracer
154+
tracer=tracer or self.tracer, # do not clone the Tracer
155+
_config=config,
156+
)
123157

124158
def _send(self):
125159
self.tracer.set_service_info(
126160
service=self.service,
127161
app=self.app,
128-
app_type=self.app_type)
162+
app_type=self.app_type,
163+
)

ddtrace/configuration.py renamed to ddtrace/settings.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
1+
import logging
2+
13
from copy import deepcopy
24

5+
from .pin import Pin
6+
7+
8+
log = logging.getLogger(__name__)
9+
310

411
class ConfigException(Exception):
512
"""Configuration exception when an integration that is not available
@@ -26,6 +33,19 @@ def __getattr__(self, name):
2633
'Integration "{}" is not registered in this configuration'.format(e.message)
2734
)
2835

36+
def get_from(self, obj):
37+
"""Retrieves the configuration for the given object.
38+
Any object that has an attached `Pin` must have a configuration
39+
and if a wrong object is given, an empty `dict` is returned
40+
for safety reasons.
41+
"""
42+
pin = Pin.get_from(obj)
43+
if pin is None:
44+
log.debug('No configuration found for %s', obj)
45+
return {}
46+
47+
return pin._config
48+
2949
def _add(self, integration, settings):
3050
"""Internal API that registers an integration with given default
3151
settings.

tests/test_configuration.py renamed to tests/test_global_config.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,10 @@
33
from nose.tools import eq_, ok_, assert_raises
44

55
from ddtrace import config as global_config
6-
from ddtrace.configuration import Config, ConfigException
6+
from ddtrace.settings import Config, ConfigException
77

88

9-
class ConfigTestCase(TestCase):
9+
class GlobalConfigTestCase(TestCase):
1010
"""Test the `Configuration` class that stores integration settings"""
1111
def setUp(self):
1212
self.config = Config()

tests/test_instance_config.py

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
from unittest import TestCase
2+
3+
from nose.tools import eq_, ok_
4+
5+
from ddtrace import config
6+
from ddtrace.pin import Pin
7+
8+
9+
class InstanceConfigTestCase(TestCase):
10+
"""TestCase for the Configuration API that is used to define
11+
global settings and for each `Pin` instance.
12+
"""
13+
def setUp(self):
14+
class Klass(object):
15+
"""Helper class where a Pin is always attached"""
16+
pass
17+
18+
# define the Class and attach a Pin to it
19+
self.Klass = Klass
20+
Pin(service='metrics').onto(Klass)
21+
22+
def test_configuration_get_from(self):
23+
# ensure a dictionary is returned
24+
cfg = config.get_from(self.Klass)
25+
ok_(isinstance(cfg, dict))
26+
27+
def test_configuration_get_from_twice(self):
28+
# ensure the configuration is the same if `get_from` is used
29+
# in the same instance
30+
instance = self.Klass()
31+
cfg1 = config.get_from(instance)
32+
cfg2 = config.get_from(instance)
33+
ok_(cfg1 is cfg2)
34+
35+
def test_configuration_set(self):
36+
# ensure the configuration can be updated in the Pin
37+
instance = self.Klass()
38+
cfg = config.get_from(instance)
39+
cfg['distributed_tracing'] = True
40+
ok_(config.get_from(instance)['distributed_tracing'] is True)
41+
42+
def test_global_configuration_inheritance(self):
43+
# ensure global configuration is inherited when it's set
44+
cfg = config.get_from(self.Klass)
45+
cfg['distributed_tracing'] = True
46+
instance = self.Klass()
47+
ok_(config.get_from(instance)['distributed_tracing'] is True)
48+
49+
def test_configuration_override_instance(self):
50+
# ensure instance configuration doesn't override global settings
51+
global_cfg = config.get_from(self.Klass)
52+
global_cfg['distributed_tracing'] = True
53+
instance = self.Klass()
54+
cfg = config.get_from(instance)
55+
cfg['distributed_tracing'] = False
56+
ok_(config.get_from(self.Klass)['distributed_tracing'] is True)
57+
ok_(config.get_from(instance)['distributed_tracing'] is False)
58+
59+
def test_service_name_for_pin(self):
60+
# ensure for backward compatibility that changing the service
61+
# name via the Pin object also updates integration config
62+
Pin(service='intake').onto(self.Klass)
63+
instance = self.Klass()
64+
cfg = config.get_from(instance)
65+
eq_(cfg['service_name'], 'intake')
66+
67+
def test_service_attribute_priority(self):
68+
# ensure the `service` arg has highest priority over configuration
69+
# for backward compatibility
70+
global_config = {
71+
'service_name': 'primary_service',
72+
}
73+
Pin(service='service', _config=global_config).onto(self.Klass)
74+
instance = self.Klass()
75+
cfg = config.get_from(instance)
76+
eq_(cfg['service_name'], 'service')
77+
78+
def test_configuration_copy(self):
79+
# ensure when a Pin is used, the given configuration is copied
80+
global_config = {
81+
'service_name': 'service',
82+
}
83+
Pin(service='service', _config=global_config).onto(self.Klass)
84+
instance = self.Klass()
85+
cfg = config.get_from(instance)
86+
cfg['service_name'] = 'metrics'
87+
eq_(global_config['service_name'], 'service')
88+
89+
def test_configuration_copy_upside_down(self):
90+
# ensure when a Pin is created, it does not copy the given configuration
91+
# until it's used for at least once
92+
global_config = {
93+
'service_name': 'service',
94+
}
95+
Pin(service='service', _config=global_config).onto(self.Klass)
96+
# override the global config: users do that before using the integration
97+
global_config['service_name'] = 'metrics'
98+
# use the Pin via `get_from`
99+
instance = self.Klass()
100+
cfg = config.get_from(instance)
101+
# it should have users updated value
102+
eq_(cfg['service_name'], 'metrics')

0 commit comments

Comments
 (0)