diagnostic_event.py 6.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207
  1. import hashlib
  2. import re
  3. import uuid
  4. from datetime import datetime, timezone
  5. _STAGE_CONTEXT_FIELDS = {
  6. 'request_ingress': frozenset(('jsonrpc_method', 'http_status', 'transport')),
  7. 'protocol_validation': frozenset(('jsonrpc_method', 'jsonrpc_code', 'transport')),
  8. 'gateway_session': frozenset(('transport',)),
  9. 'rate_limit': frozenset(('limit_type', 'transport')),
  10. 'backend_call': frozenset(('http_status', 'transport')),
  11. 'response_safety': frozenset(('transport',)),
  12. 'response_write': frozenset(('http_status', 'client_disconnected', 'transport')),
  13. }
  14. _STATUSES = frozenset(('started', 'succeeded', 'failed', 'skipped'))
  15. _CODE_PATTERN = re.compile(r'^[A-Z][A-Z0-9_]*$')
  16. def _utc_timestamp():
  17. return datetime.now(timezone.utc).isoformat(timespec='milliseconds').replace(
  18. '+00:00', 'Z'
  19. )
  20. def _optional_positive_integer(event, name, value):
  21. if value is None:
  22. return
  23. if isinstance(value, bool) or not isinstance(value, int) or value <= 0:
  24. raise ValueError('{0} must be a positive integer'.format(name))
  25. event[name] = value
  26. def _optional_code(event, name, value, max_length):
  27. if value is None or value == '':
  28. return
  29. if (
  30. not isinstance(value, str)
  31. or len(value) > max_length
  32. or _CODE_PATTERN.fullmatch(value) is None
  33. ):
  34. raise ValueError('{0} is invalid'.format(name))
  35. event[name] = value
  36. def _validated_context(stage, context):
  37. context = {} if context is None else context
  38. if not isinstance(context, dict):
  39. raise ValueError('context must be an object')
  40. unknown = set(context) - _STAGE_CONTEXT_FIELDS[stage]
  41. if unknown:
  42. raise ValueError('context contains unsupported fields')
  43. result = {}
  44. for key, value in context.items():
  45. if key == 'http_status':
  46. if isinstance(value, bool) or not isinstance(value, int) or not 100 <= value <= 599:
  47. raise ValueError('http_status is invalid')
  48. elif key == 'jsonrpc_code':
  49. if isinstance(value, bool) or not isinstance(value, int):
  50. raise ValueError('jsonrpc_code is invalid')
  51. elif key == 'client_disconnected':
  52. if not isinstance(value, bool):
  53. raise ValueError('client_disconnected is invalid')
  54. elif not isinstance(value, str) or not value or len(value) > 64:
  55. raise ValueError('{0} is invalid'.format(key))
  56. result[key] = value
  57. return result
  58. def build_diagnostic_event(
  59. request_id,
  60. stage,
  61. status,
  62. event_code,
  63. occurred_at=None,
  64. session_credential='',
  65. company_id=None,
  66. admin_id=None,
  67. tool_code=None,
  68. response_code=None,
  69. cost_ms=None,
  70. summary_code=None,
  71. context=None,
  72. ):
  73. if not isinstance(request_id, str) or not re.fullmatch(
  74. r'rq_[A-Za-z0-9._:-]{1,97}', request_id
  75. ):
  76. raise ValueError('request_id is invalid')
  77. if stage not in _STAGE_CONTEXT_FIELDS:
  78. raise ValueError('stage is invalid')
  79. if status not in _STATUSES:
  80. raise ValueError('status is invalid')
  81. if (
  82. not isinstance(event_code, str)
  83. or len(event_code) > 64
  84. or _CODE_PATTERN.fullmatch(event_code) is None
  85. ):
  86. raise ValueError('event_code is invalid')
  87. event = {
  88. 'schema_version': 1,
  89. 'event_id': 'evt_gateway_{0}'.format(uuid.uuid4().hex),
  90. 'request_id': request_id,
  91. 'source': 'gateway',
  92. 'stage': stage,
  93. 'status': status,
  94. 'event_code': event_code,
  95. 'occurred_at': occurred_at or _utc_timestamp(),
  96. }
  97. _optional_positive_integer(event, 'company_id', company_id)
  98. _optional_positive_integer(event, 'admin_id', admin_id)
  99. if tool_code is not None and tool_code != '':
  100. if not isinstance(tool_code, str) or re.fullmatch(r'[a-z][a-z0-9_]{0,63}', tool_code) is None:
  101. raise ValueError('tool_code is invalid')
  102. event['tool_code'] = tool_code
  103. if session_credential:
  104. if not isinstance(session_credential, str):
  105. raise ValueError('session_credential must be text')
  106. event['session_hash'] = hashlib.sha256(
  107. session_credential.encode('utf-8')
  108. ).hexdigest()[:12]
  109. _optional_code(event, 'response_code', response_code, 32)
  110. _optional_code(event, 'summary_code', summary_code, 64)
  111. if cost_ms is not None:
  112. if (
  113. isinstance(cost_ms, bool)
  114. or not isinstance(cost_ms, int)
  115. or not 0 <= cost_ms <= 3600000
  116. ):
  117. raise ValueError('cost_ms is invalid')
  118. event['cost_ms'] = cost_ms
  119. validated_context = _validated_context(stage, context)
  120. if validated_context:
  121. event['context'] = validated_context
  122. return event
  123. class RequestDiagnosticEmitter:
  124. MAX_EVENTS = 20
  125. def __init__(
  126. self,
  127. reporter,
  128. request_id,
  129. defer_until_identity=False,
  130. **event_defaults
  131. ):
  132. self.reporter = reporter
  133. self.request_id = request_id
  134. self.event_defaults = event_defaults
  135. self.defer_until_identity = bool(defer_until_identity)
  136. self._deferred = []
  137. self.emitted_count = 0
  138. self.dropped_count = 0
  139. def set_defaults(self, **event_defaults):
  140. self.event_defaults.update(event_defaults)
  141. company_id = self.event_defaults.get('company_id')
  142. if (
  143. self.defer_until_identity
  144. and isinstance(company_id, int)
  145. and not isinstance(company_id, bool)
  146. and company_id > 0
  147. ):
  148. self.flush()
  149. def emit(self, **fields):
  150. if self.emitted_count >= self.MAX_EVENTS:
  151. self.dropped_count += 1
  152. return False
  153. event_fields = dict(self.event_defaults)
  154. event_fields.update(fields)
  155. event_fields['request_id'] = self.request_id
  156. self.emitted_count += 1
  157. if (
  158. self.defer_until_identity
  159. and not event_fields.get('company_id')
  160. ):
  161. self._deferred.append(event_fields)
  162. return True
  163. return self._deliver(event_fields)
  164. def flush(self):
  165. deferred = self._deferred
  166. self._deferred = []
  167. accepted = True
  168. for fields in deferred:
  169. enriched = dict(fields)
  170. for name in ('company_id', 'admin_id', 'session_credential'):
  171. if name in self.event_defaults:
  172. enriched[name] = self.event_defaults[name]
  173. if not self._deliver(enriched):
  174. accepted = False
  175. return accepted
  176. def _deliver(self, event_fields):
  177. try:
  178. event = build_diagnostic_event(**event_fields)
  179. accepted = self.reporter.report(event)
  180. except Exception:
  181. self.dropped_count += 1
  182. return False
  183. if not accepted:
  184. self.dropped_count += 1
  185. return accepted