django-nativemojo 0.1.15__py3-none-any.whl → 0.1.16__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.
Files changed (221) hide show
  1. {django_nativemojo-0.1.15.dist-info → django_nativemojo-0.1.16.dist-info}/METADATA +3 -1
  2. django_nativemojo-0.1.16.dist-info/RECORD +302 -0
  3. mojo/__init__.py +1 -1
  4. mojo/apps/account/management/commands/serializer_admin.py +121 -1
  5. mojo/apps/account/migrations/0006_add_device_tracking_models.py +72 -0
  6. mojo/apps/account/migrations/0007_delete_userdevicelocation.py +16 -0
  7. mojo/apps/account/migrations/0008_userdevicelocation.py +33 -0
  8. mojo/apps/account/migrations/0009_geolocatedip_subnet.py +18 -0
  9. mojo/apps/account/migrations/0010_group_avatar.py +20 -0
  10. mojo/apps/account/migrations/0011_user_org_registereddevice_pushconfig_and_more.py +118 -0
  11. mojo/apps/account/migrations/0012_remove_pushconfig_apns_key_file_and_more.py +21 -0
  12. mojo/apps/account/migrations/0013_pushconfig_test_mode_alter_pushconfig_apns_enabled_and_more.py +28 -0
  13. mojo/apps/account/migrations/0014_notificationdelivery_data_payload_and_more.py +48 -0
  14. mojo/apps/account/models/__init__.py +2 -0
  15. mojo/apps/account/models/device.py +281 -0
  16. mojo/apps/account/models/group.py +294 -8
  17. mojo/apps/account/models/member.py +14 -1
  18. mojo/apps/account/models/push/__init__.py +4 -0
  19. mojo/apps/account/models/push/config.py +112 -0
  20. mojo/apps/account/models/push/delivery.py +93 -0
  21. mojo/apps/account/models/push/device.py +66 -0
  22. mojo/apps/account/models/push/template.py +99 -0
  23. mojo/apps/account/models/user.py +190 -17
  24. mojo/apps/account/rest/__init__.py +2 -0
  25. mojo/apps/account/rest/device.py +39 -0
  26. mojo/apps/account/rest/group.py +8 -0
  27. mojo/apps/account/rest/push.py +187 -0
  28. mojo/apps/account/rest/user.py +95 -5
  29. mojo/apps/account/services/__init__.py +1 -0
  30. mojo/apps/account/services/push.py +363 -0
  31. mojo/apps/aws/migrations/0001_initial.py +206 -0
  32. mojo/apps/aws/migrations/0002_emaildomain_can_recv_emaildomain_can_send_and_more.py +28 -0
  33. mojo/apps/aws/migrations/0003_mailbox_is_domain_default_mailbox_is_system_default_and_more.py +31 -0
  34. mojo/apps/aws/migrations/0004_s3bucket.py +39 -0
  35. mojo/apps/aws/migrations/0005_alter_emaildomain_region_delete_s3bucket.py +21 -0
  36. mojo/apps/aws/models/__init__.py +19 -0
  37. mojo/apps/aws/models/email_attachment.py +99 -0
  38. mojo/apps/aws/models/email_domain.py +218 -0
  39. mojo/apps/aws/models/email_template.py +132 -0
  40. mojo/apps/aws/models/incoming_email.py +197 -0
  41. mojo/apps/aws/models/mailbox.py +288 -0
  42. mojo/apps/aws/models/sent_message.py +175 -0
  43. mojo/apps/aws/rest/__init__.py +6 -0
  44. mojo/apps/aws/rest/email.py +33 -0
  45. mojo/apps/aws/rest/email_ops.py +183 -0
  46. mojo/apps/aws/rest/messages.py +32 -0
  47. mojo/apps/aws/rest/send.py +101 -0
  48. mojo/apps/aws/rest/sns.py +403 -0
  49. mojo/apps/aws/rest/templates.py +19 -0
  50. mojo/apps/aws/services/__init__.py +32 -0
  51. mojo/apps/aws/services/email.py +390 -0
  52. mojo/apps/aws/services/email_ops.py +548 -0
  53. mojo/apps/docit/__init__.py +6 -0
  54. mojo/apps/docit/markdown_plugins/syntax_highlight.py +25 -0
  55. mojo/apps/docit/markdown_plugins/toc.py +12 -0
  56. mojo/apps/docit/migrations/0001_initial.py +113 -0
  57. mojo/apps/docit/migrations/0002_alter_book_modified_by_alter_page_modified_by.py +26 -0
  58. mojo/apps/docit/migrations/0003_alter_book_group.py +20 -0
  59. mojo/apps/docit/models/__init__.py +17 -0
  60. mojo/apps/docit/models/asset.py +231 -0
  61. mojo/apps/docit/models/book.py +227 -0
  62. mojo/apps/docit/models/page.py +319 -0
  63. mojo/apps/docit/models/page_revision.py +203 -0
  64. mojo/apps/docit/rest/__init__.py +10 -0
  65. mojo/apps/docit/rest/asset.py +17 -0
  66. mojo/apps/docit/rest/book.py +22 -0
  67. mojo/apps/docit/rest/page.py +22 -0
  68. mojo/apps/docit/rest/page_revision.py +17 -0
  69. mojo/apps/docit/services/__init__.py +11 -0
  70. mojo/apps/docit/services/docit.py +315 -0
  71. mojo/apps/docit/services/markdown.py +44 -0
  72. mojo/apps/fileman/backends/s3.py +209 -0
  73. mojo/apps/fileman/models/file.py +45 -9
  74. mojo/apps/fileman/models/manager.py +269 -3
  75. mojo/apps/incident/migrations/0007_event_uid.py +18 -0
  76. mojo/apps/incident/migrations/0008_ticket_ticketnote.py +55 -0
  77. mojo/apps/incident/migrations/0009_incident_status.py +18 -0
  78. mojo/apps/incident/migrations/0010_event_country_code.py +18 -0
  79. mojo/apps/incident/migrations/0011_incident_country_code.py +18 -0
  80. mojo/apps/incident/migrations/0012_alter_incident_status.py +18 -0
  81. mojo/apps/incident/models/__init__.py +1 -0
  82. mojo/apps/incident/models/event.py +35 -0
  83. mojo/apps/incident/models/incident.py +2 -0
  84. mojo/apps/incident/models/ticket.py +62 -0
  85. mojo/apps/incident/reporter.py +21 -3
  86. mojo/apps/incident/rest/__init__.py +1 -0
  87. mojo/apps/incident/rest/ticket.py +43 -0
  88. mojo/apps/jobs/__init__.py +489 -0
  89. mojo/apps/jobs/adapters.py +24 -0
  90. mojo/apps/jobs/cli.py +616 -0
  91. mojo/apps/jobs/daemon.py +370 -0
  92. mojo/apps/jobs/examples/sample_jobs.py +376 -0
  93. mojo/apps/jobs/examples/webhook_examples.py +203 -0
  94. mojo/apps/jobs/handlers/__init__.py +5 -0
  95. mojo/apps/jobs/handlers/webhook.py +317 -0
  96. mojo/apps/jobs/job_engine.py +734 -0
  97. mojo/apps/jobs/keys.py +203 -0
  98. mojo/apps/jobs/local_queue.py +363 -0
  99. mojo/apps/jobs/management/__init__.py +3 -0
  100. mojo/apps/jobs/management/commands/__init__.py +3 -0
  101. mojo/apps/jobs/manager.py +1327 -0
  102. mojo/apps/jobs/migrations/0001_initial.py +97 -0
  103. mojo/apps/jobs/migrations/0002_alter_job_max_retries_joblog.py +39 -0
  104. mojo/apps/jobs/models/__init__.py +6 -0
  105. mojo/apps/jobs/models/job.py +441 -0
  106. mojo/apps/jobs/rest/__init__.py +2 -0
  107. mojo/apps/jobs/rest/control.py +466 -0
  108. mojo/apps/jobs/rest/jobs.py +421 -0
  109. mojo/apps/jobs/scheduler.py +571 -0
  110. mojo/apps/jobs/services/__init__.py +6 -0
  111. mojo/apps/jobs/services/job_actions.py +465 -0
  112. mojo/apps/jobs/settings.py +209 -0
  113. mojo/apps/logit/models/log.py +3 -0
  114. mojo/apps/metrics/__init__.py +8 -1
  115. mojo/apps/metrics/redis_metrics.py +198 -0
  116. mojo/apps/metrics/rest/__init__.py +3 -0
  117. mojo/apps/metrics/rest/categories.py +266 -0
  118. mojo/apps/metrics/rest/helpers.py +48 -0
  119. mojo/apps/metrics/rest/permissions.py +99 -0
  120. mojo/apps/metrics/rest/values.py +277 -0
  121. mojo/apps/metrics/utils.py +17 -0
  122. mojo/decorators/http.py +40 -1
  123. mojo/helpers/aws/__init__.py +11 -7
  124. mojo/helpers/aws/inbound_email.py +309 -0
  125. mojo/helpers/aws/kms.py +413 -0
  126. mojo/helpers/aws/ses_domain.py +959 -0
  127. mojo/helpers/crypto/__init__.py +1 -1
  128. mojo/helpers/crypto/utils.py +15 -0
  129. mojo/helpers/location/__init__.py +2 -0
  130. mojo/helpers/location/countries.py +262 -0
  131. mojo/helpers/location/geolocation.py +196 -0
  132. mojo/helpers/logit.py +37 -0
  133. mojo/helpers/redis/__init__.py +2 -0
  134. mojo/helpers/redis/adapter.py +606 -0
  135. mojo/helpers/redis/client.py +48 -0
  136. mojo/helpers/redis/pool.py +225 -0
  137. mojo/helpers/request.py +8 -0
  138. mojo/helpers/response.py +8 -0
  139. mojo/middleware/auth.py +1 -1
  140. mojo/middleware/cors.py +40 -0
  141. mojo/middleware/logging.py +131 -12
  142. mojo/middleware/mojo.py +5 -0
  143. mojo/models/rest.py +271 -57
  144. mojo/models/secrets.py +86 -0
  145. mojo/serializers/__init__.py +16 -10
  146. mojo/serializers/core/__init__.py +90 -0
  147. mojo/serializers/core/cache/__init__.py +121 -0
  148. mojo/serializers/core/cache/backends.py +518 -0
  149. mojo/serializers/core/cache/base.py +102 -0
  150. mojo/serializers/core/cache/disabled.py +181 -0
  151. mojo/serializers/core/cache/memory.py +287 -0
  152. mojo/serializers/core/cache/redis.py +533 -0
  153. mojo/serializers/core/cache/utils.py +454 -0
  154. mojo/serializers/{manager.py → core/manager.py} +53 -4
  155. mojo/serializers/core/serializer.py +475 -0
  156. mojo/serializers/{advanced/formats → formats}/csv.py +116 -139
  157. mojo/serializers/suggested_improvements.md +388 -0
  158. testit/client.py +1 -1
  159. testit/helpers.py +14 -0
  160. testit/runner.py +23 -6
  161. django_nativemojo-0.1.15.dist-info/RECORD +0 -234
  162. mojo/apps/notify/README.md +0 -91
  163. mojo/apps/notify/README_NOTIFICATIONS.md +0 -566
  164. mojo/apps/notify/admin.py +0 -52
  165. mojo/apps/notify/handlers/example_handlers.py +0 -516
  166. mojo/apps/notify/handlers/ses/__init__.py +0 -25
  167. mojo/apps/notify/handlers/ses/complaint.py +0 -25
  168. mojo/apps/notify/handlers/ses/message.py +0 -86
  169. mojo/apps/notify/management/commands/__init__.py +0 -1
  170. mojo/apps/notify/management/commands/process_notifications.py +0 -370
  171. mojo/apps/notify/mod +0 -0
  172. mojo/apps/notify/models/__init__.py +0 -12
  173. mojo/apps/notify/models/account.py +0 -128
  174. mojo/apps/notify/models/attachment.py +0 -24
  175. mojo/apps/notify/models/bounce.py +0 -68
  176. mojo/apps/notify/models/complaint.py +0 -40
  177. mojo/apps/notify/models/inbox.py +0 -113
  178. mojo/apps/notify/models/inbox_message.py +0 -173
  179. mojo/apps/notify/models/outbox.py +0 -129
  180. mojo/apps/notify/models/outbox_message.py +0 -288
  181. mojo/apps/notify/models/template.py +0 -30
  182. mojo/apps/notify/providers/aws.py +0 -73
  183. mojo/apps/notify/rest/ses.py +0 -0
  184. mojo/apps/notify/utils/__init__.py +0 -2
  185. mojo/apps/notify/utils/notifications.py +0 -404
  186. mojo/apps/notify/utils/parsing.py +0 -202
  187. mojo/apps/notify/utils/render.py +0 -144
  188. mojo/apps/tasks/README.md +0 -118
  189. mojo/apps/tasks/__init__.py +0 -44
  190. mojo/apps/tasks/manager.py +0 -644
  191. mojo/apps/tasks/rest/__init__.py +0 -2
  192. mojo/apps/tasks/rest/hooks.py +0 -0
  193. mojo/apps/tasks/rest/tasks.py +0 -76
  194. mojo/apps/tasks/runner.py +0 -439
  195. mojo/apps/tasks/task.py +0 -99
  196. mojo/apps/tasks/tq_handlers.py +0 -132
  197. mojo/helpers/crypto/__pycache__/hash.cpython-310.pyc +0 -0
  198. mojo/helpers/crypto/__pycache__/sign.cpython-310.pyc +0 -0
  199. mojo/helpers/crypto/__pycache__/utils.cpython-310.pyc +0 -0
  200. mojo/helpers/redis.py +0 -10
  201. mojo/models/meta.py +0 -262
  202. mojo/serializers/advanced/README.md +0 -363
  203. mojo/serializers/advanced/__init__.py +0 -247
  204. mojo/serializers/advanced/formats/__init__.py +0 -28
  205. mojo/serializers/advanced/formats/excel.py +0 -516
  206. mojo/serializers/advanced/formats/json.py +0 -239
  207. mojo/serializers/advanced/formats/response.py +0 -485
  208. mojo/serializers/advanced/serializer.py +0 -568
  209. mojo/serializers/optimized.py +0 -618
  210. {django_nativemojo-0.1.15.dist-info → django_nativemojo-0.1.16.dist-info}/LICENSE +0 -0
  211. {django_nativemojo-0.1.15.dist-info → django_nativemojo-0.1.16.dist-info}/NOTICE +0 -0
  212. {django_nativemojo-0.1.15.dist-info → django_nativemojo-0.1.16.dist-info}/WHEEL +0 -0
  213. /mojo/apps/{notify → aws/migrations}/__init__.py +0 -0
  214. /mojo/apps/{notify/handlers → docit/markdown_plugins}/__init__.py +0 -0
  215. /mojo/apps/{notify/management → docit/migrations}/__init__.py +0 -0
  216. /mojo/apps/{notify/providers → jobs/examples}/__init__.py +0 -0
  217. /mojo/apps/{notify/rest → jobs/migrations}/__init__.py +0 -0
  218. /mojo/{serializers → rest}/openapi.py +0 -0
  219. /mojo/serializers/{settings_example.py → examples/settings.py} +0 -0
  220. /mojo/{apps/notify/handlers/ses/bounce.py → serializers/formats/__init__.py} +0 -0
  221. /mojo/serializers/{advanced/formats → formats}/localizers.py +0 -0
