diagnostic_reporter.py 6.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218
  1. import hashlib
  2. import hmac
  3. import json
  4. import logging
  5. import queue
  6. import threading
  7. import time
  8. import urllib.request
  9. from urllib.parse import urlsplit
  10. import uuid
  11. logger = logging.getLogger(__name__)
  12. def diagnostic_reporter_from_config(config, **overrides):
  13. if getattr(config, 'diagnosis_enabled', False) is not True:
  14. return NullDiagnosticReporter()
  15. url = str(getattr(config, 'diagnosis_url', '') or '').strip()
  16. key_id = str(getattr(config, 'diagnosis_key_id', '') or '').strip()
  17. secret = str(getattr(config, 'diagnosis_secret', '') or '')
  18. allow_insecure_http = getattr(
  19. config, 'diagnosis_allow_insecure_http', False
  20. ) is True
  21. parsed_url = urlsplit(url)
  22. if (
  23. (
  24. parsed_url.scheme != 'https'
  25. and not (parsed_url.scheme == 'http' and allow_insecure_http)
  26. )
  27. or not parsed_url.netloc
  28. or not key_id
  29. or len(secret) < 32
  30. ):
  31. logger.warning('MCP diagnostic reporter configuration is invalid')
  32. return NullDiagnosticReporter()
  33. options = {
  34. 'url': url,
  35. 'key_id': key_id,
  36. 'secret': secret,
  37. 'queue_size': getattr(config, 'diagnosis_queue_size', 1000),
  38. 'batch_size': getattr(config, 'diagnosis_batch_size', 100),
  39. 'timeout_seconds': getattr(config, 'diagnosis_timeout_seconds', 0.5),
  40. 'initial_backoff': getattr(
  41. config, 'diagnosis_initial_backoff_seconds', 0.25
  42. ),
  43. 'max_backoff': getattr(
  44. config, 'diagnosis_max_backoff_seconds', 5.0
  45. ),
  46. }
  47. options.update(overrides)
  48. reporter = DiagnosticReporter(**options)
  49. if reporter.start():
  50. return reporter
  51. reporter.close()
  52. return NullDiagnosticReporter()
  53. def _http_transport(url, body, headers, timeout):
  54. request = urllib.request.Request(url, data=body, headers=headers, method='POST')
  55. with urllib.request.urlopen(request, timeout=timeout) as response:
  56. return response.getcode(), response.read()
  57. class NullDiagnosticReporter:
  58. drop_count = 0
  59. pending_count = 0
  60. def start(self):
  61. return True
  62. def report(self, _event):
  63. return True
  64. def close(self):
  65. return None
  66. class DiagnosticReporter:
  67. def __init__(
  68. self,
  69. url,
  70. key_id,
  71. secret,
  72. queue_size=1000,
  73. batch_size=100,
  74. timeout_seconds=0.5,
  75. initial_backoff=0.25,
  76. max_backoff=5.0,
  77. transport=None,
  78. clock=None,
  79. nonce_factory=None,
  80. sleeper=None,
  81. thread_factory=None,
  82. ):
  83. self.url = str(url)
  84. self.key_id = str(key_id)
  85. self.secret = str(secret)
  86. self.batch_size = max(1, min(100, int(batch_size)))
  87. self.timeout_seconds = float(timeout_seconds)
  88. self.initial_backoff = max(0.0, float(initial_backoff))
  89. self.max_backoff = max(self.initial_backoff, float(max_backoff))
  90. self.transport = transport or _http_transport
  91. self.clock = clock or time.time
  92. self.nonce_factory = nonce_factory or (lambda: uuid.uuid4().hex)
  93. self.sleeper = sleeper or time.sleep
  94. self.thread_factory = thread_factory or threading.Thread
  95. self._queue = queue.Queue(maxsize=max(1, int(queue_size)))
  96. self._pending = []
  97. self._backoff = self.initial_backoff
  98. self._stop = threading.Event()
  99. self._thread = None
  100. self.drop_count = 0
  101. @property
  102. def pending_count(self):
  103. return len(self._pending)
  104. def report(self, event):
  105. try:
  106. self._queue.put_nowait(event)
  107. return True
  108. except queue.Full:
  109. self.drop_count += 1
  110. return False
  111. def start(self):
  112. if self._thread is not None:
  113. return True
  114. try:
  115. thread = self.thread_factory(target=self._run, daemon=True)
  116. thread.start()
  117. self._thread = thread
  118. return True
  119. except Exception:
  120. logger.warning('MCP diagnostic reporter thread unavailable')
  121. return False
  122. def close(self):
  123. self._stop.set()
  124. thread = self._thread
  125. if thread is not None and thread.is_alive():
  126. thread.join(timeout=self.timeout_seconds)
  127. def process_once(self):
  128. if not self._pending:
  129. self._pending = self._take_batch()
  130. if not self._pending:
  131. return True
  132. body = json.dumps(
  133. {'events': self._pending},
  134. ensure_ascii=False,
  135. separators=(',', ':'),
  136. ).encode('utf-8')
  137. headers = self._signed_headers(body)
  138. try:
  139. status, response_body = self.transport(
  140. self.url,
  141. body,
  142. headers,
  143. self.timeout_seconds,
  144. )
  145. payload = json.loads(response_body.decode('utf-8'))
  146. if status < 200 or status >= 300 or payload.get('code') != 'MCP_DIAG_INGEST_0000':
  147. raise ValueError('collector rejected batch')
  148. except Exception:
  149. delay = self._backoff
  150. self._backoff = min(
  151. self.max_backoff,
  152. max(self.initial_backoff, self._backoff * 2),
  153. )
  154. self.sleeper(delay)
  155. return False
  156. self._pending = []
  157. self._backoff = self.initial_backoff
  158. return True
  159. def _take_batch(self):
  160. events = []
  161. while len(events) < self.batch_size:
  162. try:
  163. events.append(self._queue.get_nowait())
  164. except queue.Empty:
  165. break
  166. return events
  167. def _signed_headers(self, body):
  168. timestamp = str(int(self.clock()))
  169. nonce = str(self.nonce_factory())
  170. path = urlsplit(self.url).path or '/'
  171. canonical = 'POST\n{0}\n{1}\n{2}\n{3}'.format(
  172. path,
  173. timestamp,
  174. nonce,
  175. hashlib.sha256(body).hexdigest(),
  176. )
  177. signature = hmac.new(
  178. self.secret.encode('utf-8'),
  179. canonical.encode('utf-8'),
  180. hashlib.sha256,
  181. ).hexdigest()
  182. return {
  183. 'Content-Type': 'application/json',
  184. 'X-MCP-Source': 'gateway',
  185. 'X-MCP-Timestamp': timestamp,
  186. 'X-MCP-Nonce': nonce,
  187. 'X-MCP-Key-Id': self.key_id,
  188. 'X-MCP-Signature': signature,
  189. }
  190. def _run(self):
  191. while not self._stop.is_set():
  192. if not self.process_once():
  193. continue
  194. self._stop.wait(0.1)