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

1,079 lines 40.6 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 // Attach white-label metadata to the issue-report event (not only the
248 // user-feedback item) so support can identify the reporting site.
249 $event_data = self::apply_white_label_context($event_data, $feedback);
250
251 // Send the event first with attachment
252 $event_result = $this->sendToSentry($event_data, 'event', $attachment);
253
254 // Extract event_id from response
255 $event_id = null;
256 if (is_array($event_result) && isset($event_result['event_id'])) {
257 $event_id = $event_result['event_id'];
258 } elseif (is_array($event_result) && $event_result['success']) {
259 // If event was sent but no event_id in response, use the one we generated
260 $event_id = $event_data['event_id'] ?? null;
261 }
262
263 // If event creation failed, return false
264 if (!$event_id) {
265 return false;
266 }
267
268 // Now send the feedback with the event_id (no attachment on feedback, it's already on the event)
269 // Make sure the feedback message includes title, severity, and user description
270 $feedback['event_id'] = $event_id;
271 $feedback['message'] = $formatted_message; // Use the formatted message (title + severity + description)
272 $data = $this->formatFeedback($feedback);
273 $feedback_result = $this->sendToSentry($data, 'user_report');
274
275 return is_array($feedback_result) ? $feedback_result['success'] : $feedback_result;
276 }
277
278 /**
279 * Attach white-label metadata to an issue-report event payload.
280 *
281 * Support needs to tell from the Sentry event alone whether the reporting
282 * site is white-labeled, so the values go on the event itself (tags plus a
283 * white_label context), not only on the associated user-feedback item.
284 *
285 * Static and public so the unit suite can exercise the mapping directly;
286 * captureFeedback() runs the result through a private curl sender that has
287 * no test seam.
288 *
289 * @param array $event_data Event payload built by formatMessage().
290 * @param array $feedback Feedback data supplied by the report handler.
291 * @return array The event payload with white-label tags and context added.
292 */
293 public static function apply_white_label_context(array $event_data, array $feedback)
294 {
295 $is_whitelabel = !empty($feedback['is_whitelabel']);
296 $brand_name = isset($feedback['whitelabel_brand_name'])
297 ? sanitize_text_field($feedback['whitelabel_brand_name']) : '';
298 $support_email = isset($feedback['whitelabel_support_email'])
299 ? sanitize_email($feedback['whitelabel_support_email']) : '';
300 $site_url = isset($feedback['site_url']) ? esc_url_raw($feedback['site_url']) : '';
301 $event_data['tags']['is_whitelabel'] = $is_whitelabel ? 'true' : 'false';
302 $event_data['tags']['whitelabel_brand_name'] = $brand_name;
303 $event_data['tags']['whitelabel_support_email'] = $support_email;
304 $event_data['tags']['site_url'] = $site_url;
305 $event_data['contexts']['white_label'] = array(
306 'is_whitelabel' => $is_whitelabel,
307 'whitelabel_brand_name' => $brand_name,
308 'whitelabel_support_email' => $support_email,
309 'site_url' => $site_url,
310 );
311
312 return $event_data;
313 }
314
315 /**
316 * Format exception data for Sentry API
317 */
318 private function formatException($exception, $extra = []) {
319 $trace = [];
320 if (is_object($exception) && method_exists($exception, 'getTrace')) {
321 foreach ($exception->getTrace() as $frame) {
322 $trace[] = [
323 'filename' => isset($frame['file']) ? $frame['file'] : '<unknown>',
324 'lineno' => isset($frame['line']) ? $frame['line'] : 0,
325 'function' => isset($frame['function']) ? $frame['function'] : '<unknown>',
326 'module' => isset($frame['class']) ? $frame['class'] : null,
327 'in_app' => $this->isInApp($frame)
328 ];
329 }
330 }
331
332 return [
333 'event_id' => $this->generateEventId(),
334 'timestamp' => gmdate('Y-m-d\TH:i:s\Z'),
335 'level' => 'error',
336 'platform' => 'php',
337 'sdk' => [
338 'name' => 'metasync-wordpress-sentry',
339 'version' => $this->release
340 ],
341 'server_name' => $_SERVER['HTTP_HOST'] ?? 'unknown',
342 'release' => $this->release,
343 'environment' => $this->environment,
344 'exception' => [
345 'values' => [
346 [
347 'type' => is_object($exception) ? get_class($exception) : 'Error',
348 'value' => is_object($exception) ? $exception->getMessage() : (string)$exception,
349 'stacktrace' => ['frames' => array_reverse($trace)]
350 ]
351 ]
352 ],
353 'tags' => $this->getTags(),
354 'extra' => array_merge($this->getSystemContext(), $extra),
355 'user' => $this->getUserContext(),
356 'contexts' => $this->getContexts()
357 ];
358 }
359
360 /**
361 * Format message data for Sentry API
362 */
363 private function formatMessage($message, $level, $extra = []) {
364 return [
365 'event_id' => $this->generateEventId(),
366 'timestamp' => gmdate('Y-m-d\TH:i:s\Z'),
367 'level' => $this->normalizeLevel($level),
368 'platform' => 'php',
369 'sdk' => [
370 'name' => 'metasync-wordpress-sentry',
371 'version' => $this->release
372 ],
373 'server_name' => $_SERVER['HTTP_HOST'] ?? 'unknown',
374 'release' => $this->release,
375 'environment' => $this->environment,
376 'message' => [
377 'message' => $message
378 ],
379 'tags' => $this->getTags(),
380 'extra' => array_merge($this->getSystemContext(), $extra),
381 'user' => $this->getUserContext(),
382 'contexts' => $this->getContexts()
383 ];
384 }
385
386 /**
387 * Format user feedback data for Sentry User Feedback API
388 *
389 * @param array $feedback Feedback data with keys: name, email, message (or comments), event_id
390 * @return array Formatted feedback data
391 */
392 private function formatFeedback($feedback) {
393 // Support both 'message' (JavaScript SDK API) and 'comments' (envelope format)
394 // The JavaScript SDK accepts 'message' but converts it to 'comments' in the envelope
395 $comments = '';
396 if (isset($feedback['message']) && !empty($feedback['message'])) {
397 $comments = $feedback['message'];
398 } elseif (isset($feedback['comments']) && !empty($feedback['comments'])) {
399 $comments = $feedback['comments'];
400 }
401
402 // Validate required fields
403 if (empty($comments)) {
404 throw new InvalidArgumentException('Message/comments field is required for user feedback');
405 }
406
407 // Build feedback payload according to Sentry User Feedback API envelope format
408 // Reference: https://docs.sentry.io/platforms/javascript/user-feedback/#user-feedback-api
409 // IMPORTANT: The envelope format uses 'comments' as the key (not 'message')
410 // The JavaScript SDK accepts 'message' but converts it to 'comments' internally
411 $feedback_data = [
412 'comments' => sanitize_textarea_field($comments)
413 ];
414
415 // Add optional name field
416 if (isset($feedback['name']) && !empty(trim($feedback['name']))) {
417 $feedback_data['name'] = sanitize_text_field($feedback['name']);
418 }
419
420 // Add optional email field
421 if (isset($feedback['email']) && !empty(trim($feedback['email']))) {
422 $feedback_data['email'] = sanitize_email($feedback['email']);
423 }
424
425 // Add event_id if provided (to associate feedback with an event)
426 // Note: In envelope format, event_id can be in the payload
427 if (isset($feedback['event_id']) && !empty($feedback['event_id'])) {
428 $feedback_data['event_id'] = sanitize_text_field($feedback['event_id']);
429 }
430
431 return $feedback_data;
432 }
433
434 /**
435 * Check if the current environment is localhost/development
436 */
437 private function isLocalhost() {
438 $host = wp_parse_url(home_url(), PHP_URL_HOST);
439
440 // Check for common localhost patterns
441 $localhost_patterns = [
442 'localhost',
443 '127.0.0.1',
444 '::1',
445 '0.0.0.0',
446 '.local',
447 '.test',
448 '.dev',
449 '.localhost'
450 ];
451
452 foreach ($localhost_patterns as $pattern) {
453 if ($host !== false && $host !== null && strpos($host, $pattern) !== false) {
454 return true;
455 }
456 }
457
458 // Check if host is an IP address in private ranges
459 if (filter_var($host, FILTER_VALIDATE_IP)) {
460 $ip = ip2long($host);
461 if ($ip !== false) {
462 // Private IP ranges: 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16
463 if (($ip >= ip2long('10.0.0.0') && $ip <= ip2long('10.255.255.255')) ||
464 ($ip >= ip2long('172.16.0.0') && $ip <= ip2long('172.31.255.255')) ||
465 ($ip >= ip2long('192.168.0.0') && $ip <= ip2long('192.168.255.255'))) {
466 return true;
467 }
468 }
469 }
470
471 return false;
472 }
473
474 /**
475 * Send data to Sentry API using WordPress HTTP functions (proxied through our system with JWT)
476 *
477 * @param array $data Sentry event or feedback data
478 * @param string $item_type Type of item: 'event' or 'user_report'
479 * @param array|null $attachment Optional attachment data
480 * @return bool|array Success status, or array with success and event_id
481 */
482 private function sendToSentry($data, $item_type = 'event', $attachment = null) {
483 if (metasync_telemetry_is_disabled()) {
484 return false;
485 }
486
487 // Skip sending to Sentry if running on localhost/development environment
488 if ($this->isLocalhost()) {
489 return false;
490 }
491
492 // Get JWT token for authentication
493 if (!function_exists('metasync_get_jwt_token')) {
494 return false;
495 }
496
497 try {
498 $jwt_token = metasync_get_jwt_token();
499 } catch (Exception $e) {
500 return false;
501 } catch (Error $e) {
502 return false;
503 }
504
505 if (empty($jwt_token)) {
506 return false;
507 }
508
509 // Use WordPress Sentry tunnel endpoint
510 $url = 'https://wordpress.telemetry.infra.searchatlas.com/api/4509950439849985/envelope/';
511
512 // Convert Sentry data to envelope format, including attachment if present
513 $envelope = $this->createSentryEnvelope($data, $item_type, $attachment);
514
515 $plugin_version = defined('METASYNC_VERSION') ? METASYNC_VERSION : '1.0.0';
516
517 $headers = [
518 'Authorization' => 'Bearer ' . $jwt_token,
519 'Content-Type' => 'application/x-sentry-envelope',
520 'X-Plugin-Version' => $plugin_version,
521 'User-Agent' => 'WordPress MetaSync Plugin/' . $plugin_version
522 ];
523
524 $response_data = wp_remote_post($url, [
525 'body' => $envelope,
526 'headers' => $headers,
527 'timeout' => 5, // 5 second timeout as requested
528 'sslverify' => true,
529 'user-agent' => 'WordPress MetaSync Plugin/' . $plugin_version,
530 ]);
531 $error = is_wp_error($response_data) ? $response_data->get_error_message() : '';
532 $response_code = is_wp_error($response_data) ? 0 : (int) wp_remote_retrieve_response_code($response_data);
533 $response = is_wp_error($response_data) ? '' : wp_remote_retrieve_body($response_data);
534
535 // Log errors in debug mode for troubleshooting
536 if (defined('WP_DEBUG') && WP_DEBUG && WP_DEBUG_LOG) {
537 if ($error) {
538 error_log(sprintf( // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- debug-gated, no secrets
539 'MetaSync Sentry Error (%s): %s | Response Code: %s | Item Type: %s',
540 $item_type,
541 $error,
542 $response_code,
543 $item_type
544 ));
545 } elseif ($response_code < 200 || $response_code >= 300) {
546 error_log(sprintf( // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- debug-gated, no secrets
547 'MetaSync Sentry HTTP Error (%s): Response Code: %s | Item Type: %s',
548 $item_type,
549 $response_code,
550 $item_type
551 ));
552 }
553 }
554
555 // Return false silently on any error or timeout
556 if ($error) {
557 return false;
558 }
559
560 $success = $response_code >= 200 && $response_code < 300;
561
562 // Parse response to extract event_id if available
563 $event_id = null;
564 if ($success && !empty($response)) {
565 $response_data = json_decode($response, true);
566 if (is_array($response_data) && isset($response_data['id'])) {
567 $event_id = $response_data['id'];
568 }
569 }
570
571 // Return array with success status and event_id if available
572 return [
573 'success' => $success,
574 'event_id' => $event_id
575 ];
576 }
577
578 /**
579 * Create Sentry envelope format from event or feedback data
580 *
581 * @param array $data Sentry event or feedback data
582 * @param string $item_type Type of item: 'event' or 'user_report'
583 * @param array|null $attachment Optional attachment data
584 * @return string Envelope format string
585 */
586 private function createSentryEnvelope($data, $item_type = 'event', $attachment = null) {
587 // Envelope header
588 // Note: When sending to the envelope endpoint directly, we don't include DSN
589 // The project ID is already in the URL path
590 $envelope_header = [
591 'sent_at' => gmdate('c')
592 ];
593
594 // Add event_id to header if present (for events) or if it's in feedback data
595 if ($item_type === 'event' && isset($data['event_id'])) {
596 $envelope_header['event_id'] = $data['event_id'];
597 } elseif ($item_type === 'user_report' && isset($data['event_id'])) {
598 // For user feedback, event_id is in the payload, not header
599 // But we can still include it in header if needed
600 }
601
602 // Item header - determine type based on parameter
603 $item_header = [
604 'type' => $item_type
605 ];
606
607 // Create envelope format: header\nitem_header\nitem_payload
608 $envelope = wp_json_encode($envelope_header) . "\n";
609 $envelope .= wp_json_encode($item_header) . "\n";
610 $envelope .= wp_json_encode($data) . "\n";
611
612 // Add attachment if present
613 if ($attachment && isset($attachment['data']) && isset($attachment['filename'])) {
614 // Attachment item header
615 $attachment_header = [
616 'type' => 'attachment',
617 'length' => strlen($attachment['data']),
618 'filename' => $attachment['filename'],
619 'content_type' => $attachment['content_type'] ?? 'application/octet-stream'
620 ];
621
622 // Add attachment to envelope
623 $envelope .= wp_json_encode($attachment_header) . "\n";
624 $envelope .= $attachment['data'] . "\n";
625 }
626
627 return $envelope;
628 }
629
630 /**
631 * Test the Sentry proxy connection
632 *
633 * @return array Test results
634 */
635 public function testProxyConnection() {
636 $test_data = [
637 'message' => [
638 'message' => 'Sentry proxy connection test',
639 'formatted' => 'Sentry proxy connection test'
640 ],
641 'level' => 'info',
642 'timestamp' => gmdate('c'),
643 'platform' => 'php',
644 'sdk' => [
645 'name' => 'metasync-telemetry-test',
646 'version' => '1.0.0'
647 ]
648 ];
649
650 $result = $this->sendToSentry($test_data);
651 $success = is_array($result) ? $result['success'] : $result;
652
653 return [
654 'success' => $success,
655 'message' => $success ? 'Sentry tunnel connection successful' : 'Sentry tunnel connection failed',
656 'endpoint' => 'https://wordpress.telemetry.infra.searchatlas.com/api/4509950439849985/envelope/',
657 'jwt_available' => function_exists('metasync_get_jwt_token') && !empty(metasync_get_jwt_token())
658 ];
659 }
660
661 /**
662 * Test user feedback submission
663 *
664 * @return array Test results
665 */
666 public function testUserFeedback() {
667 $test_feedback = [
668 'name' => 'Test User',
669 'email' => '[email protected]',
670 '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.'
671 ];
672
673 $success = $this->captureFeedback($test_feedback);
674
675 return [
676 'success' => $success,
677 'message' => $success ? 'User feedback test sent successfully' : 'User feedback test failed',
678 'endpoint' => 'https://wordpress.telemetry.infra.searchatlas.com/api/4509950439849985/envelope/',
679 'item_type' => 'user_report',
680 'jwt_available' => function_exists('metasync_get_jwt_token') && !empty(metasync_get_jwt_token())
681 ];
682 }
683
684 /**
685 * Generate Sentry authentication header (legacy - now used for reference only)
686 */
687 private function getSentryAuthHeader() {
688 $timestamp = time();
689 $auth_parts = [
690 'Sentry sentry_version=7',
691 'sentry_client=metasync-wordpress/' . $this->release,
692 'sentry_timestamp=' . $timestamp,
693 'sentry_key=' . $this->public_key
694 ];
695
696 // Note: Modern Sentry DSNs only use public key, no secret key
697 // The secret key is only used for server-side authentication
698 if ($this->secret_key) {
699 $auth_parts[] = 'sentry_secret=' . $this->secret_key;
700 }
701
702 return implode(', ', $auth_parts);
703 }
704
705 /**
706 * Generate unique event ID
707 */
708 private function generateEventId() {
709 return str_replace('-', '', wp_generate_uuid4());
710 }
711
712 /**
713 * Get cached tags for the event (optimized)
714 */
715 private function getTags() {
716 // Cache static tags
717 $cache_key = 'metasync_sentry_tags';
718 $cached_tags = wp_cache_get($cache_key, 'metasync');
719
720 if ($cached_tags !== false) {
721 // Add dynamic tags that change per request
722 $cached_tags['server_name'] = $_SERVER['HTTP_HOST'] ?? 'unknown';
723 return $cached_tags;
724 }
725
726 $tags = [
727 'wp_version' => get_bloginfo('version'),
728 'php_version' => PHP_VERSION,
729 'plugin_version' => $this->release,
730 'environment' => $this->environment,
731 'wp_url' => home_url(),
732 'plugin_name' => 'metasync',
733 // Dynamic tag added per request
734 'server_name' => $_SERVER['HTTP_HOST'] ?? 'unknown'
735 ];
736
737 // Cache for 1 hour
738 wp_cache_set($cache_key, $tags, 'metasync', HOUR_IN_SECONDS);
739
740 return $tags;
741 }
742
743 /**
744 * Get cached system context for Sentry (optimized)
745 */
746 private function getSystemContext() {
747 // Use WordPress object cache to avoid repeated expensive operations
748 $cache_key = 'metasync_system_context';
749 $cached_context = wp_cache_get($cache_key, 'metasync');
750
751 if ($cached_context !== false) {
752 // Add dynamic data that changes per request
753 $cached_context['memory_usage'] = memory_get_usage(true);
754 $cached_context['memory_peak'] = memory_get_peak_usage(true);
755 $cached_context['request_uri'] = metasync_telemetry_request_path();
756 return $cached_context;
757 }
758
759 // Collect static system context (expensive operations)
760 global $wpdb;
761 $context = [
762 'wp_url' => home_url(),
763 'plugin_version' => $this->release,
764 'plugin_name' => 'Search Engine Labs SEO (MetaSync)',
765 'wordpress_version' => get_bloginfo('version'),
766 'site_title' => get_bloginfo('name'),
767 'site_admin_email' => get_bloginfo('admin_email'),
768 'php_version' => PHP_VERSION,
769 'memory_limit' => ini_get('memory_limit'),
770 'max_execution_time' => ini_get('max_execution_time'),
771 'active_plugins' => count(get_option('active_plugins', [])),
772 'active_theme' => get_template(),
773 'multisite' => is_multisite(),
774 '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
775 'server_software' => $_SERVER['SERVER_SOFTWARE'] ?? 'unknown',
776 // Dynamic data added per request
777 'memory_usage' => memory_get_usage(true),
778 'memory_peak' => memory_get_peak_usage(true),
779 'request_uri' => metasync_telemetry_request_path()
780 ];
781
782 // Cache static context for 1 hour
783 wp_cache_set($cache_key, $context, 'metasync', HOUR_IN_SECONDS);
784
785 return $context;
786 }
787
788 /**
789 * Get cached contexts for Sentry (optimized)
790 */
791 private function getContexts() {
792 // Cache static context data
793 $cache_key = 'metasync_sentry_contexts';
794 $cached_contexts = wp_cache_get($cache_key, 'metasync');
795
796 if ($cached_contexts !== false) {
797 return $cached_contexts;
798 }
799
800 $contexts = [
801 'runtime' => [
802 'name' => 'php',
803 'version' => PHP_VERSION
804 ],
805 'os' => [
806 'name' => PHP_OS_FAMILY
807 ],
808 'app' => [
809 'app_name' => 'MetaSync Plugin',
810 'app_version' => $this->release
811 ]
812 ];
813
814 // Cache for 1 hour
815 wp_cache_set($cache_key, $contexts, 'metasync', HOUR_IN_SECONDS);
816
817 return $contexts;
818 }
819
820 /**
821 * Get cached user context (anonymized)
822 */
823 private function getUserContext() {
824 // Cache user context since it's based on static site data
825 $cache_key = 'metasync_user_context';
826 $cached_context = wp_cache_get($cache_key, 'metasync');
827
828 if ($cached_context !== false) {
829 return $cached_context;
830 }
831
832 $context = [
833 'id' => substr(md5(home_url() . get_bloginfo('name')), 0, 16),
834 'ip_address' => '{{auto}}' // Let Sentry handle IP detection and anonymization
835 ];
836
837 // Cache for 1 hour
838 wp_cache_set($cache_key, $context, 'metasync', HOUR_IN_SECONDS);
839
840 return $context;
841 }
842
843 /**
844 * Detect current environment
845 */
846 private function detectEnvironment() {
847
848 if (defined('WP_DEBUG') && WP_DEBUG) {
849 return 'development';
850 }
851
852 // Check if running on localhost/development environment
853 if ($this->isLocalhost()) {
854 return 'development';
855 }
856
857 $host = wp_parse_url(home_url(), PHP_URL_HOST);
858 if ($host !== false && $host !== null && (strpos($host, 'staging') !== false || strpos($host, 'dev') !== false)) {
859 return 'staging';
860 }
861
862 return 'production';
863 }
864
865 /**
866 * Normalize log level for Sentry
867 */
868 private function normalizeLevel($level) {
869 $levels = ['debug', 'info', 'warning', 'error', 'fatal'];
870 return in_array($level, $levels) ? $level : 'info';
871 }
872
873 /**
874 * Check if stack frame is in application code
875 */
876 private function isInApp($frame) {
877 if (!isset($frame['file'])) {
878 return false;
879 }
880
881 $wp_content_dir = defined('WP_CONTENT_DIR') ? WP_CONTENT_DIR : ABSPATH . 'wp-content';
882 return strpos($frame['file'], $wp_content_dir) !== false;
883 }
884
885 /**
886 * Test the connection to Sentry
887 */
888 public function testConnection() {
889 $test_data = $this->formatMessage('🧪 Sentry connection test', 'info', [
890 'test' => true,
891 'timestamp' => time(),
892 'source' => 'connection_test'
893 ]);
894
895 $result = $this->sendToSentry($test_data);
896 $success = is_array($result) ? $result['success'] : $result;
897
898 return [
899 'success' => $success,
900 'dsn_configured' => !empty($this->dsn),
901 'project_id' => $this->project_id,
902 'environment' => $this->environment,
903 'release' => $this->release
904 ];
905 }
906 }
907
908 /**
909 * Global Sentry instance
910 */
911 global $metasync_sentry_wordpress;
912 $metasync_sentry_wordpress = null;
913
914 /**
915 * Initialize Sentry with DSN configuration
916 */
917 function init_metasync_sentry_wordpress() {
918 global $metasync_sentry_wordpress;
919
920 if (metasync_telemetry_is_disabled()) {
921 return null;
922 }
923
924 $dsn = '';
925
926 // Use constants defined in metasync.php for configuration
927 if (defined('METASYNC_SENTRY_PROJECT_ID')) {
928 // Create proxy DSN format using the project ID constant
929 $dsn = 'proxy://' . METASYNC_SENTRY_PROJECT_ID;
930 } else {
931 // Fallback: Check wp-config.php for custom DSN (for developers)
932 if (defined('METASYNC_SENTRY_DSN')) {
933 $dsn = METASYNC_SENTRY_DSN;
934 }
935 }
936
937 if (!empty($dsn)) {
938 try {
939 $metasync_sentry_wordpress = new MetaSync_Sentry_WordPress($dsn);
940 return $metasync_sentry_wordpress;
941 } catch (Exception $e) {
942 return null;
943 }
944 } else {
945 return null;
946 }
947 }
948
949 /**
950 * Helper function to capture exceptions
951 */
952 function metasync_sentry_capture_exception($exception, $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->captureException($exception, $extra);
963 }
964 return false;
965 }
966
967 /**
968 * Helper function to capture messages
969 */
970 function metasync_sentry_capture_message($message, $level = 'info', $extra = [], $attachment = null) {
971 global $metasync_sentry_wordpress;
972 if (metasync_telemetry_is_disabled()) {
973 return false;
974 }
975 if (!$metasync_sentry_wordpress) {
976 $metasync_sentry_wordpress = init_metasync_sentry_wordpress();
977 }
978
979 if ($metasync_sentry_wordpress) {
980 return $metasync_sentry_wordpress->captureMessage($message, $level, $extra, $attachment);
981 }
982 return false;
983 }
984
985 /**
986 * Helper function to capture messages without blocking the current request.
987 *
988 * Use this instead of metasync_sentry_capture_message() from anything that runs
989 * on a visitor's page render — the blocking variant can hold the request for up
990 * to ten seconds if the collector is slow. See
991 * MetaSync_Sentry_WordPress::captureMessageNonBlocking() for the trade-off.
992 */
993 function metasync_sentry_capture_message_nonblocking($message, $level = 'info', $extra = []) {
994 global $metasync_sentry_wordpress;
995 if (metasync_telemetry_is_disabled()) {
996 return false;
997 }
998 if (!$metasync_sentry_wordpress) {
999 $metasync_sentry_wordpress = init_metasync_sentry_wordpress();
1000 }
1001
1002 if ($metasync_sentry_wordpress) {
1003 return $metasync_sentry_wordpress->captureMessageNonBlocking($message, $level, $extra);
1004 }
1005 return false;
1006 }
1007
1008 /**
1009 * Helper function to capture user feedback
1010 *
1011 * @param array $feedback Feedback data with keys: name (optional), email (optional), message (required), event_id (optional)
1012 * @return bool Success status
1013 */
1014 function metasync_sentry_capture_feedback($feedback, $attachment = null) {
1015 global $metasync_sentry_wordpress;
1016 if (metasync_telemetry_is_disabled()) {
1017 return false;
1018 }
1019 if (!$metasync_sentry_wordpress) {
1020 $metasync_sentry_wordpress = init_metasync_sentry_wordpress();
1021 }
1022
1023 if ($metasync_sentry_wordpress) {
1024 return $metasync_sentry_wordpress->captureFeedback($feedback, $attachment);
1025 }
1026 return false;
1027 }
1028
1029 /**
1030 * Test Sentry connection
1031 */
1032 function metasync_sentry_test_connection() {
1033 global $metasync_sentry_wordpress;
1034 if (!$metasync_sentry_wordpress) {
1035 $metasync_sentry_wordpress = init_metasync_sentry_wordpress();
1036 }
1037
1038 if ($metasync_sentry_wordpress) {
1039 // Use the new proxy test method if available
1040 if (method_exists($metasync_sentry_wordpress, 'testProxyConnection')) {
1041 return $metasync_sentry_wordpress->testProxyConnection();
1042 }
1043 // Fallback to legacy method
1044 return $metasync_sentry_wordpress->testConnection();
1045 }
1046
1047 return [
1048 'success' => false,
1049 'error' => 'Sentry not initialized. Check DSN configuration.'
1050 ];
1051 }
1052
1053 /**
1054 * Test user feedback submission
1055 *
1056 * @return array Test results
1057 */
1058 function metasync_sentry_test_user_feedback() {
1059 global $metasync_sentry_wordpress;
1060 if (!$metasync_sentry_wordpress) {
1061 $metasync_sentry_wordpress = init_metasync_sentry_wordpress();
1062 }
1063
1064 if ($metasync_sentry_wordpress) {
1065 if (method_exists($metasync_sentry_wordpress, 'testUserFeedback')) {
1066 return $metasync_sentry_wordpress->testUserFeedback();
1067 }
1068 }
1069
1070 return [
1071 'success' => false,
1072 'error' => 'Sentry not initialized or test method not available.'
1073 ];
1074 }
1075
1076 // Auto-initialize when file is loaded
1077 init_metasync_sentry_wordpress();
1078 ?>
1079