Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 86 additions & 5 deletions includes/class-alert-manager.php
Original file line number Diff line number Diff line change
Expand Up @@ -96,10 +96,85 @@ public static function init() {
add_action( 'newspack_sync_retry_exhausted', [ __CLASS__, 'handle_sync_retry_exhausted' ] );
add_action( 'newspack_data_event_retry_exhausted', [ __CLASS__, 'handle_data_event_retry_exhausted' ] );
add_action( 'newspack_integration_health_check_failed', [ __CLASS__, 'handle_health_check_failed' ] );
add_action( 'newspack_alert', [ __CLASS__, 'forward_alert_to_log' ] );
Comment thread
miguelpeixe marked this conversation as resolved.
add_action( self::PATTERN_SCAN_HOOK, [ __CLASS__, 'scan_failure_patterns' ] );
add_action( 'init', [ __CLASS__, 'schedule_pattern_scan' ] );
}

/**
* Forward a `newspack_alert` to the `newspack_log` action so Newspack
* Manager's Logger routes it. Severity drives the destination:
*
* - severity = 'error' or 'critical' → type 'error', log_level 3
* (Alert — Slack)
* - anything else (incl. 'warning', unknown, or missing severity) →
* type 'debug', log_level 2 (Watch — logstash only)
*
* Only known error severities escalate to Slack so an unanticipated
* alert shape (e.g. a third-party `newspack_alert` with no severity)
* lands in Watch rather than paging on-call.
*
* Only the human-readable `message` is forwarded as free text. Any
* contact email carried in the alert `context` is passed through
* Logger's first-class `user_email` param — a structured field that is
* not part of the Slack message body — instead of being interpolated
* into `message`. The rest of the `context` is intentionally dropped to
* avoid leaking source payloads into downstream logs.
*
* When Newspack Manager isn't active, `newspack_log` is a no-op.
*
* @param mixed $alert The alert payload fired by this class.
*/
public static function forward_alert_to_log( $alert ) {
if ( ! is_array( $alert ) || ! isset( $alert['message'] ) || ! is_scalar( $alert['message'] ) || '' === (string) $alert['message'] ) {
return;
}

$code = is_scalar( $alert['type'] ?? null ) && '' !== (string) $alert['type']
? (string) $alert['type']
: 'newspack_alert';

$severity = is_scalar( $alert['severity'] ?? null ) ? (string) $alert['severity'] : '';
$is_error = in_array( $severity, [ 'error', 'critical' ], true );

$params = [
'type' => $is_error ? 'error' : 'debug',
'log_level' => $is_error ? 3 : 2,
];

$user_email = self::get_alert_user_email( $alert );
if ( '' !== $user_email ) {
$params['user_email'] = $user_email;
}

do_action( 'newspack_log', $code, (string) $alert['message'], $params );
}

/**
* Extract the contact email (if any) carried in an alert's `context` so
* it can be forwarded via Logger's structured `user_email` param rather
* than interpolated into the human-readable message.
*
* @param array $alert The alert payload.
*
* @return string The contact email, or '' when none is present.
*/
private static function get_alert_user_email( $alert ) {
$context = is_array( $alert['context'] ?? null ) ? $alert['context'] : [];

// Failure-pattern alerts grouped by contact email carry it as the group value.
if ( 'contact_email' === ( $context['group_by'] ?? '' ) && is_scalar( $context['group_value'] ?? null ) ) {
return (string) $context['group_value'];
}

// Sync/handler exhaustion payloads carry the contact under `contact.email`.
if ( is_array( $context['contact'] ?? null ) && is_scalar( $context['contact']['email'] ?? null ) ) {
return (string) $context['contact']['email'];
}

return '';
}

/**
* Schedule the recurring pattern scan via WP-Cron.
*/
Expand Down Expand Up @@ -159,11 +234,13 @@ public static function record_failure( $payload ) {
* @param array $payload Alert data from Contact_Sync.
*/
public static function handle_sync_retry_exhausted( $payload ) {
// The contact email is intentionally left out of the message; it is
// forwarded to the log via Logger's structured `user_email` param
// (see forward_alert_to_log) and remains available in `context`.
$message = sprintf(
'Max retries (%d) reached for integration "%s" sync of %s. Last error: %s',
'Max retries (%d) reached for integration "%s" contact sync. Last error: %s',
$payload['retry_count'] ?? 0,
$payload['integration_id'] ?? 'unknown',
$payload['contact']['email'] ?? 'unknown',
$payload['reason'] ?? 'unknown'
);

Expand Down Expand Up @@ -287,11 +364,15 @@ function ( $entry ) use ( $global_cutoff ) {
continue;
}

$message = sprintf(
'Pattern detected: %d failures with %s "%s" in the last %s.',
// When grouping by contact email, keep the email out of the
// message; it is forwarded via Logger's `user_email` param
// (see forward_alert_to_log) and stays in `context`.
$is_email_group = 'contact_email' === $rule['group_by'];
$message = sprintf(
'Pattern detected: %d failures with %s%s in the last %s.',
count( $entries ),
$rule['label'],
$group_value,
$is_email_group ? '' : sprintf( ' "%s"', $group_value ),
self::format_interval( $rule['interval'] )
);

Expand Down
Loading
Loading