*/ private const CONTEXT_FIELDS = array( 'event_type', 'request_id', 'correlation_id', 'job_id', ); /** * Project a {@see LogRecord} into the §12.2 row shape with * masking applied. * * Returned shape: * * `array( * 'id' => int|null, * 'event_type' => ?string, * 'level' => string, * 'channel' => string, * 'message' => string, * 'request_id' => ?string, * 'correlation_id' => ?string, * 'job_id' => ?string, * 'related_product_id' => ?int, * 'related_job_id' => ?int, * 'created_at' => string, * )` * * `created_at` is rendered as an ISO-8601 UTC string * (`Y-m-d\TH:i:s\Z`) so callers do not need to re-format the * MySQL `DATETIME` representation. * * @param LogRecord $record Record to project. * * @return array */ public function maskRecord( LogRecord $record ): array { $contextFields = $this->extractContextFields( $record->contextJson() ); return array( 'id' => $record->id(), 'event_type' => $contextFields['event_type'], 'level' => $record->level(), 'channel' => $record->channel(), 'message' => $this->maskString( $record->message() ), 'request_id' => $contextFields['request_id'], 'correlation_id' => $contextFields['correlation_id'], 'job_id' => $contextFields['job_id'], 'related_product_id' => $record->relatedProductId(), 'related_job_id' => $record->relatedJobId(), 'created_at' => $this->formatCreatedAt( $record ), ); } /** * Apply every masking pass to the supplied string value. * * @param string $value Value to mask. */ private function maskString( string $value ): string { $masked = $value; $bearer = preg_replace( self::PATTERN_BEARER, self::SUBSTITUTION_BEARER, $masked ); if ( is_string( $bearer ) ) { $masked = $bearer; } $apiKey = preg_replace( self::PATTERN_API_KEY, self::SUBSTITUTION_API_KEY, $masked ); if ( is_string( $apiKey ) ) { $masked = $apiKey; } $aws = preg_replace( self::PATTERN_AWS, self::SUBSTITUTION_STARS, $masked ); if ( is_string( $aws ) ) { $masked = $aws; } $hex = preg_replace( self::PATTERN_LONG_HEX, self::SUBSTITUTION_STARS, $masked ); if ( is_string( $hex ) ) { $masked = $hex; } $jwt = preg_replace( self::PATTERN_JWT, self::SUBSTITUTION_STARS, $masked ); if ( is_string( $jwt ) ) { $masked = $jwt; } $lwa = preg_replace( self::PATTERN_LWA_TOKEN, self::SUBSTITUTION_STARS, $masked ); if ( is_string( $lwa ) ) { $masked = $lwa; } $email = preg_replace( self::PATTERN_EMAIL, self::SUBSTITUTION_EMAIL, $masked ); if ( is_string( $email ) ) { $masked = $email; } return $masked; } /** * Decode `$contextJson` and extract the four §12.2 identifier * fields. Returns null for every field when the JSON is absent * or fails to decode as an object. * * Every OTHER string value inside the decoded context is run * through {@see self::maskString()} recursively for parity with * `message`, even though the masked context is not currently * surfaced on the §12.2 row — recursive scrubbing keeps the * masker safe for future row-shape additions and is cheap. * * @param string|null $contextJson Raw JSON blob, or null. * * @return array{ * event_type: ?string, * request_id: ?string, * correlation_id: ?string, * job_id: ?string, * } */ private function extractContextFields( ?string $contextJson ): array { $empty = array( 'event_type' => null, 'request_id' => null, 'correlation_id' => null, 'job_id' => null, ); if ( $contextJson === null || $contextJson === '' ) { return $empty; } $decoded = json_decode( $contextJson, true ); if ( ! is_array( $decoded ) ) { return $empty; } // Apply masking recursively to every value (kept for parity // even though the masked context is not surfaced on the row). $this->maskRecursive( $decoded ); $out = $empty; foreach ( self::CONTEXT_FIELDS as $key ) { if ( array_key_exists( $key, $decoded ) ) { $value = $decoded[ $key ]; if ( is_string( $value ) && $value !== '' ) { $out[ $key ] = $value; } } } return $out; } /** * Recursively apply {@see self::maskString()} to every string * value reachable in `$values`. Mutates `$values` in place by * reference. * * @param array $values Values to scrub. */ private function maskRecursive( array &$values ): void { foreach ( $values as $key => &$value ) { if ( is_string( $value ) ) { // Identifier fields are returned verbatim and not // re-read after masking; masking them in the working // copy is harmless. Skipping them keeps the recursive // scrub cheap when contexts are id-heavy. if ( is_string( $key ) && in_array( $key, self::CONTEXT_FIELDS, true ) ) { continue; } $value = $this->maskString( $value ); continue; } if ( is_array( $value ) ) { $this->maskRecursive( $value ); } } unset( $value ); } /** * Render the record's `created_at` as an ISO-8601 UTC string. * * @param LogRecord $record Record to read. */ private function formatCreatedAt( LogRecord $record ): string { return $record->createdAt() ->setTimezone( new DateTimeZone( 'UTC' ) ) ->format( 'Y-m-d\TH:i:s\Z' ); } }