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.
@@ -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
+ }