mirror of
https://github.com/open-telemetry/opentelemetry-python-contrib.git
synced 2025-07-29 21:23:55 +08:00
280 lines
10 KiB
Python
280 lines
10 KiB
Python
# 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()
|