@@ -0,0 +1,317 @@
1
+ """
2
+ Webhook job handler for Django-MOJO Jobs System.
3
+
4
+ Specialized handler for sending webhooks (HTTP POST requests) with proper
5
+ retry logic, timeout handling, and comprehensive logging.
6
+ """
7
+ import json
8
+ import requests
9
+ from datetime import datetime, timezone
10
+ from typing import Dict, Any, Optional
11
+ from urllib.parse import urlparse
12
+
13
+ from django.conf import settings
14
+
15
+ from mojo.helpers import logit
16
+ from mojo.apps.jobs.models import Job
17
+
18
+
19
+ def post_webhook(job: Job) -> str:
20
+ """
21
+ Send a webhook POST request with comprehensive error handling and logging.
22
+
23
+ Expected payload:
24
+ url: Target webhook URL (required)
25
+ data: Data to POST as JSON (required)
26
+ headers: HTTP headers dict (optional)
27
+ timeout: Request timeout in seconds (default: 30)
28
+ webhook_id: Optional webhook identifier for tracking
29
+
30
+ Returns:
31
+ str: 'success', 'failed', or 'cancelled'
32
+
33
+ The job will be automatically retried based on the job's retry configuration
34
+ when certain errors occur (network errors, 5xx responses, timeouts).
35
+ """
36
+ # Extract payload
37
+ url = job.payload.get('url')
38
+ data = job.payload.get('data')
39
+ headers = job.payload.get('headers', {})
40
+ timeout = job.payload.get('timeout', getattr(settings, 'JOBS_WEBHOOK_DEFAULT_TIMEOUT', 30))
41
+ webhook_id = job.payload.get('webhook_id')
42
+
43
+ # Validate required fields
44
+ if not url:
45
+ job.metadata['error'] = 'Missing required field: url'
46
+ job.metadata['failed_at'] = datetime.now(timezone.utc).isoformat()
47
+ return 'failed'
48
+
49
+ if data is None:
50
+ job.metadata['error'] = 'Missing required field: data'
51
+ job.metadata['failed_at'] = datetime.now(timezone.utc).isoformat()
52
+ return 'failed'
53
+
54
+ # Check for cancellation
55
+ if job.cancel_requested:
56
+ job.metadata['cancelled'] = True
57
+ job.metadata['cancelled_at'] = datetime.now(timezone.utc).isoformat()
58
+ logit.info(f"Webhook job {job.id} cancelled before execution")
59
+ return 'cancelled'
60
+
61
+ # Initialize tracking metadata
62
+ start_time = datetime.now(timezone.utc)
63
+ job.metadata.update({
64
+ 'webhook_started_at': start_time.isoformat(),
65
+ 'url': url,
66
+ 'webhook_id': webhook_id,
67
+ 'attempt': job.attempt,
68
+ 'timeout_seconds': timeout,
69
+ 'headers_sent': _sanitize_headers(headers)
70
+ })
71
+
72
+ try:
73
+ # Parse URL for validation and logging
74
+ parsed_url = urlparse(url)
75
+ job.metadata['parsed_url'] = {
76
+ 'scheme': parsed_url.scheme,
77
+ 'netloc': parsed_url.netloc,
78
+ 'path': parsed_url.path
79
+ }
80
+
81
+ logit.info(f"Sending webhook {job.id} to {parsed_url.netloc}{parsed_url.path} "
82
+ f"(attempt {job.attempt})")
83
+
84
+ # Make the request
85
+ response = requests.post(
86
+ url=url,
87
+ json=data, # Auto JSON encoding and Content-Type header
88
+ headers=headers,
89
+ timeout=timeout,
90
+ allow_redirects=True # Follow redirects for webhooks
91
+ )
92
+
93
+ # Calculate duration
94
+ end_time = datetime.now(timezone.utc)
95
+ duration_ms = int((end_time - start_time).total_seconds() * 1000)
96
+
97
+ # Store response metadata
98
+ job.metadata.update({
99
+ 'response_status_code': response.status_code,
100
+ 'response_headers': dict(response.headers),
101
+ 'response_size_bytes': len(response.content),
102
+ 'duration_ms': duration_ms,
103
+ 'webhook_completed_at': end_time.isoformat()
104
+ })
105
+
106
+ # Store response body sample (first 1KB for debugging)
107
+ if response.content:
108
+ try:
109
+ # Try to parse as JSON first
110
+ response_json = response.json()
111
+ if isinstance(response_json, dict):
112
+ # Store first 10 keys for debugging
113
+ sample = {k: v for k, v in list(response_json.items())[:10]}
114
+ job.metadata['response_sample'] = sample
115
+ elif isinstance(response_json, list):
116
+ job.metadata['response_count'] = len(response_json)
117
+ if response_json:
118
+ job.metadata['response_sample'] = response_json[0]
119
+ except:
120
+ # Not JSON, store text sample
121
+ text_sample = response.text[:1000]
122
+ job.metadata['response_text_sample'] = text_sample
123
+
124
+ # Check response status
125
+ try:
126
+ response.raise_for_status()
127
+
128
+ # Success!
129
+ logit.info(f"Webhook {job.id} delivered successfully to {parsed_url.netloc} "
130
+ f"(status {response.status_code}, {duration_ms}ms)")
131
+
132
+ # Emit success metrics
133
+ _emit_webhook_metrics('success', duration_ms, parsed_url.netloc)
134
+
135
+ return 'success'
136
+
137
+ except requests.exceptions.HTTPError as e:
138
+ # HTTP error response
139
+ status_code = response.status_code
140
+ job.metadata.update({
141
+ 'error_type': 'http_error',
142
+ 'error_status_code': status_code,
143
+ 'error_message': f"HTTP {status_code}: {response.reason}"
144
+ })
145
+
146
+ # Determine if we should retry based on status code
147
+ retriable_codes = [408, 429, 502, 503, 504, 520, 521, 522, 523, 524]
148
+
149
+ if status_code in retriable_codes:
150
+ # Server/network error - retry
151
+ logit.warn(f"Webhook {job.id} received retriable HTTP {status_code}, will retry")
152
+ _emit_webhook_metrics('error_retriable', duration_ms, parsed_url.netloc)
153
+ raise # This will trigger retry logic
154
+
155
+ elif 400 <= status_code < 500:
156
+ # Client error - don't retry
157
+ logit.error(f"Webhook {job.id} failed with client error HTTP {status_code}")
158
+ _emit_webhook_metrics('error_client', duration_ms, parsed_url.netloc)
159
+ return 'failed'
160
+
161
+ else:
162
+ # Other error - retry
163
+ logit.warn(f"Webhook {job.id} received HTTP {status_code}, will retry")
164
+ _emit_webhook_metrics('error_retriable', duration_ms, parsed_url.netloc)
165
+ raise
166
+
167
+ except requests.exceptions.Timeout:
168
+ job.metadata.update({
169
+ 'error_type': 'timeout',
170
+ 'error_message': f'Request timed out after {timeout} seconds'
171
+ })
172
+ logit.warn(f"Webhook {job.id} timed out after {timeout}s, will retry")
173
+ _emit_webhook_metrics('timeout', None, parsed_url.netloc if 'parsed_url' in locals() else 'unknown')
174
+ raise # Retry on timeout
175
+
176
+ except requests.exceptions.ConnectionError as e:
177
+ job.metadata.update({
178
+ 'error_type': 'connection_error',
179
+ 'error_message': f'Connection failed: {str(e)}'
180
+ })
181
+ logit.warn(f"Webhook {job.id} connection failed, will retry: {e}")
182
+ _emit_webhook_metrics('connection_error', None, parsed_url.netloc if 'parsed_url' in locals() else 'unknown')
183
+ raise # Retry on connection errors
184
+
185
+ except requests.exceptions.RequestException as e:
186
+ # Other requests errors - retry
187
+ job.metadata.update({
188
+ 'error_type': 'request_error',
189
+ 'error_message': str(e)
190
+ })
191
+ logit.warn(f"Webhook {job.id} request failed, will retry: {e}")
192
+ _emit_webhook_metrics('request_error', None, parsed_url.netloc if 'parsed_url' in locals() else 'unknown')
193
+ raise
194
+
195
+ except Exception as e:
196
+ # Unexpected error - don't retry
197
+ job.metadata.update({
198
+ 'error_type': 'unexpected_error',
199
+ 'error_message': str(e),
200
+ 'failed_at': datetime.now(timezone.utc).isoformat()
201
+ })
202
+ logit.error(f"Webhook {job.id} failed with unexpected error: {e}")
203
+ _emit_webhook_metrics('unexpected_error', None, parsed_url.netloc if 'parsed_url' in locals() else 'unknown')
204
+ return 'failed'
205
+
206
+
207
+ def _sanitize_headers(headers: Dict[str, str]) -> Dict[str, str]:
208
+ """
209
+ Sanitize headers for logging by masking sensitive values.
210
+
211
+ Args:
212
+ headers: Original headers dict
213
+
214
+ Returns:
215
+ dict: Headers with sensitive values masked
216
+ """
217
+ if not headers:
218
+ return {}
219
+
220
+ # Headers to mask
221
+ sensitive_headers = {
222
+ 'authorization', 'x-api-key', 'x-auth-token', 'cookie',
223
+ 'x-webhook-secret', 'x-hub-signature', 'x-signature'
224
+ }
225
+
226
+ sanitized = {}
227
+ for key, value in headers.items():
228
+ if key.lower() in sensitive_headers:
229
+ # Show just the first few characters
230
+ if isinstance(value, str) and len(value) > 8:
231
+ sanitized[key] = f"{value[:4]}...{value[-4:]}"
232
+ else:
233
+ sanitized[key] = "***masked***"
234
+ else:
235
+ sanitized[key] = value
236
+
237
+ return sanitized
238
+
239
+
240
+ def _emit_webhook_metrics(outcome: str, duration_ms: Optional[int], host: str):
241
+ """
242
+ Emit webhook metrics for monitoring.
243
+
244
+ Args:
245
+ outcome: Outcome type (success, timeout, error_*, etc.)
246
+ duration_ms: Request duration in milliseconds (if available)
247
+ host: Target hostname
248
+ """
249
+ try:
250
+ from mojo.apps import metrics
251
+
252
+ now = datetime.now(timezone.utc)
253
+
254
+ # Emit outcome metric
255
+ metrics.record(
256
+ slug=f"webhooks.{outcome}",
257
+ when=now,
258
+ count=1,
259
+ category="webhooks"
260
+ )
261
+
262
+ # Emit per-host metric
263
+ safe_host = host.replace('.', '_').replace('-', '_')[:50] # Safe metric name
264
+ metrics.record(
265
+ slug=f"webhooks.host.{safe_host}.{outcome}",
266
+ when=now,
267
+ count=1,
268
+ category="webhooks"
269
+ )
270
+
271
+ # Emit duration metric if available
272
+ if duration_ms is not None:
273
+ metrics.record(
274
+ slug="webhooks.duration_ms",
275
+ when=now,
276
+ count=duration_ms,
277
+ category="webhooks"
278
+ )
279
+
280
+ except Exception as e:
281
+ # Don't fail the job if metrics fail
282
+ logit.debug(f"Failed to emit webhook metrics: {e}")
283
+
284
+
285
+ def validate_webhook_payload(payload: Dict[str, Any]) -> Optional[str]:
286
+ """
287
+ Validate webhook job payload.
288
+
289
+ Args:
290
+ payload: Job payload to validate
291
+
292
+ Returns:
293
+ str: Error message if invalid, None if valid
294
+ """
295
+ if not isinstance(payload, dict):
296
+ return "Payload must be a dictionary"
297
+
298
+ if 'url' not in payload:
299
+ return "Missing required field: url"
300
+
301
+ if 'data' not in payload:
302
+ return "Missing required field: data"
303
+
304
+ url = payload['url']
305
+ if not isinstance(url, str) or not url.strip():
306
+ return "URL must be a non-empty string"
307
+
308
+ if not url.startswith(('http://', 'https://')):
309
+ return "URL must start with http:// or https://"
310
+
311
+ # Validate data is JSON serializable
312
+ try:
313
+ json.dumps(payload['data'])
314
+ except (TypeError, ValueError):
315
+ return "Data field must be JSON serializable"
316
+
317
+ return None