PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.6.26
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.6.26
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.26, at telemetry/sentry-wordpress-integration.php

1,048 lines 38.4 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 = parse_url($this->dsn);
48 $this->public_key = isset($parsed['user']) ? $parsed['user'] : null;
49 $this->secret_key = isset($parsed['pass']) ? $parsed['pass'] : null;
50 $this->project_id = trim($parsed['path'], '/');
51 $this->host = isset($parsed['host']) ? $parsed['host'] : null;
52 $this->scheme = 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 = 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 (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 // Use cURL directly to ensure proper envelope format
484 $ch = curl_init();
485 curl_setopt($ch, CURLOPT_URL, $url);
486 curl_setopt($ch, CURLOPT_POST, true);
487 curl_setopt($ch, CURLOPT_POSTFIELDS, $envelope);
488 curl_setopt($ch, CURLOPT_HTTPHEADER, array_map(function($key, $value) {
489 return $key . ': ' . $value;
490 }, array_keys($headers), $headers));
491 curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
492 curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
493 curl_setopt($ch, CURLOPT_TIMEOUT, 5); // 5 second timeout as requested
494 curl_setopt($ch, CURLOPT_USERAGENT, 'WordPress MetaSync Plugin/' . $plugin_version);
495 curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 5); // 5 second connection timeout
496
497 $response = curl_exec($ch);
498 $response_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
499 $error = curl_error($ch);
500
501 #curl_close($ch);
502
503 // Log errors in debug mode for troubleshooting
504 if (defined('WP_DEBUG') && WP_DEBUG && WP_DEBUG_LOG) {
505 if ($error) {
506 error_log(sprintf(
507 'MetaSync Sentry Error (%s): %s | Response Code: %s | Item Type: %s',
508 $item_type,
509 $error,
510 $response_code,
511 $item_type
512 ));
513 } elseif ($response_code < 200 || $response_code >= 300) {
514 error_log(sprintf(
515 'MetaSync Sentry HTTP Error (%s): Response Code: %s | Response: %s | Item Type: %s',
516 $item_type,
517 $response_code,
518 substr($response, 0, 200),
519 $item_type
520 ));
521 }
522 }
523
524 // Return false silently on any error or timeout
525 if ($error) {
526 return false;
527 }
528
529 $success = $response_code >= 200 && $response_code < 300;
530
531 // Parse response to extract event_id if available
532 $event_id = null;
533 if ($success && !empty($response)) {
534 $response_data = json_decode($response, true);
535 if (is_array($response_data) && isset($response_data['id'])) {
536 $event_id = $response_data['id'];
537 }
538 }
539
540 // Return array with success status and event_id if available
541 return [
542 'success' => $success,
543 'event_id' => $event_id
544 ];
545 }
546
547 /**
548 * Create Sentry envelope format from event or feedback data
549 *
550 * @param array $data Sentry event or feedback data
551 * @param string $item_type Type of item: 'event' or 'user_report'
552 * @param array|null $attachment Optional attachment data
553 * @return string Envelope format string
554 */
555 private function createSentryEnvelope($data, $item_type = 'event', $attachment = null) {
556 // Envelope header
557 // Note: When sending to the envelope endpoint directly, we don't include DSN
558 // The project ID is already in the URL path
559 $envelope_header = [
560 'sent_at' => gmdate('c')
561 ];
562
563 // Add event_id to header if present (for events) or if it's in feedback data
564 if ($item_type === 'event' && isset($data['event_id'])) {
565 $envelope_header['event_id'] = $data['event_id'];
566 } elseif ($item_type === 'user_report' && isset($data['event_id'])) {
567 // For user feedback, event_id is in the payload, not header
568 // But we can still include it in header if needed
569 }
570
571 // Item header - determine type based on parameter
572 $item_header = [
573 'type' => $item_type
574 ];
575
576 // Create envelope format: header\nitem_header\nitem_payload
577 $envelope = wp_json_encode($envelope_header) . "\n";
578 $envelope .= wp_json_encode($item_header) . "\n";
579 $envelope .= wp_json_encode($data) . "\n";
580
581 // Add attachment if present
582 if ($attachment && isset($attachment['data']) && isset($attachment['filename'])) {
583 // Attachment item header
584 $attachment_header = [
585 'type' => 'attachment',
586 'length' => strlen($attachment['data']),
587 'filename' => $attachment['filename'],
588 'content_type' => $attachment['content_type'] ?? 'application/octet-stream'
589 ];
590
591 // Add attachment to envelope
592 $envelope .= wp_json_encode($attachment_header) . "\n";
593 $envelope .= $attachment['data'] . "\n";
594 }
595
596 return $envelope;
597 }
598
599 /**
600 * Test the Sentry proxy connection
601 *
602 * @return array Test results
603 */
604 public function testProxyConnection() {
605 $test_data = [
606 'message' => [
607 'message' => 'Sentry proxy connection test',
608 'formatted' => 'Sentry proxy connection test'
609 ],
610 'level' => 'info',
611 'timestamp' => gmdate('c'),
612 'platform' => 'php',
613 'sdk' => [
614 'name' => 'metasync-telemetry-test',
615 'version' => '1.0.0'
616 ]
617 ];
618
619 $result = $this->sendToSentry($test_data);
620 $success = is_array($result) ? $result['success'] : $result;
621
622 return [
623 'success' => $success,
624 'message' => $success ? 'Sentry tunnel connection successful' : 'Sentry tunnel connection failed',
625 'endpoint' => 'https://wordpress.telemetry.infra.searchatlas.com/api/4509950439849985/envelope/',
626 'jwt_available' => function_exists('metasync_get_jwt_token') && !empty(metasync_get_jwt_token())
627 ];
628 }
629
630 /**
631 * Test user feedback submission
632 *
633 * @return array Test results
634 */
635 public function testUserFeedback() {
636 $test_feedback = [
637 'name' => 'Test User',
638 'email' => 'test@example.com',
639 '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.'
640 ];
641
642 $success = $this->captureFeedback($test_feedback);
643
644 return [
645 'success' => $success,
646 'message' => $success ? 'User feedback test sent successfully' : 'User feedback test failed',
647 'endpoint' => 'https://wordpress.telemetry.infra.searchatlas.com/api/4509950439849985/envelope/',
648 'item_type' => 'user_report',
649 'jwt_available' => function_exists('metasync_get_jwt_token') && !empty(metasync_get_jwt_token())
650 ];
651 }
652
653 /**
654 * Generate Sentry authentication header (legacy - now used for reference only)
655 */
656 private function getSentryAuthHeader() {
657 $timestamp = time();
658 $auth_parts = [
659 'Sentry sentry_version=7',
660 'sentry_client=metasync-wordpress/' . $this->release,
661 'sentry_timestamp=' . $timestamp,
662 'sentry_key=' . $this->public_key
663 ];
664
665 // Note: Modern Sentry DSNs only use public key, no secret key
666 // The secret key is only used for server-side authentication
667 if ($this->secret_key) {
668 $auth_parts[] = 'sentry_secret=' . $this->secret_key;
669 }
670
671 return implode(', ', $auth_parts);
672 }
673
674 /**
675 * Generate unique event ID
676 */
677 private function generateEventId() {
678 return str_replace('-', '', wp_generate_uuid4());
679 }
680
681 /**
682 * Get cached tags for the event (optimized)
683 */
684 private function getTags() {
685 // Cache static tags
686 $cache_key = 'metasync_sentry_tags';
687 $cached_tags = wp_cache_get($cache_key, 'metasync');
688
689 if ($cached_tags !== false) {
690 // Add dynamic tags that change per request
691 $cached_tags['server_name'] = $_SERVER['HTTP_HOST'] ?? 'unknown';
692 return $cached_tags;
693 }
694
695 $tags = [
696 'wp_version' => get_bloginfo('version'),
697 'php_version' => PHP_VERSION,
698 'plugin_version' => $this->release,
699 'environment' => $this->environment,
700 'wp_url' => home_url(),
701 'plugin_name' => 'metasync',
702 // Dynamic tag added per request
703 'server_name' => $_SERVER['HTTP_HOST'] ?? 'unknown'
704 ];
705
706 // Cache for 1 hour
707 wp_cache_set($cache_key, $tags, 'metasync', HOUR_IN_SECONDS);
708
709 return $tags;
710 }
711
712 /**
713 * Get cached system context for Sentry (optimized)
714 */
715 private function getSystemContext() {
716 // Use WordPress object cache to avoid repeated expensive operations
717 $cache_key = 'metasync_system_context';
718 $cached_context = wp_cache_get($cache_key, 'metasync');
719
720 if ($cached_context !== false) {
721 // Add dynamic data that changes per request
722 $cached_context['memory_usage'] = memory_get_usage(true);
723 $cached_context['memory_peak'] = memory_get_peak_usage(true);
724 $cached_context['request_uri'] = metasync_telemetry_request_path();
725 return $cached_context;
726 }
727
728 // Collect static system context (expensive operations)
729 global $wpdb;
730 $context = [
731 'wp_url' => home_url(),
732 'plugin_version' => $this->release,
733 'plugin_name' => 'Search Engine Labs SEO (MetaSync)',
734 'wordpress_version' => get_bloginfo('version'),
735 'site_title' => get_bloginfo('name'),
736 'site_admin_email' => get_bloginfo('admin_email'),
737 'php_version' => PHP_VERSION,
738 'memory_limit' => ini_get('memory_limit'),
739 'max_execution_time' => ini_get('max_execution_time'),
740 'active_plugins' => count(get_option('active_plugins', [])),
741 'active_theme' => get_template(),
742 'multisite' => is_multisite(),
743 'mysql_version' => method_exists($wpdb, 'get_var') ? $wpdb->get_var('SELECT VERSION()') : 'unknown',
744 'server_software' => $_SERVER['SERVER_SOFTWARE'] ?? 'unknown',
745 // Dynamic data added per request
746 'memory_usage' => memory_get_usage(true),
747 'memory_peak' => memory_get_peak_usage(true),
748 'request_uri' => metasync_telemetry_request_path()
749 ];
750
751 // Cache static context for 1 hour
752 wp_cache_set($cache_key, $context, 'metasync', HOUR_IN_SECONDS);
753
754 return $context;
755 }
756
757 /**
758 * Get cached contexts for Sentry (optimized)
759 */
760 private function getContexts() {
761 // Cache static context data
762 $cache_key = 'metasync_sentry_contexts';
763 $cached_contexts = wp_cache_get($cache_key, 'metasync');
764
765 if ($cached_contexts !== false) {
766 return $cached_contexts;
767 }
768
769 $contexts = [
770 'runtime' => [
771 'name' => 'php',
772 'version' => PHP_VERSION
773 ],
774 'os' => [
775 'name' => PHP_OS_FAMILY
776 ],
777 'app' => [
778 'app_name' => 'MetaSync Plugin',
779 'app_version' => $this->release
780 ]
781 ];
782
783 // Cache for 1 hour
784 wp_cache_set($cache_key, $contexts, 'metasync', HOUR_IN_SECONDS);
785
786 return $contexts;
787 }
788
789 /**
790 * Get cached user context (anonymized)
791 */
792 private function getUserContext() {
793 // Cache user context since it's based on static site data
794 $cache_key = 'metasync_user_context';
795 $cached_context = wp_cache_get($cache_key, 'metasync');
796
797 if ($cached_context !== false) {
798 return $cached_context;
799 }
800
801 $context = [
802 'id' => substr(md5(home_url() . get_bloginfo('name')), 0, 16),
803 'ip_address' => '{{auto}}' // Let Sentry handle IP detection and anonymization
804 ];
805
806 // Cache for 1 hour
807 wp_cache_set($cache_key, $context, 'metasync', HOUR_IN_SECONDS);
808
809 return $context;
810 }
811
812 /**
813 * Detect current environment
814 */
815 private function detectEnvironment() {
816
817 if (defined('WP_DEBUG') && WP_DEBUG) {
818 return 'development';
819 }
820
821 // Check if running on localhost/development environment
822 if ($this->isLocalhost()) {
823 return 'development';
824 }
825
826 $host = parse_url(home_url(), PHP_URL_HOST);
827 if (strpos($host, 'staging') !== false || strpos($host, 'dev') !== false) {
828 return 'staging';
829 }
830
831 return 'production';
832 }
833
834 /**
835 * Normalize log level for Sentry
836 */
837 private function normalizeLevel($level) {
838 $levels = ['debug', 'info', 'warning', 'error', 'fatal'];
839 return in_array($level, $levels) ? $level : 'info';
840 }
841
842 /**
843 * Check if stack frame is in application code
844 */
845 private function isInApp($frame) {
846 if (!isset($frame['file'])) {
847 return false;
848 }
849
850 $wp_content_dir = defined('WP_CONTENT_DIR') ? WP_CONTENT_DIR : ABSPATH . 'wp-content';
851 return strpos($frame['file'], $wp_content_dir) !== false;
852 }
853
854 /**
855 * Test the connection to Sentry
856 */
857 public function testConnection() {
858 $test_data = $this->formatMessage('🧪 Sentry connection test', 'info', [
859 'test' => true,
860 'timestamp' => time(),
861 'source' => 'connection_test'
862 ]);
863
864 $result = $this->sendToSentry($test_data);
865 $success = is_array($result) ? $result['success'] : $result;
866
867 return [
868 'success' => $success,
869 'dsn_configured' => !empty($this->dsn),
870 'project_id' => $this->project_id,
871 'environment' => $this->environment,
872 'release' => $this->release
873 ];
874 }
875 }
876
877 /**
878 * Global Sentry instance
879 */
880 global $metasync_sentry_wordpress;
881 $metasync_sentry_wordpress = null;
882
883 /**
884 * Initialize Sentry with DSN configuration
885 */
886 function init_metasync_sentry_wordpress() {
887 global $metasync_sentry_wordpress;
888
889 if (metasync_telemetry_is_disabled()) {
890 return null;
891 }
892
893 $dsn = '';
894
895 // Use constants defined in metasync.php for configuration
896 if (defined('METASYNC_SENTRY_PROJECT_ID')) {
897 // Create proxy DSN format using the project ID constant
898 $dsn = 'proxy://' . METASYNC_SENTRY_PROJECT_ID;
899 } else {
900 // Fallback: Check wp-config.php for custom DSN (for developers)
901 if (defined('METASYNC_SENTRY_DSN')) {
902 $dsn = METASYNC_SENTRY_DSN;
903 }
904 }
905
906 if (!empty($dsn)) {
907 try {
908 $metasync_sentry_wordpress = new MetaSync_Sentry_WordPress($dsn);
909 return $metasync_sentry_wordpress;
910 } catch (Exception $e) {
911 return null;
912 }
913 } else {
914 return null;
915 }
916 }
917
918 /**
919 * Helper function to capture exceptions
920 */
921 function metasync_sentry_capture_exception($exception, $extra = []) {
922 global $metasync_sentry_wordpress;
923 if (metasync_telemetry_is_disabled()) {
924 return false;
925 }
926 if (!$metasync_sentry_wordpress) {
927 $metasync_sentry_wordpress = init_metasync_sentry_wordpress();
928 }
929
930 if ($metasync_sentry_wordpress) {
931 return $metasync_sentry_wordpress->captureException($exception, $extra);
932 }
933 return false;
934 }
935
936 /**
937 * Helper function to capture messages
938 */
939 function metasync_sentry_capture_message($message, $level = 'info', $extra = [], $attachment = null) {
940 global $metasync_sentry_wordpress;
941 if (metasync_telemetry_is_disabled()) {
942 return false;
943 }
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->captureMessage($message, $level, $extra, $attachment);
950 }
951 return false;
952 }
953
954 /**
955 * Helper function to capture messages without blocking the current request.
956 *
957 * Use this instead of metasync_sentry_capture_message() from anything that runs
958 * on a visitor's page render — the blocking variant can hold the request for up
959 * to ten seconds if the collector is slow. See
960 * MetaSync_Sentry_WordPress::captureMessageNonBlocking() for the trade-off.
961 */
962 function metasync_sentry_capture_message_nonblocking($message, $level = 'info', $extra = []) {
963 global $metasync_sentry_wordpress;
964 if (metasync_telemetry_is_disabled()) {
965 return false;
966 }
967 if (!$metasync_sentry_wordpress) {
968 $metasync_sentry_wordpress = init_metasync_sentry_wordpress();
969 }
970
971 if ($metasync_sentry_wordpress) {
972 return $metasync_sentry_wordpress->captureMessageNonBlocking($message, $level, $extra);
973 }
974 return false;
975 }
976
977 /**
978 * Helper function to capture user feedback
979 *
980 * @param array $feedback Feedback data with keys: name (optional), email (optional), message (required), event_id (optional)
981 * @return bool Success status
982 */
983 function metasync_sentry_capture_feedback($feedback, $attachment = null) {
984 global $metasync_sentry_wordpress;
985 if (metasync_telemetry_is_disabled()) {
986 return false;
987 }
988 if (!$metasync_sentry_wordpress) {
989 $metasync_sentry_wordpress = init_metasync_sentry_wordpress();
990 }
991
992 if ($metasync_sentry_wordpress) {
993 return $metasync_sentry_wordpress->captureFeedback($feedback, $attachment);
994 }
995 return false;
996 }
997
998 /**
999 * Test Sentry connection
1000 */
1001 function metasync_sentry_test_connection() {
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 // Use the new proxy test method if available
1009 if (method_exists($metasync_sentry_wordpress, 'testProxyConnection')) {
1010 return $metasync_sentry_wordpress->testProxyConnection();
1011 }
1012 // Fallback to legacy method
1013 return $metasync_sentry_wordpress->testConnection();
1014 }
1015
1016 return [
1017 'success' => false,
1018 'error' => 'Sentry not initialized. Check DSN configuration.'
1019 ];
1020 }
1021
1022 /**
1023 * Test user feedback submission
1024 *
1025 * @return array Test results
1026 */
1027 function metasync_sentry_test_user_feedback() {
1028 global $metasync_sentry_wordpress;
1029 if (!$metasync_sentry_wordpress) {
1030 $metasync_sentry_wordpress = init_metasync_sentry_wordpress();
1031 }
1032
1033 if ($metasync_sentry_wordpress) {
1034 if (method_exists($metasync_sentry_wordpress, 'testUserFeedback')) {
1035 return $metasync_sentry_wordpress->testUserFeedback();
1036 }
1037 }
1038
1039 return [
1040 'success' => false,
1041 'error' => 'Sentry not initialized or test method not available.'
1042 ];
1043 }
1044
1045 // Auto-initialize when file is loaded
1046 init_metasync_sentry_wordpress();
1047 ?>
1048