direction = $direction; $this->role = $role; $this->migration_id = $migration_id; $this->started_at = microtime( true ); } /** A trace for the site driving the transfer. */ public static function sender( int $migration_id, string $direction = self::PULL ): self { return new self( $direction, self::ROLE_SENDER, $migration_id ); } /** A trace for the receiving site, which only learns the sender's migration id once a request arrives, so 0 is a normal value. */ public static function receiver( int $migration_id, string $direction = self::PULL, string $role = self::ROLE_RECEIVER ): self { return new self( $direction, $role, $migration_id ); } public function with_uuid( ?string $transfer_uuid ): self { $trace = clone $this; $trace->transfer_uuid = self::normalise_uuid( $transfer_uuid ); return $trace; } public function stage( string $stage ): self { $trace = clone $this; $trace->stage = $stage; return $trace; } public function table( ?string $table ): self { $trace = clone $this; $trace->table = self::normalise_name( $table ); return $trace; } public function file( ?string $file ): self { $trace = clone $this; $trace->file = self::normalise_name( $file ); return $trace; } /** Positions this chunk in the transfer and records which attempt is running it. */ public function chunk( int $chunk_id, int $chunk_total, int $attempt = 1 ): self { $trace = clone $this; $trace->chunk_id = $chunk_id; $trace->chunk_total = $chunk_total; $trace->attempt = $attempt; return $trace; } /** Milliseconds since this trace was opened. */ public function duration_ms(): int { return max( 0, (int) round( ( microtime( true ) - $this->started_at ) * 1000 ) ); } /** * The context to hand the logger, plus stage-specific extras. Extras merge * first so a caller can never overwrite identity fields; encode() flattens nested values. * * @param array $extra * @return array */ public function context( array $extra = [] ): array { $context = $extra; $identity = [ 'direction' => $this->direction, 'role' => $this->role, 'migration_id' => $this->migration_id, 'transfer_uuid' => $this->transfer_uuid, 'stage' => $this->stage, 'table' => $this->table, 'file' => $this->file, 'chunk_id' => $this->chunk_id, 'chunk_total' => $this->chunk_total, 'attempt' => $this->attempt, ]; // A zero migration id means the sender has not said yet. Unset fields are dropped too: empty values look like matches when grepping logs. if ( ! $this->migration_id ) { unset( $identity['migration_id'] ); } $context = array_merge( $context, $identity, [ 'duration_ms' => $this->duration_ms() ] ); return self::bound( array_filter( $context, static fn( mixed $value ): bool => null !== $value ) ); } /** JSON-encodes a context inside the byte budget, dropping extras newest-first and always keeping identity fields. */ public static function encode( array $context ): string { // The logger passes raw arrays, so flatten rather than trust the caller; a nested value could be a decoded remote body. $context = self::flatten( $context ); $original = $context; $identity = array_intersect_key( $context, array_flip( self::IDENTITY_KEYS ) ); $extras = array_diff_key( $context, $identity ); $encoded = wp_json_encode( $extras + $identity ); while ( ( false === $encoded || strlen( $encoded ) > self::MAX_CONTEXT_BYTES ) && $extras ) { array_pop( $extras ); $encoded = wp_json_encode( $extras + $identity ); } if ( false === $encoded || strlen( $encoded ) > self::MAX_CONTEXT_BYTES ) { return wp_json_encode( self::identity_only( $original ) ); } return $encoded; } /** Trims a log message to the byte budget without splitting a character. */ public static function message( string $message ): string { if ( strlen( $message ) <= self::MAX_MESSAGE_BYTES ) { return $message; } $cut = Formatter::cut_utf8( $message, self::MAX_MESSAGE_BYTES - 3 ); return rtrim( $cut ) . '…'; } /** * Reduces an array to scalars so nothing nested ends up in the log. * * @param array $context * @return array */ private static function flatten( array $context ): array { $flat = []; foreach ( $context as $key => $value ) { if ( null === $value ) { continue; } $key = self::normalise_name( (string) $key ); if ( null === $key ) { continue; } $flat[ $key ] = self::flatten_value( $value ); } return $flat; } private static function flatten_value( mixed $value ): mixed { if ( is_scalar( $value ) ) { return is_string( $value ) ? self::clip( $value ) : $value; } // Log only the type, since the content may be a credential or customer data. return is_object( $value ) ? get_class( $value ) : gettype( $value ); } /** * Clips oversized values, keeping scalars and dropping the rest. * * @param array $context * @return array */ private static function bound( array $context ): array { $bounded = []; foreach ( $context as $key => $value ) { if ( is_string( $value ) && strlen( $value ) > self::MAX_CONTEXT_BYTES ) { continue; } $bounded[ $key ] = $value; } return $bounded; } /** * @param array $context * @return array */ private static function identity_only( array $context ): array { $preserved = array_intersect_key( $context, array_flip( self::IDENTITY_KEYS ) ); return $preserved + [ 'truncated' => true ]; } private static function clip( string $value ): string { if ( strlen( $value ) <= self::MAX_VALUE_BYTES ) { return $value; } return rtrim( Formatter::cut_utf8( $value, self::MAX_VALUE_BYTES - 3 ) ) . '…'; } /** Keeps a correlation key only if it is a 32-hex token like the transfer registry issues. */ private static function normalise_uuid( ?string $uuid ): ?string { if ( null === $uuid || '' === $uuid ) { return null; } return preg_match( '/^[a-f0-9]{32}$/', $uuid ) ? $uuid : null; } /** A table or file name for a log row, stripped of null bytes and line breaks and clipped. */ private static function normalise_name( ?string $name ): ?string { if ( null === $name || '' === $name ) { return null; } $name = str_replace( [ "\0", "\n", "\r", "\t" ], '', $name ); if ( '' === $name ) { return null; } return self::clip( $name ); } }