@@ -96,10 +96,85 @@ public static function init() {
9696 add_action ( 'newspack_sync_retry_exhausted ' , [ __CLASS__ , 'handle_sync_retry_exhausted ' ] );
9797 add_action ( 'newspack_data_event_retry_exhausted ' , [ __CLASS__ , 'handle_data_event_retry_exhausted ' ] );
9898 add_action ( 'newspack_integration_health_check_failed ' , [ __CLASS__ , 'handle_health_check_failed ' ] );
99+ add_action ( 'newspack_alert ' , [ __CLASS__ , 'forward_alert_to_log ' ] );
99100 add_action ( self ::PATTERN_SCAN_HOOK , [ __CLASS__ , 'scan_failure_patterns ' ] );
100101 add_action ( 'init ' , [ __CLASS__ , 'schedule_pattern_scan ' ] );
101102 }
102103
104+ /**
105+ * Forward a `newspack_alert` to the `newspack_log` action so Newspack
106+ * Manager's Logger routes it. Severity drives the destination:
107+ *
108+ * - severity = 'error' or 'critical' → type 'error', log_level 3
109+ * (Alert — Slack)
110+ * - anything else (incl. 'warning', unknown, or missing severity) →
111+ * type 'debug', log_level 2 (Watch — logstash only)
112+ *
113+ * Only known error severities escalate to Slack so an unanticipated
114+ * alert shape (e.g. a third-party `newspack_alert` with no severity)
115+ * lands in Watch rather than paging on-call.
116+ *
117+ * Only the human-readable `message` is forwarded as free text. Any
118+ * contact email carried in the alert `context` is passed through
119+ * Logger's first-class `user_email` param — a structured field that is
120+ * not part of the Slack message body — instead of being interpolated
121+ * into `message`. The rest of the `context` is intentionally dropped to
122+ * avoid leaking source payloads into downstream logs.
123+ *
124+ * When Newspack Manager isn't active, `newspack_log` is a no-op.
125+ *
126+ * @param mixed $alert The alert payload fired by this class.
127+ */
128+ public static function forward_alert_to_log ( $ alert ) {
129+ if ( ! is_array ( $ alert ) || ! isset ( $ alert ['message ' ] ) || ! is_scalar ( $ alert ['message ' ] ) || '' === (string ) $ alert ['message ' ] ) {
130+ return ;
131+ }
132+
133+ $ code = is_scalar ( $ alert ['type ' ] ?? null ) && '' !== (string ) $ alert ['type ' ]
134+ ? (string ) $ alert ['type ' ]
135+ : 'newspack_alert ' ;
136+
137+ $ severity = is_scalar ( $ alert ['severity ' ] ?? null ) ? (string ) $ alert ['severity ' ] : '' ;
138+ $ is_error = in_array ( $ severity , [ 'error ' , 'critical ' ], true );
139+
140+ $ params = [
141+ 'type ' => $ is_error ? 'error ' : 'debug ' ,
142+ 'log_level ' => $ is_error ? 3 : 2 ,
143+ ];
144+
145+ $ user_email = self ::get_alert_user_email ( $ alert );
146+ if ( '' !== $ user_email ) {
147+ $ params ['user_email ' ] = $ user_email ;
148+ }
149+
150+ do_action ( 'newspack_log ' , $ code , (string ) $ alert ['message ' ], $ params );
151+ }
152+
153+ /**
154+ * Extract the contact email (if any) carried in an alert's `context` so
155+ * it can be forwarded via Logger's structured `user_email` param rather
156+ * than interpolated into the human-readable message.
157+ *
158+ * @param array $alert The alert payload.
159+ *
160+ * @return string The contact email, or '' when none is present.
161+ */
162+ private static function get_alert_user_email ( $ alert ) {
163+ $ context = is_array ( $ alert ['context ' ] ?? null ) ? $ alert ['context ' ] : [];
164+
165+ // Failure-pattern alerts grouped by contact email carry it as the group value.
166+ if ( 'contact_email ' === ( $ context ['group_by ' ] ?? '' ) && is_scalar ( $ context ['group_value ' ] ?? null ) ) {
167+ return (string ) $ context ['group_value ' ];
168+ }
169+
170+ // Sync/handler exhaustion payloads carry the contact under `contact.email`.
171+ if ( is_array ( $ context ['contact ' ] ?? null ) && is_scalar ( $ context ['contact ' ]['email ' ] ?? null ) ) {
172+ return (string ) $ context ['contact ' ]['email ' ];
173+ }
174+
175+ return '' ;
176+ }
177+
103178 /**
104179 * Schedule the recurring pattern scan via WP-Cron.
105180 */
@@ -159,11 +234,13 @@ public static function record_failure( $payload ) {
159234 * @param array $payload Alert data from Contact_Sync.
160235 */
161236 public static function handle_sync_retry_exhausted ( $ payload ) {
237+ // The contact email is intentionally left out of the message; it is
238+ // forwarded to the log via Logger's structured `user_email` param
239+ // (see forward_alert_to_log) and remains available in `context`.
162240 $ message = sprintf (
163- 'Max retries (%d) reached for integration "%s" sync of %s . Last error: %s ' ,
241+ 'Max retries (%d) reached for integration "%s" contact sync . Last error: %s ' ,
164242 $ payload ['retry_count ' ] ?? 0 ,
165243 $ payload ['integration_id ' ] ?? 'unknown ' ,
166- $ payload ['contact ' ]['email ' ] ?? 'unknown ' ,
167244 $ payload ['reason ' ] ?? 'unknown '
168245 );
169246
@@ -287,11 +364,15 @@ function ( $entry ) use ( $global_cutoff ) {
287364 continue ;
288365 }
289366
290- $ message = sprintf (
291- 'Pattern detected: %d failures with %s "%s" in the last %s. ' ,
367+ // When grouping by contact email, keep the email out of the
368+ // message; it is forwarded via Logger's `user_email` param
369+ // (see forward_alert_to_log) and stays in `context`.
370+ $ is_email_group = 'contact_email ' === $ rule ['group_by ' ];
371+ $ message = sprintf (
372+ 'Pattern detected: %d failures with %s%s in the last %s. ' ,
292373 count ( $ entries ),
293374 $ rule ['label ' ],
294- $ group_value ,
375+ $ is_email_group ? '' : sprintf ( ' "%s" ' , $ group_value ) ,
295376 self ::format_interval ( $ rule ['interval ' ] )
296377 );
297378
0 commit comments