PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.6.22
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.6.22
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 2.6.4 2.6.3 2.5.23 All 138 releases
metasync / telemetry / sentry-wordpress-integration.php

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

1,022 lines 37.8 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 /**
8 * WordPress-Native Sentry Integration
9 *
10 * Uses WordPress HTTP API to send data directly to Sentry
11 * Compatible with PHP 7.1+ and WordPress 5.0+
12 *
13 * This is the OFFICIAL way to use Sentry without Composer
14 */
15 class MetaSync_Sentry_WordPress {
16
17 private $dsn;
18 private $options;
19 private $environment;
20 private $release;
21 private $public_key;
22 private $secret_key;
23 private $project_id;
24 private $host;
25 private $scheme;
26
27 public function __construct($dsn, $options = []) {
28 $this->dsn = $dsn;
29 $this->options = $options;
30
31 // Use constants for configuration instead of detecting/parsing
32 $this->environment = defined('METASYNC_SENTRY_ENVIRONMENT') ? METASYNC_SENTRY_ENVIRONMENT : $this->detectEnvironment();
33 $this->release = defined('METASYNC_SENTRY_RELEASE') ? METASYNC_SENTRY_RELEASE : (defined('METASYNC_VERSION') ? METASYNC_VERSION : '1.0.0');
34
35 // Handle proxy DSN format (proxy://project_id) or legacy DSN
36 if (strpos($dsn, 'proxy://') === 0) {
37 // New proxy format: proxy://project_id
38 $this->project_id = str_replace('proxy://', '', $dsn);
39 $this->public_key = null; // Not needed for proxy
40 $this->secret_key = null; // Not needed for proxy
41 $this->host = null; // Proxy handles this
42 $this->scheme = 'proxy';
43 } else {
44 // Legacy DSN format for backward compatibility
45 $parsed = parse_url($this->dsn);
46 $this->public_key = isset($parsed['user']) ? $parsed['user'] : null;
47 $this->secret_key = isset($parsed['pass']) ? $parsed['pass'] : null;
48 $this->project_id = trim($parsed['path'], '/');
49 $this->host = isset($parsed['host']) ? $parsed['host'] : null;
50 $this->scheme = isset($parsed['scheme']) ? $parsed['scheme'] : 'https';
51 }
52 }
53
54 /**
55 * Capture an exception and send to Sentry
56 */
57 public function captureException($exception, $extra = []) {
58 $data = $this->formatException($exception, $extra);
59 $result = $this->sendToSentry($data);
60 return is_array($result) ? $result['success'] : $result;
61 }
62
63 /**
64 * Capture a message and send to Sentry
65 */
66 public function captureMessage($message, $level = 'info', $extra = [], $attachment = null) {
67 $data = $this->formatMessage($message, $level, $extra);
68 $result = $this->sendToSentry($data, 'event', $attachment);
69 return is_array($result) ? $result['success'] : $result;
70 }
71
72 /**
73 * Capture a message without waiting for Sentry to answer.
74 *
75 * captureMessage() blocks: sendToSentry() uses cURL with a 5s connect and 5s
76 * read timeout, so a slow or unreachable collector can add up to ten seconds
77 * to whatever request called it. That is acceptable for admin-side and cron
78 * work, but not on a visitor's page render — least of all when the reason we
79 * are reporting is that the request is already degraded.
80 *
81 * This variant hands the envelope to WordPress's HTTP API with
82 * 'blocking' => false, so the request is dispatched and the caller continues
83 * immediately. The trade-off is that delivery is unconfirmed and never
84 * retried: a dropped report is silently lost. For counting how often
85 * something happens that is a fair exchange for not touching page-load time.
86 *
87 * The token is read from cache only. metasync_get_jwt_token() falls through to
88 * a fresh fetch on a cache miss — a blocking POST with a 15 second timeout —
89 * which would defeat the whole point of this method and land that cost on an
90 * already-degraded request. With no cached token the report is skipped; the
91 * next admin or cron request repopulates the cache.
92 *
93 * @param string $message Message to record.
94 * @param string $level Sentry level (info|warning|error|fatal).
95 * @param array $extra Additional context.
96 * @return bool True if a request was dispatched, false if it could not be.
97 */
98 public function captureMessageNonBlocking($message, $level = 'info', $extra = []) {
99 # Mirrors sendToSentry()'s preconditions.
100 if ($this->isLocalhost()) {
101 return false;
102 }
103
104 try {
105 # method_exists() is redundant to static analysis — this MR adds the
106 # method, so PHPStan proves the call always true. Kept for the upgrade
107 # window: during a plugin update an opcache can still hold the previous
108 # Metasync_Connect_Manager, where class_exists() passes but the
109 # cache-only accessor is absent. Falling through to the else branch is
110 # the safe outcome there; an unguarded call would fatal.
111 # @phpstan-ignore-next-line function.alreadyNarrowedType
112 if (class_exists('Metasync_Connect_Manager') && method_exists('Metasync_Connect_Manager', 'get_cached_jwt_token')) {
113 $jwt_token = Metasync_Connect_Manager::get_cached_jwt_token();
114 } else {
115 # No cache-only accessor available — skip rather than risk the
116 # blocking fetch path.
117 return false;
118 }
119 } catch (Exception $e) {
120 return false;
121 } catch (Error $e) {
122 return false;
123 }
124
125 if (empty($jwt_token)) {
126 return false;
127 }
128
129 $data = $this->formatMessage($message, $level, $extra);
130 $envelope = $this->createSentryEnvelope($data, 'event', null);
131
132 if (empty($envelope)) {
133 return false;
134 }
135
136 $plugin_version = defined('METASYNC_VERSION') ? METASYNC_VERSION : '1.0.0';
137
138 # Same tunnel endpoint sendToSentry() posts to.
139 $url = 'https://wordpress.telemetry.infra.searchatlas.com/api/4509950439849985/envelope/';
140
141 wp_remote_post($url, [
142 'blocking' => false,
143 'timeout' => 0.01,
144 'sslverify' => true,
145 'headers' => [
146 'Authorization' => 'Bearer ' . $jwt_token,
147 'Content-Type' => 'application/x-sentry-envelope',
148 'X-Plugin-Version' => $plugin_version,
149 'User-Agent' => 'WordPress MetaSync Plugin/' . $plugin_version,
150 ],
151 'body' => $envelope,
152 ]);
153
154 return true;
155 }
156
157 /**
158 * Capture user feedback and send to Sentry
159 *
160 * Since Sentry requires user feedback to be associated with an event,
161 * this method first creates an event with the feedback message, then
162 * associates the feedback with that event.
163 *
164 * @param array $feedback Feedback data with keys: name (optional), email (optional), message (required), event_id (optional), severity (optional)
165 * @param array|null $attachment Optional attachment data
166 * @return bool|array Success status, or array with success and event_id
167 */
168 public function captureFeedback($feedback, $attachment = null) {
169 // Get the feedback message
170 $message = '';
171 if (isset($feedback['message']) && !empty($feedback['message'])) {
172 $message = $feedback['message'];
173 } elseif (isset($feedback['comments']) && !empty($feedback['comments'])) {
174 $message = $feedback['comments'];
175 }
176
177 if (empty($message)) {
178 return false;
179 }
180
181 // Get severity level if provided
182 $severity = isset($feedback['severity']) ? sanitize_text_field($feedback['severity']) : '';
183 $valid_severity_levels = array('info', 'warning', 'error', 'fatal');
184
185 // Get otto_pixel_uuid from general options (same way as used in admin handler)
186 $general_options = class_exists('Metasync') ? Metasync::get_option('general') : get_option('metasync_options', [])['general'] ?? [];
187 if (!is_array($general_options)) {
188 $general_options = [];
189 }
190 $project_uuid = isset($general_options['otto_pixel_uuid']) ? sanitize_text_field($general_options['otto_pixel_uuid']) : '';
191
192 // Format event title as "Client Report {UUID}"
193 $event_title = !empty($project_uuid) ? 'Client Report ' . $project_uuid : 'Client Report (UUID Not Configured)';
194
195 // Build the message with title, severity, and user description
196 // Format: "Client Report {uuid}\nSeverity: {level}\n\n{user description}"
197 $formatted_message = $event_title;
198 if (!empty($severity) && in_array($severity, $valid_severity_levels, true)) {
199 $severity_label = ucfirst($severity);
200 $formatted_message .= "\nSeverity: {$severity_label}";
201 }
202
203 // Add attachment indicator if an attachment is present
204 if ($attachment && !empty($attachment['filename'])) {
205 $formatted_message .= "\nAttachment: " . $attachment['filename'];
206 }
207
208 $formatted_message .= "\n\n" . $message;
209
210 // If event_id is already provided, use it directly
211 if (isset($feedback['event_id']) && !empty($feedback['event_id'])) {
212 // Update the feedback message with formatted message (title + severity + description)
213 $feedback['message'] = $formatted_message;
214 $data = $this->formatFeedback($feedback);
215 $result = $this->sendToSentry($data, 'user_report', $attachment);
216 return is_array($result) ? $result['success'] : $result;
217 }
218
219 // Determine event level based on severity
220 $event_level = !empty($severity) && in_array($severity, $valid_severity_levels, true) ? $severity : 'info';
221
222 // Create an event with the formatted message (title + severity + user description)
223 // The title will also be set in culprit field for Sentry to display
224 $event_data = $this->formatMessage($formatted_message, $event_level, [
225 'feedback_source' => 'user_report',
226 'original_feedback' => $feedback,
227 'report_title' => $event_title
228 ]);
229
230 // Set the culprit (title) field for Sentry to display as the event title
231 // The culprit field is what Sentry uses to show the title in the issues list
232 $event_data['culprit'] = $event_title;
233
234 // Add the category tag
235 if (isset($event_data['tags']) && is_array($event_data['tags'])) {
236 $event_data['tags']['category'] = 'user-feedback';
237 } else {
238 $event_data['tags'] = ['category' => 'user-feedback'];
239 }
240
241 // Send the event first with attachment
242 $event_result = $this->sendToSentry($event_data, 'event', $attachment);
243
244 // Extract event_id from response
245 $event_id = null;
246 if (is_array($event_result) && isset($event_result['event_id'])) {
247 $event_id = $event_result['event_id'];
248 } elseif (is_array($event_result) && $event_result['success']) {
249 // If event was sent but no event_id in response, use the one we generated
250 $event_id = $event_data['event_id'] ?? null;
251 }
252
253 // If event creation failed, return false
254 if (!$event_id) {
255 return false;
256 }
257
258 // Now send the feedback with the event_id (no attachment on feedback, it's already on the event)
259 // Make sure the feedback message includes title, severity, and user description
260 $feedback['event_id'] = $event_id;
261 $feedback['message'] = $formatted_message; // Use the formatted message (title + severity + description)
262 $data = $this->formatFeedback($feedback);
263 $feedback_result = $this->sendToSentry($data, 'user_report');
264
265 return is_array($feedback_result) ? $feedback_result['success'] : $feedback_result;
266 }
267
268 /**
269 * Format exception data for Sentry API
270 */
271 private function formatException($exception, $extra = []) {
272 $trace = [];
273 if (is_object($exception) && method_exists($exception, 'getTrace')) {
274 foreach ($exception->getTrace() as $frame) {
275 $trace[] = [
276 'filename' => isset($frame['file']) ? $frame['file'] : '<unknown>',
277 'lineno' => isset($frame['line']) ? $frame['line'] : 0,
278 'function' => isset($frame['function']) ? $frame['function'] : '<unknown>',
279 'module' => isset($frame['class']) ? $frame['class'] : null,
280 'in_app' => $this->isInApp($frame)
281 ];
282 }
283 }
284
285 return [
286 'event_id' => $this->generateEventId(),
287 'timestamp' => gmdate('Y-m-d\TH:i:s\Z'),
288 'level' => 'error',
289 'platform' => 'php',
290 'sdk' => [
291 'name' => 'metasync-wordpress-sentry',
292 'version' => $this->release
293 ],
294 'server_name' => $_SERVER['HTTP_HOST'] ?? 'unknown',
295 'release' => $this->release,
296 'environment' => $this->environment,
297 'exception' => [
298 'values' => [
299 [
300 'type' => is_object($exception) ? get_class($exception) : 'Error',
301 'value' => is_object($exception) ? $exception->getMessage() : (string)$exception,
302 'stacktrace' => ['frames' => array_reverse($trace)]
303 ]
304 ]
305 ],
306 'tags' => $this->getTags(),
307 'extra' => array_merge($this->getSystemContext(), $extra),
308 'user' => $this->getUserContext(),
309 'contexts' => $this->getContexts()
310 ];
311 }
312
313 /**
314 * Format message data for Sentry API
315 */
316 private function formatMessage($message, $level, $extra = []) {
317 return [
318 'event_id' => $this->generateEventId(),
319 'timestamp' => gmdate('Y-m-d\TH:i:s\Z'),
320 'level' => $this->normalizeLevel($level),
321 'platform' => 'php',
322 'sdk' => [
323 'name' => 'metasync-wordpress-sentry',
324 'version' => $this->release
325 ],
326 'server_name' => $_SERVER['HTTP_HOST'] ?? 'unknown',
327 'release' => $this->release,
328 'environment' => $this->environment,
329 'message' => [
330 'message' => $message
331 ],
332 'tags' => $this->getTags(),
333 'extra' => array_merge($this->getSystemContext(), $extra),
334 'user' => $this->getUserContext(),
335 'contexts' => $this->getContexts()
336 ];
337 }
338
339 /**
340 * Format user feedback data for Sentry User Feedback API
341 *
342 * @param array $feedback Feedback data with keys: name, email, message (or comments), event_id
343 * @return array Formatted feedback data
344 */
345 private function formatFeedback($feedback) {
346 // Support both 'message' (JavaScript SDK API) and 'comments' (envelope format)
347 // The JavaScript SDK accepts 'message' but converts it to 'comments' in the envelope
348 $comments = '';
349 if (isset($feedback['message']) && !empty($feedback['message'])) {
350 $comments = $feedback['message'];
351 } elseif (isset($feedback['comments']) && !empty($feedback['comments'])) {
352 $comments = $feedback['comments'];
353 }
354
355 // Validate required fields
356 if (empty($comments)) {
357 throw new InvalidArgumentException('Message/comments field is required for user feedback');
358 }
359
360 // Build feedback payload according to Sentry User Feedback API envelope format
361 // Reference: https://docs.sentry.io/platforms/javascript/user-feedback/#user-feedback-api
362 // IMPORTANT: The envelope format uses 'comments' as the key (not 'message')
363 // The JavaScript SDK accepts 'message' but converts it to 'comments' internally
364 $feedback_data = [
365 'comments' => sanitize_textarea_field($comments)
366 ];
367
368 // Add optional name field
369 if (isset($feedback['name']) && !empty(trim($feedback['name']))) {
370 $feedback_data['name'] = sanitize_text_field($feedback['name']);
371 }
372
373 // Add optional email field
374 if (isset($feedback['email']) && !empty(trim($feedback['email']))) {
375 $feedback_data['email'] = sanitize_email($feedback['email']);
376 }
377
378 // Add event_id if provided (to associate feedback with an event)
379 // Note: In envelope format, event_id can be in the payload
380 if (isset($feedback['event_id']) && !empty($feedback['event_id'])) {
381 $feedback_data['event_id'] = sanitize_text_field($feedback['event_id']);
382 }
383
384 return $feedback_data;
385 }
386
387 /**
388 * Check if the current environment is localhost/development
389 */
390 private function isLocalhost() {
391 $host = parse_url(home_url(), PHP_URL_HOST);
392
393 // Check for common localhost patterns
394 $localhost_patterns = [
395 'localhost',
396 '127.0.0.1',
397 '::1',
398 '0.0.0.0',
399 '.local',
400 '.test',
401 '.dev',
402 '.localhost'
403 ];
404
405 foreach ($localhost_patterns as $pattern) {
406 if (strpos($host, $pattern) !== false) {
407 return true;
408 }
409 }
410
411 // Check if host is an IP address in private ranges
412 if (filter_var($host, FILTER_VALIDATE_IP)) {
413 $ip = ip2long($host);
414 if ($ip !== false) {
415 // Private IP ranges: 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16
416 if (($ip >= ip2long('10.0.0.0') && $ip <= ip2long('10.255.255.255')) ||
417 ($ip >= ip2long('172.16.0.0') && $ip <= ip2long('172.31.255.255')) ||
418 ($ip >= ip2long('192.168.0.0') && $ip <= ip2long('192.168.255.255'))) {
419 return true;
420 }
421 }
422 }
423
424 return false;
425 }
426
427 /**
428 * Send data to Sentry API using WordPress HTTP functions (proxied through our system with JWT)
429 *
430 * @param array $data Sentry event or feedback data
431 * @param string $item_type Type of item: 'event' or 'user_report'
432 * @param array|null $attachment Optional attachment data
433 * @return bool|array Success status, or array with success and event_id
434 */
435 private function sendToSentry($data, $item_type = 'event', $attachment = null) {
436 // Skip sending to Sentry if running on localhost/development environment
437 if ($this->isLocalhost()) {
438 return false;
439 }
440
441 // Get JWT token for authentication
442 if (!function_exists('metasync_get_jwt_token')) {
443 return false;
444 }
445
446 try {
447 $jwt_token = metasync_get_jwt_token();
448 } catch (Exception $e) {
449 return false;
450 } catch (Error $e) {
451 return false;
452 }
453
454 if (empty($jwt_token)) {
455 return false;
456 }
457
458 // Use WordPress Sentry tunnel endpoint
459 $url = 'https://wordpress.telemetry.infra.searchatlas.com/api/4509950439849985/envelope/';
460
461 // Convert Sentry data to envelope format, including attachment if present
462 $envelope = $this->createSentryEnvelope($data, $item_type, $attachment);
463
464 $plugin_version = defined('METASYNC_VERSION') ? METASYNC_VERSION : '1.0.0';
465
466 $headers = [
467 'Authorization' => 'Bearer ' . $jwt_token,
468 'Content-Type' => 'application/x-sentry-envelope',
469 'X-Plugin-Version' => $plugin_version,
470 'User-Agent' => 'WordPress MetaSync Plugin/' . $plugin_version
471 ];
472
473 // Use cURL directly to ensure proper envelope format
474 $ch = curl_init();
475 curl_setopt($ch, CURLOPT_URL, $url);
476 curl_setopt($ch, CURLOPT_POST, true);
477 curl_setopt($ch, CURLOPT_POSTFIELDS, $envelope);
478 curl_setopt($ch, CURLOPT_HTTPHEADER, array_map(function($key, $value) {
479 return $key . ': ' . $value;
480 }, array_keys($headers), $headers));
481 curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
482 curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
483 curl_setopt($ch, CURLOPT_TIMEOUT, 5); // 5 second timeout as requested
484 curl_setopt($ch, CURLOPT_USERAGENT, 'WordPress MetaSync Plugin/' . $plugin_version);
485 curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 5); // 5 second connection timeout
486
487 $response = curl_exec($ch);
488 $response_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
489 $error = curl_error($ch);
490
491 #curl_close($ch);
492
493 // Log errors in debug mode for troubleshooting
494 if (defined('WP_DEBUG') && WP_DEBUG && WP_DEBUG_LOG) {
495 if ($error) {
496 error_log(sprintf(
497 'MetaSync Sentry Error (%s): %s | Response Code: %s | Item Type: %s',
498 $item_type,
499 $error,
500 $response_code,
501 $item_type
502 ));
503 } elseif ($response_code < 200 || $response_code >= 300) {
504 error_log(sprintf(
505 'MetaSync Sentry HTTP Error (%s): Response Code: %s | Response: %s | Item Type: %s',
506 $item_type,
507 $response_code,
508 substr($response, 0, 200),
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' => 'test@example.com',
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'] = $_SERVER['REQUEST_URI'] ?? 'unknown';
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',
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' => $_SERVER['REQUEST_URI'] ?? 'unknown'
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 = parse_url(home_url(), PHP_URL_HOST);
817 if (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 $dsn = '';
880
881 // Use constants defined in metasync.php for configuration
882 if (defined('METASYNC_SENTRY_PROJECT_ID')) {
883 // Create proxy DSN format using the project ID constant
884 $dsn = 'proxy://' . METASYNC_SENTRY_PROJECT_ID;
885 } else {
886 // Fallback: Check wp-config.php for custom DSN (for developers)
887 if (defined('METASYNC_SENTRY_DSN')) {
888 $dsn = METASYNC_SENTRY_DSN;
889 }
890 }
891
892 if (!empty($dsn)) {
893 try {
894 $metasync_sentry_wordpress = new MetaSync_Sentry_WordPress($dsn);
895 return $metasync_sentry_wordpress;
896 } catch (Exception $e) {
897 return null;
898 }
899 } else {
900 return null;
901 }
902 }
903
904 /**
905 * Helper function to capture exceptions
906 */
907 function metasync_sentry_capture_exception($exception, $extra = []) {
908 global $metasync_sentry_wordpress;
909 if (!$metasync_sentry_wordpress) {
910 $metasync_sentry_wordpress = init_metasync_sentry_wordpress();
911 }
912
913 if ($metasync_sentry_wordpress) {
914 return $metasync_sentry_wordpress->captureException($exception, $extra);
915 }
916 return false;
917 }
918
919 /**
920 * Helper function to capture messages
921 */
922 function metasync_sentry_capture_message($message, $level = 'info', $extra = [], $attachment = null) {
923 global $metasync_sentry_wordpress;
924 if (!$metasync_sentry_wordpress) {
925 $metasync_sentry_wordpress = init_metasync_sentry_wordpress();
926 }
927
928 if ($metasync_sentry_wordpress) {
929 return $metasync_sentry_wordpress->captureMessage($message, $level, $extra, $attachment);
930 }
931 return false;
932 }
933
934 /**
935 * Helper function to capture messages without blocking the current request.
936 *
937 * Use this instead of metasync_sentry_capture_message() from anything that runs
938 * on a visitor's page render — the blocking variant can hold the request for up
939 * to ten seconds if the collector is slow. See
940 * MetaSync_Sentry_WordPress::captureMessageNonBlocking() for the trade-off.
941 */
942 function metasync_sentry_capture_message_nonblocking($message, $level = 'info', $extra = []) {
943 global $metasync_sentry_wordpress;
944 if (!$metasync_sentry_wordpress) {
945 $metasync_sentry_wordpress = init_metasync_sentry_wordpress();
946 }
947
948 if ($metasync_sentry_wordpress) {
949 return $metasync_sentry_wordpress->captureMessageNonBlocking($message, $level, $extra);
950 }
951 return false;
952 }
953
954 /**
955 * Helper function to capture user feedback
956 *
957 * @param array $feedback Feedback data with keys: name (optional), email (optional), message (required), event_id (optional)
958 * @return bool Success status
959 */
960 function metasync_sentry_capture_feedback($feedback, $attachment = null) {
961 global $metasync_sentry_wordpress;
962 if (!$metasync_sentry_wordpress) {
963 $metasync_sentry_wordpress = init_metasync_sentry_wordpress();
964 }
965
966 if ($metasync_sentry_wordpress) {
967 return $metasync_sentry_wordpress->captureFeedback($feedback, $attachment);
968 }
969 return false;
970 }
971
972 /**
973 * Test Sentry connection
974 */
975 function metasync_sentry_test_connection() {
976 global $metasync_sentry_wordpress;
977 if (!$metasync_sentry_wordpress) {
978 $metasync_sentry_wordpress = init_metasync_sentry_wordpress();
979 }
980
981 if ($metasync_sentry_wordpress) {
982 // Use the new proxy test method if available
983 if (method_exists($metasync_sentry_wordpress, 'testProxyConnection')) {
984 return $metasync_sentry_wordpress->testProxyConnection();
985 }
986 // Fallback to legacy method
987 return $metasync_sentry_wordpress->testConnection();
988 }
989
990 return [
991 'success' => false,
992 'error' => 'Sentry not initialized. Check DSN configuration.'
993 ];
994 }
995
996 /**
997 * Test user feedback submission
998 *
999 * @return array Test results
1000 */
1001 function metasync_sentry_test_user_feedback() {
1002 global $metasync_sentry_wordpress;
1003 if (!$metasync_sentry_wordpress) {
1004 $metasync_sentry_wordpress = init_metasync_sentry_wordpress();
1005 }
1006
1007 if ($metasync_sentry_wordpress) {
1008 if (method_exists($metasync_sentry_wordpress, 'testUserFeedback')) {
1009 return $metasync_sentry_wordpress->testUserFeedback();
1010 }
1011 }
1012
1013 return [
1014 'success' => false,
1015 'error' => 'Sentry not initialized or test method not available.'
1016 ];
1017 }
1018
1019 // Auto-initialize when file is loaded
1020 init_metasync_sentry_wordpress();
1021 ?>
1022