domainnameapi 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- domainnameapi/__init__.py +38 -0
- domainnameapi/_php.py +482 -0
- domainnameapi/_version.py +4 -0
- domainnameapi/client.py +275 -0
- domainnameapi/py.typed +0 -0
- domainnameapi/rest.py +1689 -0
- domainnameapi/shared.py +182 -0
- domainnameapi/telemetry.py +471 -0
- domainnameapi/transport.py +217 -0
- domainnameapi-1.0.0.dist-info/METADATA +1637 -0
- domainnameapi-1.0.0.dist-info/RECORD +13 -0
- domainnameapi-1.0.0.dist-info/WHEEL +4 -0
- domainnameapi-1.0.0.dist-info/licenses/LICENSE +201 -0
domainnameapi/shared.py
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""Constants and helpers shared by the client and the telemetry layer.
|
|
2
|
+
|
|
3
|
+
Port of php-dna ``SharedApiConfigAndUtilsTrait``.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import re
|
|
7
|
+
|
|
8
|
+
from . import _php as php
|
|
9
|
+
from ._version import VERSION
|
|
10
|
+
|
|
11
|
+
APPLICATION = "DomainNameAPI-Python"
|
|
12
|
+
USER_AGENT = "DomainNameAPI-Python/" + VERSION
|
|
13
|
+
|
|
14
|
+
PERFORMANCE_SAMPLE_RATE = 40 # 4% (40 out of 1000)
|
|
15
|
+
RESULT_OK = "OK"
|
|
16
|
+
RESULT_ERROR = "ERROR"
|
|
17
|
+
RESULT_SUCCESS = "SUCCESS"
|
|
18
|
+
|
|
19
|
+
DEFAULT_NAMESERVERS = [
|
|
20
|
+
"ns1.domainnameapi.com",
|
|
21
|
+
"ns2.domainnameapi.com",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
# Error messages that are expected, high-volume noise and are never reported.
|
|
25
|
+
DEFAULT_IGNORED_ERRORS = [
|
|
26
|
+
"Domain not found",
|
|
27
|
+
"ERR_DOMAIN_NOT_FOUND",
|
|
28
|
+
"Reseller not found",
|
|
29
|
+
"Domain is not in updateable status",
|
|
30
|
+
"balance is not sufficient",
|
|
31
|
+
"Price definition not found",
|
|
32
|
+
"TLD is not supported",
|
|
33
|
+
"Invalid API credentials",
|
|
34
|
+
"could not be found", # API_404 "The domain name you requested could not be found"
|
|
35
|
+
"Insufficent reseller balance", # (sic) matches the gateway's spelling
|
|
36
|
+
"Redirect information not found", # no forwarding configured: get_forwarding reports it as disabled
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
DEFAULT_ERRORS = {
|
|
40
|
+
"DOMAIN_DETAILS": {
|
|
41
|
+
"code": "DOMAIN_DETAILS",
|
|
42
|
+
"message": "Invalid domain details! Details format is not valid",
|
|
43
|
+
"description": "The provided domain details are not in the expected format",
|
|
44
|
+
},
|
|
45
|
+
"CREDENTIALS": {
|
|
46
|
+
"code": "CREDENTIALS",
|
|
47
|
+
"message": "Invalid reseller ID or API key",
|
|
48
|
+
"description": "The provided API credentials are invalid",
|
|
49
|
+
},
|
|
50
|
+
"DOMAIN_LIST": {
|
|
51
|
+
"code": "DOMAIN_LIST",
|
|
52
|
+
"message": "The domain list is invalid or contains multiple entries!",
|
|
53
|
+
"description": "The domain list response format is incorrect or unexpected",
|
|
54
|
+
},
|
|
55
|
+
"TLD_LIST": {
|
|
56
|
+
"code": "TLD_LIST",
|
|
57
|
+
"message": "TLD info is not a valid array or more than one TLD info has returned!",
|
|
58
|
+
"description": "The TLD list response is not in the expected format",
|
|
59
|
+
},
|
|
60
|
+
"RESPONSE": {
|
|
61
|
+
"code": "RESPONSE",
|
|
62
|
+
"message": "Invalid response received from server! Response is empty.",
|
|
63
|
+
"description": "The API response is empty or null",
|
|
64
|
+
},
|
|
65
|
+
"RESPONSE_FORMAT": {
|
|
66
|
+
"code": "RESPONSE_FORMAT",
|
|
67
|
+
"message": "Invalid response received from server! Response format is not valid.",
|
|
68
|
+
"description": "The API response format is not in the expected structure",
|
|
69
|
+
},
|
|
70
|
+
"RESPONSE_COUNT": {
|
|
71
|
+
"code": "RESPONSE_COUNT",
|
|
72
|
+
"message": "Invalid parameters passed to function! Response data contains more than one result!",
|
|
73
|
+
"description": "The API response contains multiple results when only one was expected",
|
|
74
|
+
},
|
|
75
|
+
"RESPONSE_CODE": {
|
|
76
|
+
"code": "RESPONSE_CODE",
|
|
77
|
+
"message": "Invalid parameters passed to function! Operation result or Error code not received from server",
|
|
78
|
+
"description": "The API response is missing required operation result or error code fields",
|
|
79
|
+
},
|
|
80
|
+
"RESPONSE_REST": {
|
|
81
|
+
"code": "RESPONSE_REST",
|
|
82
|
+
"message": "Invalid parameters passed to function! REST return is not a valid array!",
|
|
83
|
+
"description": "The REST response is not in a valid array format",
|
|
84
|
+
},
|
|
85
|
+
"CONTACT_INFO": {
|
|
86
|
+
"code": "CONTACT_INFO",
|
|
87
|
+
"message": "Invalid response received from server! Contact info is not a valid array or more than one contact info has returned!",
|
|
88
|
+
"description": "The contact information response is not in the expected format",
|
|
89
|
+
},
|
|
90
|
+
"CONTACT_SAVE": {
|
|
91
|
+
"code": "CONTACT_SAVE",
|
|
92
|
+
"message": "Invalid response received from server! Contact info could not be saved!",
|
|
93
|
+
"description": "The contact information could not be saved on the server",
|
|
94
|
+
},
|
|
95
|
+
"DOMAIN_TRANSFER_REQUEST": {
|
|
96
|
+
"code": "DOMAIN_TRANSFER_REQUEST",
|
|
97
|
+
"message": "Invalid response received from server! Domain transfer request could not be completed!",
|
|
98
|
+
"description": "The domain transfer request failed to complete",
|
|
99
|
+
},
|
|
100
|
+
"DOMAIN_RENEW": {
|
|
101
|
+
"code": "DOMAIN_RENEW",
|
|
102
|
+
"message": "Invalid response received from server! Domain renew request could not be completed!",
|
|
103
|
+
"description": "The domain renewal request failed to complete",
|
|
104
|
+
},
|
|
105
|
+
"DOMAIN_REGISTER": {
|
|
106
|
+
"code": "DOMAIN_REGISTER",
|
|
107
|
+
"message": "Invalid response received from server! Domain register request could not be completed!",
|
|
108
|
+
"description": "The domain registration request failed to complete",
|
|
109
|
+
},
|
|
110
|
+
"DOMAIN_SYNC": {
|
|
111
|
+
"code": "DOMAIN_SYNC",
|
|
112
|
+
"message": "Invalid response received from server! Domain sync request could not be completed!",
|
|
113
|
+
"description": "The domain synchronization request failed to complete",
|
|
114
|
+
},
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
DEFAULT_CACHE_TTL = 512
|
|
118
|
+
DEFAULT_TIMEOUT = 30
|
|
119
|
+
DEFAULT_CONNECT_TIMEOUT = 10
|
|
120
|
+
DEFAULT_REASON = "Owner request"
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class ApiError(Exception):
|
|
124
|
+
"""Internal failure of one gateway call.
|
|
125
|
+
|
|
126
|
+
Mirrors the ``Exception($message, $code)`` php-dna throws inside
|
|
127
|
+
``request()``: ``code`` is the HTTP status, or a curl-style error number
|
|
128
|
+
for transport failures. Every public method catches it and returns the
|
|
129
|
+
error envelope, so it never reaches the caller.
|
|
130
|
+
"""
|
|
131
|
+
|
|
132
|
+
def __init__(self, message, code=0):
|
|
133
|
+
super().__init__(message)
|
|
134
|
+
self.message = message
|
|
135
|
+
self.code = code
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def set_error(code, message="", details="", gateway_code=""):
|
|
139
|
+
"""Build the ``{'Code', 'Message', 'Details'}`` error dict.
|
|
140
|
+
|
|
141
|
+
For a known code the caller's message/details win and the predefined text
|
|
142
|
+
is only the fallback when they are blank. An ``API_<status>`` error also
|
|
143
|
+
carries the gateway's own code as ``GatewayCode`` when there is one.
|
|
144
|
+
"""
|
|
145
|
+
known = DEFAULT_ERRORS.get(code) if isinstance(code, str) else None
|
|
146
|
+
if known is not None:
|
|
147
|
+
result = {
|
|
148
|
+
"Code": known["code"],
|
|
149
|
+
"Message": message if php.trim(message) != "" else known["message"],
|
|
150
|
+
"Details": details if php.trim(details) != "" else known["description"],
|
|
151
|
+
}
|
|
152
|
+
else:
|
|
153
|
+
result = {"Code": code, "Message": message, "Details": details}
|
|
154
|
+
if gateway_code and isinstance(result["Code"], str) and re.match(r"^API_\d+$", result["Code"]):
|
|
155
|
+
result["GatewayCode"] = gateway_code
|
|
156
|
+
return result
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def format_error_code(code, transport_failed=False):
|
|
160
|
+
"""HTTP statuses become ``API_<status>``, transport errors ``CURL_<errno>``."""
|
|
161
|
+
if php.is_numeric(code):
|
|
162
|
+
return ("CURL_" if transport_failed else "API_") + php.strval(code)
|
|
163
|
+
return php.strval(code)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def normalize_host_name(host_name):
|
|
167
|
+
"""Trim, drop the FQDN trailing dot and lowercase a host name."""
|
|
168
|
+
return php.strtolower(php.rtrim(php.trim(host_name), "."))
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def normalize_host_name_list(host_names):
|
|
172
|
+
"""Normalise each entry, drop empties, non-strings and duplicates; keep order."""
|
|
173
|
+
if not isinstance(host_names, (list, dict, tuple)):
|
|
174
|
+
return []
|
|
175
|
+
normalized = []
|
|
176
|
+
for host_name in php.values(host_names):
|
|
177
|
+
if not isinstance(host_name, str):
|
|
178
|
+
continue
|
|
179
|
+
host_name = normalize_host_name(host_name)
|
|
180
|
+
if host_name != "" and host_name not in normalized:
|
|
181
|
+
normalized.append(host_name)
|
|
182
|
+
return normalized
|
|
@@ -0,0 +1,471 @@
|
|
|
1
|
+
"""Error and performance telemetry (Sentry store endpoint).
|
|
2
|
+
|
|
3
|
+
Port of the telemetry half of php-dna ``SharedApiConfigAndUtilsTrait``:
|
|
4
|
+
|
|
5
|
+
* On by default; ``set_error_reporting_enabled(False)`` turns it off and
|
|
6
|
+
``set_telemetry_minimal(True)`` sends the minimal payload.
|
|
7
|
+
* Failed calls send an error event, a sample of successful calls (4 %, and
|
|
8
|
+
every call slower than one second) sends a performance transaction.
|
|
9
|
+
* Only credentials are masked (API key, passwords, auth/EPP codes, tokens).
|
|
10
|
+
* Events are posted from a daemon thread: never blocks, never raises into
|
|
11
|
+
the caller.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import getpass
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import platform
|
|
18
|
+
import random
|
|
19
|
+
import re
|
|
20
|
+
import ssl
|
|
21
|
+
import threading
|
|
22
|
+
import time
|
|
23
|
+
import traceback
|
|
24
|
+
import urllib.request
|
|
25
|
+
import uuid
|
|
26
|
+
from urllib.parse import urlsplit
|
|
27
|
+
|
|
28
|
+
from . import _php as php
|
|
29
|
+
from .shared import APPLICATION, DEFAULT_CACHE_TTL, DEFAULT_IGNORED_ERRORS, PERFORMANCE_SAMPLE_RATE
|
|
30
|
+
from ._version import VERSION
|
|
31
|
+
|
|
32
|
+
# Filled in once the Sentry project for python-dna exists; empty = telemetry is a no-op.
|
|
33
|
+
DEFAULT_SENTRY_DSN = ""
|
|
34
|
+
|
|
35
|
+
_MASK_RE = re.compile(r"auth|epp|password|passwd|secret|token|api[_-]?key", re.IGNORECASE)
|
|
36
|
+
|
|
37
|
+
_ip_cache = {"ip": None, "at": 0.0}
|
|
38
|
+
_ip_lock = threading.Lock()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def parse_dsn(dsn):
|
|
42
|
+
"""``https://<public>[:<secret>]@<host>/<project>`` -> parts, or ``None``."""
|
|
43
|
+
if not dsn or not isinstance(dsn, str):
|
|
44
|
+
return None
|
|
45
|
+
try:
|
|
46
|
+
parts = urlsplit(dsn.strip())
|
|
47
|
+
except ValueError:
|
|
48
|
+
return None
|
|
49
|
+
host = parts.hostname
|
|
50
|
+
project = (parts.path or "").lstrip("/")
|
|
51
|
+
public_key = parts.username
|
|
52
|
+
if not host or not project or not public_key:
|
|
53
|
+
return None
|
|
54
|
+
return {
|
|
55
|
+
"host": host,
|
|
56
|
+
"project_id": project,
|
|
57
|
+
"public_key": public_key,
|
|
58
|
+
"secret_key": parts.password or None,
|
|
59
|
+
"store_url": "https://%s/api/%s/store/" % (host, project),
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def telemetry_mask(data, depth=0):
|
|
64
|
+
"""Replace credential values (by key name) with ``***``; keep everything else."""
|
|
65
|
+
if depth > 8:
|
|
66
|
+
return "[…]"
|
|
67
|
+
if isinstance(data, dict):
|
|
68
|
+
out = {}
|
|
69
|
+
for key, value in data.items():
|
|
70
|
+
if _MASK_RE.search(php.strval(key)):
|
|
71
|
+
out[key] = value if (value == "" or value is None) else "***"
|
|
72
|
+
else:
|
|
73
|
+
out[key] = telemetry_mask(value, depth + 1)
|
|
74
|
+
return out
|
|
75
|
+
if isinstance(data, (list, tuple)):
|
|
76
|
+
out = []
|
|
77
|
+
for value in data:
|
|
78
|
+
out.append(telemetry_mask(value, depth + 1))
|
|
79
|
+
return out
|
|
80
|
+
return data
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def map_trace_status(success, api_code):
|
|
84
|
+
"""OTel-compatible span status from the outcome and ``API_<n>`` code."""
|
|
85
|
+
if success:
|
|
86
|
+
return "ok"
|
|
87
|
+
if api_code is None:
|
|
88
|
+
return "unknown"
|
|
89
|
+
m = re.search(r"-?\d+", php.strval(api_code))
|
|
90
|
+
if not m:
|
|
91
|
+
return "unknown"
|
|
92
|
+
code = int(m.group(0))
|
|
93
|
+
if code in (401, 2202):
|
|
94
|
+
return "unauthenticated"
|
|
95
|
+
if code == 403:
|
|
96
|
+
return "permission_denied"
|
|
97
|
+
if code in (404, 510):
|
|
98
|
+
return "not_found"
|
|
99
|
+
if code in (408, 504):
|
|
100
|
+
return "deadline_exceeded"
|
|
101
|
+
if code == -1:
|
|
102
|
+
return "unavailable"
|
|
103
|
+
if 500 <= code < 600:
|
|
104
|
+
return "internal_error"
|
|
105
|
+
if 400 <= code < 500:
|
|
106
|
+
return "invalid_argument"
|
|
107
|
+
return "unknown"
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _iso_utc(ts):
|
|
111
|
+
millis = int(round((ts % 1) * 1000))
|
|
112
|
+
if millis >= 1000:
|
|
113
|
+
millis = 999
|
|
114
|
+
return time.strftime("%Y-%m-%dT%H:%M:%S", time.gmtime(ts)) + ".%03dZ" % millis
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _server_ip():
|
|
118
|
+
"""Public IP of this host (cached for DEFAULT_CACHE_TTL seconds)."""
|
|
119
|
+
with _ip_lock:
|
|
120
|
+
if _ip_cache["ip"] and time.time() - _ip_cache["at"] < DEFAULT_CACHE_TTL:
|
|
121
|
+
return _ip_cache["ip"]
|
|
122
|
+
try:
|
|
123
|
+
req = urllib.request.Request("https://api.ipify.org", headers={"User-Agent": APPLICATION + "/" + VERSION})
|
|
124
|
+
with urllib.request.urlopen(req, timeout=2) as resp:
|
|
125
|
+
ip = resp.read(64).decode("ascii", "replace").strip()
|
|
126
|
+
if re.match(r"^[0-9a-fA-F:.]+$", ip):
|
|
127
|
+
with _ip_lock:
|
|
128
|
+
_ip_cache["ip"] = ip
|
|
129
|
+
_ip_cache["at"] = time.time()
|
|
130
|
+
return ip
|
|
131
|
+
except Exception: # noqa: BLE001 - telemetry never fails the caller
|
|
132
|
+
pass
|
|
133
|
+
return "unknown"
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _current_user():
|
|
137
|
+
try:
|
|
138
|
+
return getpass.getuser()
|
|
139
|
+
except Exception: # noqa: BLE001
|
|
140
|
+
return ""
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _post(url, payload, auth_header):
|
|
144
|
+
"""Blocking POST used inside the background thread (short timeouts)."""
|
|
145
|
+
req = urllib.request.Request(
|
|
146
|
+
url,
|
|
147
|
+
data=payload.encode("utf-8"),
|
|
148
|
+
method="POST",
|
|
149
|
+
headers={
|
|
150
|
+
"Content-Type": "application/json",
|
|
151
|
+
"X-Sentry-Auth": auth_header,
|
|
152
|
+
"User-Agent": APPLICATION + "/" + VERSION,
|
|
153
|
+
},
|
|
154
|
+
)
|
|
155
|
+
with urllib.request.urlopen(req, timeout=2) as resp:
|
|
156
|
+
resp.read(1024)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _encode(event):
|
|
160
|
+
try:
|
|
161
|
+
return json.dumps(event, default=php.strval, ensure_ascii=False)
|
|
162
|
+
except (TypeError, ValueError):
|
|
163
|
+
return ""
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
class Telemetry(object):
|
|
167
|
+
"""Telemetry for one client instance.
|
|
168
|
+
|
|
169
|
+
``client`` is the :class:`~domainnameapi.rest.DNARest` instance whose
|
|
170
|
+
``last_request``/``last_response``/... state describes the failing call.
|
|
171
|
+
"""
|
|
172
|
+
|
|
173
|
+
def __init__(self, client, dsn=None):
|
|
174
|
+
self.client = client
|
|
175
|
+
self.enabled = True
|
|
176
|
+
self.minimal = False
|
|
177
|
+
self.dsn = DEFAULT_SENTRY_DSN if dsn is None else dsn
|
|
178
|
+
self._session_trace_id = None
|
|
179
|
+
# Replaceable for tests: called with (url, json_payload, auth_header).
|
|
180
|
+
self.poster = _post
|
|
181
|
+
# Run synchronously instead of in a thread (tests).
|
|
182
|
+
self.synchronous = False
|
|
183
|
+
|
|
184
|
+
# -- shared tag helpers -------------------------------------------------
|
|
185
|
+
|
|
186
|
+
def session_trace_id(self):
|
|
187
|
+
if self._session_trace_id is None:
|
|
188
|
+
self._session_trace_id = uuid.uuid4().hex
|
|
189
|
+
return self._session_trace_id
|
|
190
|
+
|
|
191
|
+
def environment(self):
|
|
192
|
+
url = self.client.get_service_url()
|
|
193
|
+
if isinstance(url, str) and "ote." in url.lower():
|
|
194
|
+
return "ote"
|
|
195
|
+
return "production"
|
|
196
|
+
|
|
197
|
+
def common_tags(self, error=None):
|
|
198
|
+
c = self.client
|
|
199
|
+
tags = {}
|
|
200
|
+
if not php.empty(c.reseller_id):
|
|
201
|
+
tags["reseller"] = php.strval(c.reseller_id)
|
|
202
|
+
domain = php.get(c.last_request, "payload", "domainName")
|
|
203
|
+
if not php.empty(domain) and isinstance(domain, str) and not self.minimal:
|
|
204
|
+
tags["domain"] = domain
|
|
205
|
+
if not php.empty(c.last_function):
|
|
206
|
+
tags["operation"] = php.strval(c.last_function)
|
|
207
|
+
tags["api_type"] = "REST"
|
|
208
|
+
if error is not None:
|
|
209
|
+
msg = php.strval(getattr(error, "message", str(error)))
|
|
210
|
+
code = getattr(error, "code", 0)
|
|
211
|
+
m = re.search(r"API_(-?\d+)_ERROR", msg)
|
|
212
|
+
if m:
|
|
213
|
+
tags["api_code"] = "API_" + m.group(1)
|
|
214
|
+
elif php.is_numeric(code) and php.intval(code) != 0:
|
|
215
|
+
tags["api_code"] = "API_%d" % php.intval(code)
|
|
216
|
+
if php.is_numeric(code) and php.intval(code) != 0:
|
|
217
|
+
tags["status_code"] = str(php.intval(code))
|
|
218
|
+
tags["library_version"] = VERSION
|
|
219
|
+
return tags
|
|
220
|
+
|
|
221
|
+
def classification(self, error):
|
|
222
|
+
msg = php.strval(getattr(error, "message", str(error)))
|
|
223
|
+
tags = self.common_tags(error)
|
|
224
|
+
m = re.search(r"\b(\d{1,3}(?:\.\d{1,3}){3})\b", msg)
|
|
225
|
+
if m:
|
|
226
|
+
tags["ip_value"] = m.group(1)
|
|
227
|
+
api_code = tags.get("api_code")
|
|
228
|
+
n = msg
|
|
229
|
+
n = re.sub(r"^\[API_ERROR\]:\s*", "", n, flags=re.IGNORECASE)
|
|
230
|
+
n = re.sub(r"API_-?\d+_ERROR\s*-\s*Failed\s*-\s*", "", n, count=1, flags=re.IGNORECASE)
|
|
231
|
+
n = re.sub(r"\s*\(Reseller Id[^)]*\)\s*", "", n, flags=re.IGNORECASE)
|
|
232
|
+
n = re.sub(r"\b\d{1,3}(?:\.\d{1,3}){3}\b", "{ip}", n)
|
|
233
|
+
n = re.sub(r"'[^']{0,80}'|\"[^\"]{0,80}\"", "{v}", n)
|
|
234
|
+
n = re.sub(r"\b\d{2,}\b", "{n}", n)
|
|
235
|
+
n = re.sub(r"\s+", " ", n).strip()
|
|
236
|
+
if n == "":
|
|
237
|
+
n = api_code if api_code else error.__class__.__name__
|
|
238
|
+
elif api_code and not n.startswith(api_code):
|
|
239
|
+
n = api_code + ": " + n
|
|
240
|
+
if len(n) > 160:
|
|
241
|
+
n = n[:157] + "..."
|
|
242
|
+
return {
|
|
243
|
+
"value": n,
|
|
244
|
+
"tags": tags,
|
|
245
|
+
"fingerprint": ["dna", APPLICATION, tags.get("operation", "unknown"), n],
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
# -- dispatch -------------------------------------------------------------
|
|
249
|
+
|
|
250
|
+
def _dispatch(self, event):
|
|
251
|
+
"""Post an event off the caller's thread. Never raises.
|
|
252
|
+
|
|
253
|
+
The event is built synchronously from the client state of the failing
|
|
254
|
+
call; only the public-IP lookup and the POST run in the thread.
|
|
255
|
+
"""
|
|
256
|
+
|
|
257
|
+
def run():
|
|
258
|
+
try:
|
|
259
|
+
parsed = parse_dsn(self.dsn)
|
|
260
|
+
if parsed is None:
|
|
261
|
+
return
|
|
262
|
+
if not self.minimal and event["tags"].get("ip_address") is None:
|
|
263
|
+
event["tags"]["ip_address"] = _server_ip()
|
|
264
|
+
payload = _encode(event)
|
|
265
|
+
if payload == "":
|
|
266
|
+
return
|
|
267
|
+
auth = [
|
|
268
|
+
"sentry_version=7",
|
|
269
|
+
"sentry_client=%s/%s" % (APPLICATION, VERSION),
|
|
270
|
+
"sentry_key=" + parsed["public_key"],
|
|
271
|
+
]
|
|
272
|
+
if parsed["secret_key"]:
|
|
273
|
+
auth.append("sentry_secret=" + parsed["secret_key"])
|
|
274
|
+
self.poster(parsed["store_url"], payload, "Sentry " + ", ".join(auth))
|
|
275
|
+
except Exception: # noqa: BLE001 - telemetry must never surface
|
|
276
|
+
pass
|
|
277
|
+
|
|
278
|
+
try:
|
|
279
|
+
if self.synchronous:
|
|
280
|
+
run()
|
|
281
|
+
else:
|
|
282
|
+
t = threading.Thread(target=run, name="domainnameapi-telemetry")
|
|
283
|
+
t.daemon = True
|
|
284
|
+
t.start()
|
|
285
|
+
except Exception: # noqa: BLE001
|
|
286
|
+
pass
|
|
287
|
+
|
|
288
|
+
def send_error(self, error):
|
|
289
|
+
"""Report a failed call (fire and forget)."""
|
|
290
|
+
try:
|
|
291
|
+
if not self.enabled or parse_dsn(self.dsn) is None:
|
|
292
|
+
return
|
|
293
|
+
message = php.strval(getattr(error, "message", str(error)))
|
|
294
|
+
for ignored in DEFAULT_IGNORED_ERRORS:
|
|
295
|
+
if ignored in message:
|
|
296
|
+
return
|
|
297
|
+
self._dispatch(self._error_event(error, message, self._snapshot()))
|
|
298
|
+
except Exception: # noqa: BLE001
|
|
299
|
+
pass
|
|
300
|
+
|
|
301
|
+
def send_performance(self, duration_ms, start_ts, end_ts):
|
|
302
|
+
"""Report a sampled successful call (fire and forget)."""
|
|
303
|
+
try:
|
|
304
|
+
if not self.enabled or parse_dsn(self.dsn) is None:
|
|
305
|
+
return
|
|
306
|
+
self._dispatch(self._performance_event(duration_ms, start_ts, end_ts, self._snapshot()))
|
|
307
|
+
except Exception: # noqa: BLE001
|
|
308
|
+
pass
|
|
309
|
+
|
|
310
|
+
def should_sample(self, duration_ms):
|
|
311
|
+
return duration_ms > 1000 or random.randint(1, 1000) <= PERFORMANCE_SAMPLE_RATE
|
|
312
|
+
|
|
313
|
+
def _snapshot(self):
|
|
314
|
+
"""Copy the client state now; the thread runs after the caller moved on."""
|
|
315
|
+
c = self.client
|
|
316
|
+
return {
|
|
317
|
+
"last_request": c.last_request,
|
|
318
|
+
"last_response": c.last_response,
|
|
319
|
+
"last_response_headers": c.last_response_headers,
|
|
320
|
+
"last_parsed_response": c.last_parsed_response,
|
|
321
|
+
"last_function": c.last_function,
|
|
322
|
+
"started_at": c.started_at,
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
# -- event builders ------------------------------------------------------
|
|
326
|
+
|
|
327
|
+
def _frames(self, error):
|
|
328
|
+
frames = []
|
|
329
|
+
tb = getattr(error, "__traceback__", None)
|
|
330
|
+
stack = traceback.extract_tb(tb) if tb is not None else traceback.extract_stack()[:-3]
|
|
331
|
+
for fr in stack:
|
|
332
|
+
frames.append({
|
|
333
|
+
"filename": os.path.basename(fr.filename),
|
|
334
|
+
"lineno": fr.lineno or 0,
|
|
335
|
+
"function": fr.name or "",
|
|
336
|
+
})
|
|
337
|
+
return frames
|
|
338
|
+
|
|
339
|
+
def _error_event(self, error, message, snap):
|
|
340
|
+
c = self.client
|
|
341
|
+
classification = self.classification(error)
|
|
342
|
+
elapsed = time.time() - (snap["started_at"] or time.time())
|
|
343
|
+
tags = {
|
|
344
|
+
"handled": "yes",
|
|
345
|
+
"level": "error",
|
|
346
|
+
"url": "NA",
|
|
347
|
+
"transaction": "NA",
|
|
348
|
+
"trace_id": uuid.uuid4().hex[:16],
|
|
349
|
+
"runtime_name": "Python",
|
|
350
|
+
"runtime_version": platform.python_version(),
|
|
351
|
+
"elapsed_time": php.number_format(elapsed, 4),
|
|
352
|
+
"application": APPLICATION,
|
|
353
|
+
"ip_address": "" if self.minimal else None, # looked up in the background
|
|
354
|
+
"vhost_user": "" if self.minimal else _current_user(),
|
|
355
|
+
}
|
|
356
|
+
tags.update(classification["tags"])
|
|
357
|
+
event = {
|
|
358
|
+
"event_id": uuid.uuid4().hex,
|
|
359
|
+
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
|
360
|
+
"level": "error",
|
|
361
|
+
"logger": "python",
|
|
362
|
+
"platform": "python",
|
|
363
|
+
"culprit": "domainnameapi.DNARest via rest.py",
|
|
364
|
+
"message": {"formatted": classification["value"]},
|
|
365
|
+
"exception": {
|
|
366
|
+
"values": [{
|
|
367
|
+
"type": APPLICATION + " Exception",
|
|
368
|
+
"value": classification["value"],
|
|
369
|
+
"stacktrace": {"frames": self._frames(error)},
|
|
370
|
+
}]
|
|
371
|
+
},
|
|
372
|
+
"tags": tags,
|
|
373
|
+
"extra": {
|
|
374
|
+
"request_data": telemetry_mask(snap["last_request"]),
|
|
375
|
+
"response_data": telemetry_mask(snap["last_response"]),
|
|
376
|
+
"openssl_v": getattr(ssl, "OPENSSL_VERSION", "NA"),
|
|
377
|
+
"openssl_n": getattr(ssl, "OPENSSL_VERSION_NUMBER", "NA"),
|
|
378
|
+
"original_message": message,
|
|
379
|
+
},
|
|
380
|
+
"release": VERSION,
|
|
381
|
+
"environment": self.environment(),
|
|
382
|
+
"fingerprint": classification["fingerprint"],
|
|
383
|
+
}
|
|
384
|
+
if self.minimal:
|
|
385
|
+
for key in ("url", "ip_address", "vhost_user", "ip_value"):
|
|
386
|
+
event["tags"].pop(key, None)
|
|
387
|
+
lr = snap["last_request"] if isinstance(snap["last_request"], dict) else {}
|
|
388
|
+
event["extra"]["request_data"] = {
|
|
389
|
+
"method": php.strval(lr.get("method", "")),
|
|
390
|
+
"endpoint": urlsplit(php.strval(lr.get("url", ""))).path or "",
|
|
391
|
+
}
|
|
392
|
+
event["extra"]["response_data"] = {
|
|
393
|
+
"http_code": php.intval(php.get(snap["last_response_headers"], "http_code")),
|
|
394
|
+
}
|
|
395
|
+
return event
|
|
396
|
+
|
|
397
|
+
def _performance_event(self, duration_ms, start_ts, end_ts, snap):
|
|
398
|
+
c = self.client
|
|
399
|
+
trace_id = self.session_trace_id()
|
|
400
|
+
root_span = uuid.uuid4().hex[:16]
|
|
401
|
+
child_span = uuid.uuid4().hex[:16]
|
|
402
|
+
op = "http.client"
|
|
403
|
+
|
|
404
|
+
api_code = None
|
|
405
|
+
code = php.get(snap["last_parsed_response"], "Code")
|
|
406
|
+
if isinstance(code, str):
|
|
407
|
+
api_code = code
|
|
408
|
+
else:
|
|
409
|
+
code = php.get(snap["last_response"], "error", "code")
|
|
410
|
+
if isinstance(code, str):
|
|
411
|
+
api_code = code
|
|
412
|
+
status = map_trace_status(True, api_code)
|
|
413
|
+
|
|
414
|
+
tags = self.common_tags()
|
|
415
|
+
if api_code is not None and "api_code" not in tags:
|
|
416
|
+
tags["api_code"] = api_code
|
|
417
|
+
http_code = php.intval(php.get(snap["last_response_headers"], "http_code"))
|
|
418
|
+
if http_code != 0:
|
|
419
|
+
tags["status_code"] = str(http_code)
|
|
420
|
+
|
|
421
|
+
lr = snap["last_request"] if isinstance(snap["last_request"], dict) else {}
|
|
422
|
+
method = php.strval(lr.get("method")) or "GET"
|
|
423
|
+
call_url = php.strval(lr.get("url"))
|
|
424
|
+
call_host = urlsplit(call_url).hostname if call_url else urlsplit(php.strval(c.get_service_url())).hostname
|
|
425
|
+
call_host = call_host or ""
|
|
426
|
+
span_data = {"http.request.method": method}
|
|
427
|
+
if call_host:
|
|
428
|
+
span_data["server.address"] = call_host
|
|
429
|
+
if call_url and not self.minimal:
|
|
430
|
+
span_data["url.full"] = call_url.split("?", 1)[0]
|
|
431
|
+
if "status_code" in tags:
|
|
432
|
+
span_data["http.response.status_code"] = int(tags["status_code"])
|
|
433
|
+
description = ("%s https://%s" % (method, call_host)) if call_host else php.strval(snap["last_function"])
|
|
434
|
+
|
|
435
|
+
base_tags = {
|
|
436
|
+
"application": APPLICATION,
|
|
437
|
+
"ip_address": "" if self.minimal else None, # looked up in the background
|
|
438
|
+
"vhost_user": "" if self.minimal else _current_user(),
|
|
439
|
+
}
|
|
440
|
+
base_tags.update(tags)
|
|
441
|
+
return {
|
|
442
|
+
"event_id": uuid.uuid4().hex,
|
|
443
|
+
"platform": "python",
|
|
444
|
+
"level": "info",
|
|
445
|
+
"type": "transaction",
|
|
446
|
+
"transaction": "API." + php.strval(snap["last_function"]),
|
|
447
|
+
"transaction_info": {"source": "custom"},
|
|
448
|
+
"release": VERSION,
|
|
449
|
+
"environment": self.environment(),
|
|
450
|
+
"timestamp": _iso_utc(end_ts),
|
|
451
|
+
"start_timestamp": _iso_utc(start_ts),
|
|
452
|
+
"contexts": {
|
|
453
|
+
"trace": {"trace_id": trace_id, "span_id": root_span, "op": op, "status": status},
|
|
454
|
+
"runtime": {"name": "python", "version": platform.python_version()},
|
|
455
|
+
},
|
|
456
|
+
"tags": base_tags,
|
|
457
|
+
"measurements": {
|
|
458
|
+
"duration": {"value": float(duration_ms), "unit": "millisecond"},
|
|
459
|
+
},
|
|
460
|
+
"spans": [{
|
|
461
|
+
"span_id": child_span,
|
|
462
|
+
"parent_span_id": root_span,
|
|
463
|
+
"trace_id": trace_id,
|
|
464
|
+
"op": op,
|
|
465
|
+
"description": description,
|
|
466
|
+
"status": status,
|
|
467
|
+
"data": span_data,
|
|
468
|
+
"start_timestamp": _iso_utc(start_ts),
|
|
469
|
+
"timestamp": _iso_utc(end_ts),
|
|
470
|
+
}],
|
|
471
|
+
}
|