PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.7.1
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.7.1
2.7.1 2.7.2 2.7.0 2.6.26 2.6.25 2.6.24 2.6.23 2.6.22 2.6.21 2.6.20 2.6.19 2.6.18 2.6.17 2.6.16 2.6.15 2.6.14 2.6.13 2.6.12 2.6.11 2.6.10 2.6.9 2.6.8 2.6.7 2.6.6 2.6.5 All 141 releases
metasync / telemetry / sentry-wordpress-integration.php

sentry-wordpress-integration.php in Search Atlas SEO – OTTO AI SEO Automation for WordPress 2.7.1, at telemetry/sentry-wordpress-integration.php

1,038 lines 38.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 // If this file is called directly, abort.
3 if (!defined('ABSPATH')) {
4 exit;
5 }
6
7 require_once __DIR__ . '/privacy.php';
8
9 /**
10 * WordPress-Native Sentry Integration
11 *
12 * Uses WordPress HTTP API to send data directly to Sentry
13 * Compatible with PHP 7.1+ and WordPress 5.0+
14 *
15 * This is the OFFICIAL way to use Sentry without Composer
16 */
17 class MetaSync_Sentry_WordPress {
18
19 private $dsn;
20 private $options;
21 private $environment;
22 private $release;
23 private $public_key;
24 private $secret_key;
25 private $project_id;
26 private $host;
27 private $scheme;
28
29 public function __construct($dsn, $options = []) {
30 $this->dsn = $dsn;
31 $this->options = $options;
32
33 // Use constants for configuration instead of detecting/parsing
34 $this->environment = defined('METASYNC_SENTRY_ENVIRONMENT') ? METASYNC_SENTRY_ENVIRONMENT : $this->detectEnvironment();
35 $this->release = defined('METASYNC_SENTRY_RELEASE') ? METASYNC_SENTRY_RELEASE : (defined('METASYNC_VERSION') ? METASYNC_VERSION : '1.0.0');
36
37 // Handle proxy DSN format (proxy://project_id) or legacy DSN
38 if (strpos($dsn, 'proxy://') === 0) {
39 // New proxy format: proxy://project_id
40 $this->project_id = str_replace('proxy://', '', $dsn);
41 $this->public_key = null; // Not needed for proxy
42 $this->secret_key = null; // Not needed for proxy
43 $this->host = null; // Proxy handles this
44 $this->scheme = 'proxy';
45 } else {
46 // Legacy DSN format for backward compatibility
47 $parsed = wp_parse_url($this->dsn);
48 $this->public_key = is_array($parsed) && isset($parsed['user']) ? $parsed['user'] : null;
49 $this->secret_key = is_array($parsed) && isset($parsed['pass']) ? $parsed['pass'] : null;
50 $this->project_id = is_array($parsed) && isset($parsed['path']) ? trim($parsed['path'], '/') : null;
51 $this->host = is_array($parsed) && isset($parsed['host']) ? $parsed['host'] : null;
52 $this->scheme = is_array($parsed) && isset($parsed['scheme']) ? $parsed['scheme'] : 'https';
53 }
54 }
55
56 /**
57 * Capture an exception and send to Sentry
58 */
59 public function captureException($exception, $extra = []) {
60 $data = $this->formatException($exception, $extra);
61 $result = $this->sendToSentry($data);
62 return is_array($result) ? $result['success'] : $result;
63 }
64
65 /**
66 * Capture a message and send to Sentry
67 */
68 public function captureMessage($message, $level = 'info', $extra = [], $attachment = null) {
69 $data = $this->formatMessage($message, $level, $extra);
70 $result = $this->sendToSentry($data, 'event', $attachment);
71 return is_array($result) ? $result['success'] : $result;
72 }
73
74 /**
75 * Capture a message without waiting for Sentry to answer.
76 *
77 * captureMessage() blocks: sendToSentry() uses cURL with a 5s connect and 5s
78 * read timeout, so a slow or unreachable collector can add up to ten seconds
79 * to whatever request called it. That is acceptable for admin-side and cron
80 * work, but not on a visitor's page render — least of all when the reason we
81 * are reporting is that the request is already degraded.
82 *
83 * This variant hands the envelope to WordPress's HTTP API with
84 * 'blocking' => false, so the request is dispatched and the caller continues
85 * immediately. The trade-off is that delivery is unconfirmed and never
86 * retried: a dropped report is silently lost. For counting how often
87 * something happens that is a fair exchange for not touching page-load time.
88 *
89 * The token is read from cache only. metasync_get_jwt_token() falls through to
90 * a fresh fetch on a cache miss — a blocking POST with a 15 second timeout —
91 * which would defeat the whole point of this method and land that cost on an
92 * already-degraded request. With no cached token the report is skipped; the
93 * next admin or cron request repopulates the cache.
94 *
95 * @param string $message Message to record.
96 * @param string $level Sentry level (info|warning|error|fatal).
97 * @param array $extra Additional context.
98 * @return bool True if a request was dispatched, false if it could not be.
99 */
100 public function captureMessageNonBlocking($message, $level = 'info', $extra = []) {
101 if (metasync_telemetry_is_disabled()) {
102 return false;
103 }
104
105 # Mirrors sendToSentry()'s preconditions.
106 if ($this->isLocalhost()) {
107 return false;
108 }
109
110 try {
111 # method_exists() is redundant to static analysis — this MR adds the
112 # method, so PHPStan proves the call always true. Kept for the upgrade
113 # window: during a plugin update an opcache can still hold the previous
114 # Metasync_Connect_Manager, where class_exists() passes but the
115 # cache-only accessor is absent. Falling through to the else branch is
116 # the safe outcome there; an unguarded call would fatal.
117 # @phpstan-ignore-next-line function.alreadyNarrowedType
118 if (class_exists('Metasync_Connect_Manager') && method_exists('Metasync_Connect_Manager', 'get_cached_jwt_token')) {
119 $jwt_token = Metasync_Connect_Manager::get_cached_jwt_token();
120 } else {
121 # No cache-only accessor available — skip rather than risk the
122 # blocking fetch path.
123 return false;
124 }
125 } catch (Exception $e) {
126 return false;
127 } catch (Error $e) {
128 return false;
129 }
130
131 if (empty($jwt_token)) {
132 return false;
133 }
134
135 $data = $this->formatMessage($message, $level, $extra);
136 $envelope = $this->createSentryEnvelope($data, 'event', null);
137
138 if (empty($envelope)) {
139 return false;
140 }
141
142 $plugin_version = defined('METASYNC_VERSION') ? METASYNC_VERSION : '1.0.0';
143
144 # Same tunnel endpoint sendToSentry() posts to.
145 $url = 'https://wordpress.telemetry.infra.searchatlas.com/api/4509950439849985/envelope/';
146
147 wp_remote_post($url, [
148 'blocking' => false,
149 'timeout' => 0.01,
150 'sslverify' => true,
151 'headers' => [
152 'Authorization' => 'Bearer ' . $jwt_token,
153 'Content-Type' => 'application/x-sentry-envelope',
154 'X-Plugin-Version' => $plugin_version,
155 'User-Agent' => 'WordPress MetaSync Plugin/' . $plugin_version,
156 ],
157 'body' => $envelope,
158 ]);
159
160 return true;
161 }
162
163 /**
164 * Capture user feedback and send to Sentry
165 *
166 * Since Sentry requires user feedback to be associated with an event,
167 * this method first creates an event with the feedback message, then
168 * associates the feedback with that event.
169 *
170 * @param array $feedback Feedback data with keys: name (optional), email (optional), message (required), event_id (optional), severity (optional)
171 * @param array|null $attachment Optional attachment data
172 * @return bool|array Success status, or array with success and event_id
173 */
174 public function captureFeedback($feedback, $attachment = null) {
175 // Get the feedback message
176 $message = '';
177 if (isset($feedback['message']) && !empty($feedback['message'])) {
178 $message = $feedback['message'];
179 } elseif (isset($feedback['comments']) && !empty($feedback['comments'])) {
180 $message = $feedback['comments'];
181 }
182
183 if (empty($message)) {
184 return false;
185 }
186
187 // Get severity level if provided
188 $severity = isset($feedback['severity']) ? sanitize_text_field($feedback['severity']) : '';
189 $valid_severity_levels = array('info', 'warning', 'error', 'fatal');
190
191 // Get otto_pixel_uuid from general options (same way as used in admin handler)
192 $general_options = class_exists('Metasync') ? Metasync::get_option('general') : get_option('metasync_options', [])['general'] ?? [];
193 if (!is_array($general_options)) {
194 $general_options = [];
195 }
196 $project_uuid = isset($general_options['otto_pixel_uuid']) ? sanitize_text_field($general_options['otto_pixel_uuid']) : '';
197
198 // Format event title as "Client Report {UUID}"
199 $event_title = !empty($project_uuid) ? 'Client Report ' . $project_uuid : 'Client Report (UUID Not Configured)';
200
201 // Build the message with title, severity, and user description
202 // Format: "Client Report {uuid}\nSeverity: {level}\n\n{user description}"
203 $formatted_message = $event_title;
204 if (!empty($severity) && in_array($severity, $valid_severity_levels, true)) {
205 $severity_label = ucfirst($severity);
206 $formatted_message .= "\nSeverity: {$severity_label}";
207 }
208
209 // Add attachment indicator if an attachment is present
210 if ($attachment && !empty($attachment['filename'])) {
211 $formatted_message .= "\nAttachment: " . $attachment['filename'];
212 }
213
214 $formatted_message .= "\n\n" . $message;
215
216 // If event_id is already provided, use it directly
217 if (isset($feedback['event_id']) && !empty($feedback['event_id'])) {
218 // Update the feedback message with formatted message (title + severity + description)
219 $feedback['message'] = $formatted_message;
220 $data = $this->formatFeedback($feedback);
221 $result = $this->sendToSentry($data, 'user_report', $attachment);
222 return is_array($result) ? $result['success'] : $result;
223 }
224
225 // Determine event level based on severity
226 $event_level = !empty($severity) && in_array($severity, $valid_severity_levels, true) ? $severity : 'info';
227
228 // Create an event with the formatted message (title + severity + user description)
229 // The title will also be set in culprit field for Sentry to display
230 $event_data = $this->formatMessage($formatted_message, $event_level, [
231 'feedback_source' => 'user_report',
232 'original_feedback' => $feedback,
233 'report_title' => $event_title
234 ]);
235
236 // Set the culprit (title) field for Sentry to display as the event title
237 // The culprit field is what Sentry uses to show the title in the issues list
238 $event_data['culprit'] = $event_title;
239
240 // Add the category tag
241 if (isset($event_data['tags']) && is_array($event_data['tags'])) {
242 $event_data['tags']['category'] = 'user-feedback';
243 } else {
244 $event_data['tags'] = ['category' => 'user-feedback'];
245 }
246
247 // Send the event first with attachment
248 $event_result = $this->sendToSentry($event_data, 'event', $attachment);
249
250 // Extract event_id from response
251 $event_id = null;
252 if (is_array($event_result) && isset($event_result['event_id'])) {
253 $event_id = $event_result['event_id'];
254 } elseif (is_array($event_result) && $event_result['success']) {
255 // If event was sent but no event_id in response, use the one we generated
256 $event_id = $event_data['event_id'] ?? null;
257 }
258
259 // If event creation failed, return false
260 if (!$event_id) {
261 return false;
262 }
263
264 // Now send the feedback with the event_id (no attachment on feedback, it's already on the event)
265 // Make sure the feedback message includes title, severity, and user description
266 $feedback['event_id'] = $event_id;
267 $feedback['message'] = $formatted_message; // Use the formatted message (title + severity + description)
268 $data = $this->formatFeedback($feedback);
269 $feedback_result = $this->sendToSentry($data, 'user_report');
270
271 return is_array($feedback_result) ? $feedback_result['success'] : $feedback_result;
272 }
273
274 /**
275 * Format exception data for Sentry API
276 */
277 private function formatException($exception, $extra = []) {
278 $trace = [];
279 if (is_object($exception) && method_exists($exception, 'getTrace')) {
280 foreach ($exception->getTrace() as $frame) {
281 $trace[] = [
282 'filename' => isset($frame['file']) ? $frame['file'] : '<unknown>',
283 'lineno' => isset($frame['line']) ? $frame['line'] : 0,
284 'function' => isset($frame['function']) ? $frame['function'] : '<unknown>',
285 'module' => isset($frame['class']) ? $frame['class'] : null,
286 'in_app' => $this->isInApp($frame)
287 ];
288 }
289 }
290
291 return [
292 'event_id' => $this->generateEventId(),
293 'timestamp' => gmdate('Y-m-d\TH:i:s\Z'),
294 'level' => 'error',
295 'platform' => 'php',
296 'sdk' => [
297 'name' => 'metasync-wordpress-sentry',
298 'version' => $this->release
299 ],
300 'server_name' => $_SERVER['HTTP_HOST'] ?? 'unknown',
301 'release' => $this->release,
302 'environment' => $this->environment,
303 'exception' => [
304 'values' => [
305 [
306 'type' => is_object($exception) ? get_class($exception) : 'Error',
307 'value' => is_object($exception) ? $exception->getMessage() : (string)$exception,
308 'stacktrace' => ['frames' => array_reverse($trace)]
309 ]
310 ]
311 ],
312 'tags' => $this->getTags(),
313 'extra' => array_merge($this->getSystemContext(), $extra),
314 'user' => $this->getUserContext(),
315 'contexts' => $this->getContexts()
316 ];
317 }
318
319 /**
320 * Format message data for Sentry API
321 */
322 private function formatMessage($message, $level, $extra = []) {
323 return [
324 'event_id' => $this->generateEventId(),
325 'timestamp' => gmdate('Y-m-d\TH:i:s\Z'),
326 'level' => $this->normalizeLevel($level),
327 'platform' => 'php',
328 'sdk' => [
329 'name' => 'metasync-wordpress-sentry',
330 'version' => $this->release
331 ],
332 'server_name' => $_SERVER['HTTP_HOST'] ?? 'unknown',
333 'release' => $this->release,
334 'environment' => $this->environment,
335 'message' => [
336 'message' => $message
337 ],
338 'tags' => $this->getTags(),
339 'extra' => array_merge($this->getSystemContext(), $extra),
340 'user' => $this->getUserContext(),
341 'contexts' => $this->getContexts()
342 ];
343 }
344
345 /**
346 * Format user feedback data for Sentry User Feedback API
347 *
348 * @param array $feedback Feedback data with keys: name, email, message (or comments), event_id
349 * @return array Formatted feedback data
350 */
351 private function formatFeedback($feedback) {
352 // Support both 'message' (JavaScript SDK API) and 'comments' (envelope format)
353 // The JavaScript SDK accepts 'message' but converts it to 'comments' in the envelope
354 $comments = '';
355 if (isset($feedback['message']) && !empty($feedback['message'])) {
356 $comments = $feedback['message'];
357 } elseif (isset($feedback['comments']) && !empty($feedback['comments'])) {
358 $comments = $feedback['comments'];
359 }
360
361 // Validate required fields
362 if (empty($comments)) {
363 throw new InvalidArgumentException('Message/comments field is required for user feedback');
364 }
365
366 // Build feedback payload according to Sentry User Feedback API envelope format
367 // Reference: https://docs.sentry.io/platforms/javascript/user-feedback/#user-feedback-api
368 // IMPORTANT: The envelope format uses 'comments' as the key (not 'message')
369 // The JavaScript SDK accepts 'message' but converts it to 'comments' internally
370 $feedback_data = [
371 'comments' => sanitize_textarea_field($comments)
372 ];
373
374 // Add optional name field
375 if (isset($feedback['name']) && !empty(trim($feedback['name']))) {
376 $feedback_data['name'] = sanitize_text_field($feedback['name']);
377 }
378
379 // Add optional email field
380 if (isset($feedback['email']) && !empty(trim($feedback['email']))) {
381 $feedback_data['email'] = sanitize_email($feedback['email']);
382 }
383
384 // Add event_id if provided (to associate feedback with an event)
385 // Note: In envelope format, event_id can be in the payload
386 if (isset($feedback['event_id']) && !empty($feedback['event_id'])) {
387 $feedback_data['event_id'] = sanitize_text_field($feedback['event_id']);
388 }
389
390 return $feedback_data;
391 }
392
393 /**
394 * Check if the current environment is localhost/development
395 */
396 private function isLocalhost() {
397 $host = wp_parse_url(home_url(), PHP_URL_HOST);
398
399 // Check for common localhost patterns
400 $localhost_patterns = [
401 'localhost',
402 '127.0.0.1',
403 '::1',
404 '0.0.0.0',
405 '.local',
406 '.test',
407 '.dev',
408 '.localhost'
409 ];
410
411 foreach ($localhost_patterns as $pattern) {
412 if ($host !== false && $host !== null && strpos($host, $pattern) !== false) {
413 return true;
414 }
415 }
416
417 // Check if host is an IP address in private ranges
418 if (filter_var($host, FILTER_VALIDATE_IP)) {
419 $ip = ip2long($host);
420 if ($ip !== false) {
421 // Private IP ranges: 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16
422 if (($ip >= ip2long('10.0.0.0') && $ip <= ip2long('10.255.255.255')) ||
423 ($ip >= ip2long('172.16.0.0') && $ip <= ip2long('172.31.255.255')) ||
424 ($ip >= ip2long('192.168.0.0') && $ip <= ip2long('192.168.255.255'))) {
425 return true;
426 }
427 }
428 }
429
430 return false;
431 }
432
433 /**
434 * Send data to Sentry API using WordPress HTTP functions (proxied through our system with JWT)
435 *
436 * @param array $data Sentry event or feedback data
437 * @param string $item_type Type of item: 'event' or 'user_report'
438 * @param array|null $attachment Optional attachment data
439 * @return bool|array Success status, or array with success and event_id
440 */
441 private function sendToSentry($data, $item_type = 'event', $attachment = null) {
442 if (metasync_telemetry_is_disabled()) {
443 return false;
444 }
445
446 // Skip sending to Sentry if running on localhost/development environment
447 if ($this->isLocalhost()) {
448 return false;
449 }
450
451 // Get JWT token for authentication
452 if (!function_exists('metasync_get_jwt_token')) {
453 return false;
454 }
455
456 try {
457 $jwt_token = metasync_get_jwt_token();
458 } catch (Exception $e) {
459 return false;
460 } catch (Error $e) {
461 return false;
462 }
463
464 if (empty($jwt_token)) {
465 return false;
466 }
467
468 // Use WordPress Sentry tunnel endpoint
469 $url = 'https://wordpress.telemetry.infra.searchatlas.com/api/4509950439849985/envelope/';
470
471 // Convert Sentry data to envelope format, including attachment if present
472 $envelope = $this->createSentryEnvelope($data, $item_type, $attachment);
473
474 $plugin_version = defined('METASYNC_VERSION') ? METASYNC_VERSION : '1.0.0';
475
476 $headers = [
477 'Authorization' => 'Bearer ' . $jwt_token,
478 'Content-Type' => 'application/x-sentry-envelope',
479 'X-Plugin-Version' => $plugin_version,
480 'User-Agent' => 'WordPress MetaSync Plugin/' . $plugin_version
481 ];
482
483 $response_data = wp_remote_post($url, [
484 'body' => $envelope,
485 'headers' => $headers,
486 'timeout' => 5, // 5 second timeout as requested
487 'sslverify' => true,
488 'user-agent' => 'WordPress MetaSync Plugin/' . $plugin_version,
489 ]);
490 $error = is_wp_error($response_data) ? $response_data->get_error_message() : '';
491 $response_code = is_wp_error($response_data) ? 0 : (int) wp_remote_retrieve_response_code($response_data);
492 $response = is_wp_error($response_data) ? '' : wp_remote_retrieve_body($response_data);
493
494 // Log errors in debug mode for troubleshooting
495 if (defined('WP_DEBUG') && WP_DEBUG && WP_DEBUG_LOG) {
496 if ($error) {
497 error_log(sprintf( // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- debug-gated, no secrets
498 'MetaSync Sentry Error (%s): %s | Response Code: %s | Item Type: %s',
499 $item_type,
500 $error,
501 $response_code,
502 $item_type
503 ));
504 } elseif ($response_code < 200 || $response_code >= 300) {
505 error_log(sprintf( // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- debug-gated, no secrets
506 'MetaSync Sentry HTTP Error (%s): Response Code: %s | Item Type: %s',
507 $item_type,
508 $response_code,
509 $item_type
510 ));
511 }
512 }
513
514 // Return false silently on any error or timeout
515 if ($error) {
516 return false;
517 }
518
519 $success = $response_code >= 200 && $response_code < 300;
520
521 // Parse response to extract event_id if available
522 $event_id = null;
523 if ($success && !empty($response)) {
524 $response_data = json_decode($response, true);
525 if (is_array($response_data) && isset($response_data['id'])) {
526 $event_id = $response_data['id'];
527 }
528 }
529
530 // Return array with success status and event_id if available
531 return [
532 'success' => $success,
533 'event_id' => $event_id
534 ];
535 }
536
537 /**
538 * Create Sentry envelope format from event or feedback data
539 *
540 * @param array $data Sentry event or feedback data
541 * @param string $item_type Type of item: 'event' or 'user_report'
542 * @param array|null $attachment Optional attachment data
543 * @return string Envelope format string
544 */
545 private function createSentryEnvelope($data, $item_type = 'event', $attachment = null) {
546 // Envelope header
547 // Note: When sending to the envelope endpoint directly, we don't include DSN
548 // The project ID is already in the URL path
549 $envelope_header = [
550 'sent_at' => gmdate('c')
551 ];
552
553 // Add event_id to header if present (for events) or if it's in feedback data
554 if ($item_type === 'event' && isset($data['event_id'])) {
555 $envelope_header['event_id'] = $data['event_id'];
556 } elseif ($item_type === 'user_report' && isset($data['event_id'])) {
557 // For user feedback, event_id is in the payload, not header
558 // But we can still include it in header if needed
559 }
560
561 // Item header - determine type based on parameter
562 $item_header = [
563 'type' => $item_type
564 ];
565
566 // Create envelope format: header\nitem_header\nitem_payload
567 $envelope = wp_json_encode($envelope_header) . "\n";
568 $envelope .= wp_json_encode($item_header) . "\n";
569 $envelope .= wp_json_encode($data) . "\n";
570
571 // Add attachment if present
572 if ($attachment && isset($attachment['data']) && isset($attachment['filename'])) {
573 // Attachment item header
574 $attachment_header = [
575 'type' => 'attachment',
576 'length' => strlen($attachment['data']),
577 'filename' => $attachment['filename'],
578 'content_type' => $attachment['content_type'] ?? 'application/octet-stream'
579 ];
580
581 // Add attachment to envelope
582 $envelope .= wp_json_encode($attachment_header) . "\n";
583 $envelope .= $attachment['data'] . "\n";
584 }
585
586 return $envelope;
587 }
588
589 /**
590 * Test the Sentry proxy connection
591 *
592 * @return array Test results
593 */
594 public function testProxyConnection() {
595 $test_data = [
596 'message' => [
597 'message' => 'Sentry proxy connection test',
598 'formatted' => 'Sentry proxy connection test'
599 ],
600 'level' => 'info',
601 'timestamp' => gmdate('c'),
602 'platform' => 'php',
603 'sdk' => [
604 'name' => 'metasync-telemetry-test',
605 'version' => '1.0.0'
606 ]
607 ];
608
609 $result = $this->sendToSentry($test_data);
610 $success = is_array($result) ? $result['success'] : $result;
611
612 return [
613 'success' => $success,
614 'message' => $success ? 'Sentry tunnel connection successful' : 'Sentry tunnel connection failed',
615 'endpoint' => 'https://wordpress.telemetry.infra.searchatlas.com/api/4509950439849985/envelope/',
616 'jwt_available' => function_exists('metasync_get_jwt_token') && !empty(metasync_get_jwt_token())
617 ];
618 }
619
620 /**
621 * Test user feedback submission
622 *
623 * @return array Test results
624 */
625 public function testUserFeedback() {
626 $test_feedback = [
627 'name' => 'Test User',
628 'email' => '[email protected]',
629 'message' => '🧪 Test user feedback submission - ' . gmdate('Y-m-d H:i:s') . ' - This is a test to verify the User Feedback API is working correctly.'
630 ];
631
632 $success = $this->captureFeedback($test_feedback);
633
634 return [
635 'success' => $success,
636 'message' => $success ? 'User feedback test sent successfully' : 'User feedback test failed',
637 'endpoint' => 'https://wordpress.telemetry.infra.searchatlas.com/api/4509950439849985/envelope/',
638 'item_type' => 'user_report',
639 'jwt_available' => function_exists('metasync_get_jwt_token') && !empty(metasync_get_jwt_token())
640 ];
641 }
642
643 /**
644 * Generate Sentry authentication header (legacy - now used for reference only)
645 */
646 private function getSentryAuthHeader() {
647 $timestamp = time();
648 $auth_parts = [
649 'Sentry sentry_version=7',
650 'sentry_client=metasync-wordpress/' . $this->release,
651 'sentry_timestamp=' . $timestamp,
652 'sentry_key=' . $this->public_key
653 ];
654
655 // Note: Modern Sentry DSNs only use public key, no secret key
656 // The secret key is only used for server-side authentication
657 if ($this->secret_key) {
658 $auth_parts[] = 'sentry_secret=' . $this->secret_key;
659 }
660
661 return implode(', ', $auth_parts);
662 }
663
664 /**
665 * Generate unique event ID
666 */
667 private function generateEventId() {
668 return str_replace('-', '', wp_generate_uuid4());
669 }
670
671 /**
672 * Get cached tags for the event (optimized)
673 */
674 private function getTags() {
675 // Cache static tags
676 $cache_key = 'metasync_sentry_tags';
677 $cached_tags = wp_cache_get($cache_key, 'metasync');
678
679 if ($cached_tags !== false) {
680 // Add dynamic tags that change per request
681 $cached_tags['server_name'] = $_SERVER['HTTP_HOST'] ?? 'unknown';
682 return $cached_tags;
683 }
684
685 $tags = [
686 'wp_version' => get_bloginfo('version'),
687 'php_version' => PHP_VERSION,
688 'plugin_version' => $this->release,
689 'environment' => $this->environment,
690 'wp_url' => home_url(),
691 'plugin_name' => 'metasync',
692 // Dynamic tag added per request
693 'server_name' => $_SERVER['HTTP_HOST'] ?? 'unknown'
694 ];
695
696 // Cache for 1 hour
697 wp_cache_set($cache_key, $tags, 'metasync', HOUR_IN_SECONDS);
698
699 return $tags;
700 }
701
702 /**
703 * Get cached system context for Sentry (optimized)
704 */
705 private function getSystemContext() {
706 // Use WordPress object cache to avoid repeated expensive operations
707 $cache_key = 'metasync_system_context';
708 $cached_context = wp_cache_get($cache_key, 'metasync');
709
710 if ($cached_context !== false) {
711 // Add dynamic data that changes per request
712 $cached_context['memory_usage'] = memory_get_usage(true);
713 $cached_context['memory_peak'] = memory_get_peak_usage(true);
714 $cached_context['request_uri'] = metasync_telemetry_request_path();
715 return $cached_context;
716 }
717
718 // Collect static system context (expensive operations)
719 global $wpdb;
720 $context = [
721 'wp_url' => home_url(),
722 'plugin_version' => $this->release,
723 'plugin_name' => 'Search Engine Labs SEO (MetaSync)',
724 'wordpress_version' => get_bloginfo('version'),
725 'site_title' => get_bloginfo('name'),
726 'site_admin_email' => get_bloginfo('admin_email'),
727 'php_version' => PHP_VERSION,
728 'memory_limit' => ini_get('memory_limit'),
729 'max_execution_time' => ini_get('max_execution_time'),
730 'active_plugins' => count(get_option('active_plugins', [])),
731 'active_theme' => get_template(),
732 'multisite' => is_multisite(),
733 'mysql_version' => method_exists($wpdb, 'get_var') ? $wpdb->get_var('SELECT VERSION()') : 'unknown', // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- diagnostic metadata — SELECT VERSION() has no cached WordPress API
734 'server_software' => $_SERVER['SERVER_SOFTWARE'] ?? 'unknown',
735 // Dynamic data added per request
736 'memory_usage' => memory_get_usage(true),
737 'memory_peak' => memory_get_peak_usage(true),
738 'request_uri' => metasync_telemetry_request_path()
739 ];
740
741 // Cache static context for 1 hour
742 wp_cache_set($cache_key, $context, 'metasync', HOUR_IN_SECONDS);
743
744 return $context;
745 }
746
747 /**
748 * Get cached contexts for Sentry (optimized)
749 */
750 private function getContexts() {
751 // Cache static context data
752 $cache_key = 'metasync_sentry_contexts';
753 $cached_contexts = wp_cache_get($cache_key, 'metasync');
754
755 if ($cached_contexts !== false) {
756 return $cached_contexts;
757 }
758
759 $contexts = [
760 'runtime' => [
761 'name' => 'php',
762 'version' => PHP_VERSION
763 ],
764 'os' => [
765 'name' => PHP_OS_FAMILY
766 ],
767 'app' => [
768 'app_name' => 'MetaSync Plugin',
769 'app_version' => $this->release
770 ]
771 ];
772
773 // Cache for 1 hour
774 wp_cache_set($cache_key, $contexts, 'metasync', HOUR_IN_SECONDS);
775
776 return $contexts;
777 }
778
779 /**
780 * Get cached user context (anonymized)
781 */
782 private function getUserContext() {
783 // Cache user context since it's based on static site data
784 $cache_key = 'metasync_user_context';
785 $cached_context = wp_cache_get($cache_key, 'metasync');
786
787 if ($cached_context !== false) {
788 return $cached_context;
789 }
790
791 $context = [
792 'id' => substr(md5(home_url() . get_bloginfo('name')), 0, 16),
793 'ip_address' => '{{auto}}' // Let Sentry handle IP detection and anonymization
794 ];
795
796 // Cache for 1 hour
797 wp_cache_set($cache_key, $context, 'metasync', HOUR_IN_SECONDS);
798
799 return $context;
800 }
801
802 /**
803 * Detect current environment
804 */
805 private function detectEnvironment() {
806
807 if (defined('WP_DEBUG') && WP_DEBUG) {
808 return 'development';
809 }
810
811 // Check if running on localhost/development environment
812 if ($this->isLocalhost()) {
813 return 'development';
814 }
815
816 $host = wp_parse_url(home_url(), PHP_URL_HOST);
817 if ($host !== false && $host !== null && (strpos($host, 'staging') !== false || strpos($host, 'dev') !== false)) {
818 return 'staging';
819 }
820
821 return 'production';
822 }
823
824 /**
825 * Normalize log level for Sentry
826 */
827 private function normalizeLevel($level) {
828 $levels = ['debug', 'info', 'warning', 'error', 'fatal'];
829 return in_array($level, $levels) ? $level : 'info';
830 }
831
832 /**
833 * Check if stack frame is in application code
834 */
835 private function isInApp($frame) {
836 if (!isset($frame['file'])) {
837 return false;
838 }
839
840 $wp_content_dir = defined('WP_CONTENT_DIR') ? WP_CONTENT_DIR : ABSPATH . 'wp-content';
841 return strpos($frame['file'], $wp_content_dir) !== false;
842 }
843
844 /**
845 * Test the connection to Sentry
846 */
847 public function testConnection() {
848 $test_data = $this->formatMessage('🧪 Sentry connection test', 'info', [
849 'test' => true,
850 'timestamp' => time(),
851 'source' => 'connection_test'
852 ]);
853
854 $result = $this->sendToSentry($test_data);
855 $success = is_array($result) ? $result['success'] : $result;
856
857 return [
858 'success' => $success,
859 'dsn_configured' => !empty($this->dsn),
860 'project_id' => $this->project_id,
861 'environment' => $this->environment,
862 'release' => $this->release
863 ];
864 }
865 }
866
867 /**
868 * Global Sentry instance
869 */
870 global $metasync_sentry_wordpress;
871 $metasync_sentry_wordpress = null;
872
873 /**
874 * Initialize Sentry with DSN configuration
875 */
876 function init_metasync_sentry_wordpress() {
877 global $metasync_sentry_wordpress;
878
879 if (metasync_telemetry_is_disabled()) {
880 return null;
881 }
882
883 $dsn = '';
884
885 // Use constants defined in metasync.php for configuration
886 if (defined('METASYNC_SENTRY_PROJECT_ID')) {
887 // Create proxy DSN format using the project ID constant
888 $dsn = 'proxy://' . METASYNC_SENTRY_PROJECT_ID;
889 } else {
890 // Fallback: Check wp-config.php for custom DSN (for developers)
891 if (defined('METASYNC_SENTRY_DSN')) {
892 $dsn = METASYNC_SENTRY_DSN;
893 }
894 }
895
896 if (!empty($dsn)) {
897 try {
898 $metasync_sentry_wordpress = new MetaSync_Sentry_WordPress($dsn);
899 return $metasync_sentry_wordpress;
900 } catch (Exception $e) {
901 return null;
902 }
903 } else {
904 return null;
905 }
906 }
907
908 /**
909 * Helper function to capture exceptions
910 */
911 function metasync_sentry_capture_exception($exception, $extra = []) {
912 global $metasync_sentry_wordpress;
913 if (metasync_telemetry_is_disabled()) {
914 return false;
915 }
916 if (!$metasync_sentry_wordpress) {
917 $metasync_sentry_wordpress = init_metasync_sentry_wordpress();
918 }
919
920 if ($metasync_sentry_wordpress) {
921 return $metasync_sentry_wordpress->captureException($exception, $extra);
922 }
923 return false;
924 }
925
926 /**
927 * Helper function to capture messages
928 */
929 function metasync_sentry_capture_message($message, $level = 'info', $extra = [], $attachment = null) {
930 global $metasync_sentry_wordpress;
931 if (metasync_telemetry_is_disabled()) {
932 return false;
933 }
934 if (!$metasync_sentry_wordpress) {
935 $metasync_sentry_wordpress = init_metasync_sentry_wordpress();
936 }
937
938 if ($metasync_sentry_wordpress) {
939 return $metasync_sentry_wordpress->captureMessage($message, $level, $extra, $attachment);
940 }
941 return false;
942 }
943
944 /**
945 * Helper function to capture messages without blocking the current request.
946 *
947 * Use this instead of metasync_sentry_capture_message() from anything that runs
948 * on a visitor's page render — the blocking variant can hold the request for up
949 * to ten seconds if the collector is slow. See
950 * MetaSync_Sentry_WordPress::captureMessageNonBlocking() for the trade-off.
951 */
952 function metasync_sentry_capture_message_nonblocking($message, $level = 'info', $extra = []) {
953 global $metasync_sentry_wordpress;
954 if (metasync_telemetry_is_disabled()) {
955 return false;
956 }
957 if (!$metasync_sentry_wordpress) {
958 $metasync_sentry_wordpress = init_metasync_sentry_wordpress();
959 }
960
961 if ($metasync_sentry_wordpress) {
962 return $metasync_sentry_wordpress->captureMessageNonBlocking($message, $level, $extra);
963 }
964 return false;
965 }
966
967 /**
968 * Helper function to capture user feedback
969 *
970 * @param array $feedback Feedback data with keys: name (optional), email (optional), message (required), event_id (optional)
971 * @return bool Success status
972 */
973 function metasync_sentry_capture_feedback($feedback, $attachment = null) {
974 global $metasync_sentry_wordpress;
975 if (metasync_telemetry_is_disabled()) {
976 return false;
977 }
978 if (!$metasync_sentry_wordpress) {
979 $metasync_sentry_wordpress = init_metasync_sentry_wordpress();
980 }
981
982 if ($metasync_sentry_wordpress) {
983 return $metasync_sentry_wordpress->captureFeedback($feedback, $attachment);
984 }
985 return false;
986 }
987
988 /**
989 * Test Sentry connection
990 */
991 function metasync_sentry_test_connection() {
992 global $metasync_sentry_wordpress;
993 if (!$metasync_sentry_wordpress) {
994 $metasync_sentry_wordpress = init_metasync_sentry_wordpress();
995 }
996
997 if ($metasync_sentry_wordpress) {
998 // Use the new proxy test method if available
999 if (method_exists($metasync_sentry_wordpress, 'testProxyConnection')) {
1000 return $metasync_sentry_wordpress->testProxyConnection();
1001 }
1002 // Fallback to legacy method
1003 return $metasync_sentry_wordpress->testConnection();
1004 }
1005
1006 return [
1007 'success' => false,
1008 'error' => 'Sentry not initialized. Check DSN configuration.'
1009 ];
1010 }
1011
1012 /**
1013 * Test user feedback submission
1014 *
1015 * @return array Test results
1016 */
1017 function metasync_sentry_test_user_feedback() {
1018 global $metasync_sentry_wordpress;
1019 if (!$metasync_sentry_wordpress) {
1020 $metasync_sentry_wordpress = init_metasync_sentry_wordpress();
1021 }
1022
1023 if ($metasync_sentry_wordpress) {
1024 if (method_exists($metasync_sentry_wordpress, 'testUserFeedback')) {
1025 return $metasync_sentry_wordpress->testUserFeedback();
1026 }
1027 }
1028
1029 return [
1030 'success' => false,
1031 'error' => 'Sentry not initialized or test method not available.'
1032 ];
1033 }
1034
1035 // Auto-initialize when file is loaded
1036 init_metasync_sentry_wordpress();
1037 ?>
1038