# stdlib import ddtrace from json import loads import socket # project from .encoding import get_encoder, JSONEncoder from .compat import httplib, PYTHON_VERSION, PYTHON_INTERPRETER, get_connection_response from .internal.logger import get_logger from .internal.runtime import container from .payload import Payload, PayloadFull from .utils.deprecation import deprecated from .utils import time log = get_logger(__name__) _VERSIONS = {'v0.4': {'traces': '/v0.4/traces', 'services': '/v0.4/services', 'compatibility_mode': False, 'fallback': 'v0.3'}, 'v0.3': {'traces': '/v0.3/traces', 'services': '/v0.3/services', 'compatibility_mode': False, 'fallback': 'v0.2'}, 'v0.2': {'traces': '/v0.2/traces', 'services': '/v0.2/services', 'compatibility_mode': True, 'fallback': None}} class Response(object): """ Custom API Response object to represent a response from calling the API. We do this to ensure we know expected properties will exist, and so we can call `resp.read()` and load the body once into an instance before we close the HTTPConnection used for the request. """ __slots__ = ['status', 'body', 'reason', 'msg'] def __init__(self, status=None, body=None, reason=None, msg=None): self.status = status self.body = body self.reason = reason self.msg = msg @classmethod def from_http_response(cls, resp): """ Build a ``Response`` from the provided ``HTTPResponse`` object. This function will call `.read()` to consume the body of the ``HTTPResponse`` object. :param resp: ``HTTPResponse`` object to build the ``Response`` from :type resp: ``HTTPResponse`` :rtype: ``Response`` :returns: A new ``Response`` """ return cls( status=resp.status, body=resp.read(), reason=getattr(resp, 'reason', None), msg=getattr(resp, 'msg', None), ) def get_json(self): """Helper to parse the body of this request as JSON""" try: body = self.body if not body: log.debug('Empty reply from Datadog Agent, %r', self) return if not isinstance(body, str) and hasattr(body, 'decode'): body = body.decode('utf-8') if hasattr(body, 'startswith') and body.startswith('OK'): # This typically happens when using a priority-sampling enabled # library with an outdated agent. It still works, but priority sampling # will probably send too many traces, so the next step is to upgrade agent. log.debug('Cannot parse Datadog Agent response, please make sure your Datadog Agent is up to date') return return loads(body) except (ValueError, TypeError): log.debug('Unable to parse Datadog Agent JSON response: %r', body, exc_info=True) def __repr__(self): return '{0}(status={1!r}, body={2!r}, reason={3!r}, msg={4!r})'.format( self.__class__.__name__, self.status, self.body, self.reason, self.msg, ) class UDSHTTPConnection(httplib.HTTPConnection): """An HTTP connection established over a Unix Domain Socket.""" # It's "important" to keep the hostname and port arguments here; while there are not used by the connection # mechanism, they are actually used as HTTP headers such as `Host`. def __init__(self, path, https, *args, **kwargs): if https: httplib.HTTPSConnection.__init__(self, *args, **kwargs) else: httplib.HTTPConnection.__init__(self, *args, **kwargs) self.path = path def connect(self): sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) sock.connect(self.path) self.sock = sock class API(object): """ Send data to the trace agent using the HTTP protocol and JSON format """ TRACE_COUNT_HEADER = 'X-Datadog-Trace-Count' # Default timeout when establishing HTTP connection and sending/receiving from socket. # This ought to be enough as the agent is local TIMEOUT = 2 def __init__(self, hostname, port, uds_path=None, https=False, headers=None, encoder=None, priority_sampling=False): """Create a new connection to the Tracer API. :param hostname: The hostname. :param port: The TCP port to use. :param uds_path: The path to use if the connection is to be established with a Unix Domain Socket. :param headers: The headers to pass along the request. :param encoder: The encoder to use to serialize data. :param priority_sampling: Whether to use priority sampling. """ self.hostname = hostname self.port = int(port) self.uds_path = uds_path self.https = https self._headers = headers or {} self._version = None if priority_sampling: self._set_version('v0.4', encoder=encoder) else: self._set_version('v0.3', encoder=encoder) self._headers.update({ 'Datadog-Meta-Lang': 'python', 'Datadog-Meta-Lang-Version': PYTHON_VERSION, 'Datadog-Meta-Lang-Interpreter': PYTHON_INTERPRETER, 'Datadog-Meta-Tracer-Version': ddtrace.__version__, }) # Add container information if we have it self._container_info = container.get_container_info() if self._container_info and self._container_info.container_id: self._headers.update({ 'Datadog-Container-Id': self._container_info.container_id, }) def __str__(self): if self.uds_path: return 'unix://' + self.uds_path if self.https: scheme = 'https://' else: scheme = 'http://' return '%s%s:%s' % (scheme, self.hostname, self.port) def _set_version(self, version, encoder=None): if version not in _VERSIONS: version = 'v0.2' if version == self._version: return self._version = version self._traces = _VERSIONS[version]['traces'] self._services = _VERSIONS[version]['services'] self._fallback = _VERSIONS[version]['fallback'] self._compatibility_mode = _VERSIONS[version]['compatibility_mode'] if self._compatibility_mode: self._encoder = JSONEncoder() else: self._encoder = encoder or get_encoder() # overwrite the Content-type with the one chosen in the Encoder self._headers.update({'Content-Type': self._encoder.content_type}) def _downgrade(self): """ Downgrades the used encoder and API level. This method must fallback to a safe encoder and API, so that it will success despite users' configurations. This action ensures that the compatibility mode is activated so that the downgrade will be executed only once. """ self._set_version(self._fallback) def send_traces(self, traces): """Send traces to the API. :param traces: A list of traces. :return: The list of API HTTP responses. """ if not traces: return [] with time.StopWatch() as sw: responses = [] payload = Payload(encoder=self._encoder) for trace in traces: try: payload.add_trace(trace) except PayloadFull: # Is payload full or is the trace too big? # If payload is not empty, then using a new Payload might allow us to fit the trace. # Let's flush the Payload and try to put the trace in a new empty Payload. if not payload.empty: responses.append(self._flush(payload)) # Create a new payload payload = Payload(encoder=self._encoder) try: # Add the trace that we were unable to add in that iteration payload.add_trace(trace) except PayloadFull: # If the trace does not fit in a payload on its own, that's bad. Drop it. log.warning('Trace %r is too big to fit in a payload, dropping it', trace) # Check that the Payload is not empty: # it could be empty if the last trace was too big to fit. if not payload.empty: responses.append(self._flush(payload)) log.debug('reported %d traces in %.5fs', len(traces), sw.elapsed()) return responses def _flush(self, payload): try: response = self._put(self._traces, payload.get_payload(), payload.length) except (httplib.HTTPException, OSError, IOError) as e: return e # the API endpoint is not available so we should downgrade the connection and re-try the call if response.status in [404, 415] and self._fallback: log.debug("calling endpoint '%s' but received %s; downgrading API", self._traces, response.status) self._downgrade() return self._flush(payload) return response @deprecated(message='Sending services to the API is no longer necessary', version='1.0.0') def send_services(self, *args, **kwargs): return def _put(self, endpoint, data, count): headers = self._headers.copy() headers[self.TRACE_COUNT_HEADER] = str(count) if self.uds_path is None: if self.https: conn = httplib.HTTPSConnection(self.hostname, self.port, timeout=self.TIMEOUT) else: conn = httplib.HTTPConnection(self.hostname, self.port, timeout=self.TIMEOUT) else: conn = UDSHTTPConnection(self.uds_path, self.https, self.hostname, self.port, timeout=self.TIMEOUT) try: conn.request('PUT', endpoint, data, headers) # Parse the HTTPResponse into an API.Response # DEV: This will call `resp.read()` which must happen before the `conn.close()` below, # if we call `.close()` then all future `.read()` calls will return `b''` resp = get_connection_response(conn) return Response.from_http_response(resp) finally: conn.close()