The Cypress command cy.log is bad. Not like "ohh, it will print nothing unless you understand the asynchronous nature of Cypress command chains", but in the sense "cy.log does not do what you expect it to do, and when it does it is broken" kind-of-bad. Let me explain.
cy.log problems
Let's say, the page has an element with the user's age. You want to print the age to the Command Log. You might try doing the following:
1 | it('logs the user age (NOT)', () => { |

You might think that chaining .log command at the end of the chain:
1 | it('logs the user age (NOT)', () => { |
Right, it does nothing.

Ok, my trick to print the current subject is to assert it.
1 | it('logs the user age (NOT)', () => { |

Tip: the more assertions you sprinkle through your command chains, the less test flake you will have.
Ok, that works, here is where cy.log can stub you again. Let's say you want to log the text before converting it to a number. You might think that adding .then(cy.log) into the chain would work.
1 | it('logs the user age (NOT)', () => { |

"Get out of here!" screams the cy.log. Weird, right? Why is the "age" zero? Let's replace .then(cy.log) with .then(console.log)
1 | it('logs the user age (NOT)', () => { |
Strange, this works correctly.

Ohhh, yes. Cypress v10 broke cy.then + cy.log combination. The command cy.then yields the original subject IF the callback function yields undefined. For some reason, Cypress v10 switched cy.log from returning undefined to returning null, breaking all the code in my tests.
cy.log does not try too hard
Or at all. The cy.log command is not a query command, thus it will break any retries. Here is a test that works - it automatically retries getting the property name of the given object until the assertion passes.
1 | it('retries (NOT)', () => { |

If we insert cy.log between the object and the cy.its command, our retries will break.
1 | cy.wrap(person).log().its('name').should('equal', 'Ann') |

Same if we use cy.wrap(person).cy.then(cy.log) - cy.then is not a query and stops retries.
Do not try to log an object
Let's say you spied on the network call and watch to log the server response object.
1 | it('logs the object (NOT)', () => { |
The endpoint returns an array of small objects
1 | [ |
Here is how cy.log prints them

Unfortunately, you cannot control serialization. My trick is to use an assertion again with the custom long truncate threshold.
1 | chai.config.truncateThreshold = 200 |

cy.print to the rescue
Cypress v12 has introduced the concept of query commands, which are a big deal. I have written an entire NPM package cypress-map of useful queries you can start using today. The latest addition to cypress-map is the cy.print command. Install the plugin as a dev dependency:
1 | $ npm i -D cypress-map |
Let's see if we can just insert cy.print into our first test.
1 | it('prints the user age', () => { |

Even better, we can format the message using printf syntax, and the current subject value is inserted automatically
1 | it('prints the user age', () => { |

Let's say we want to log the array from the network intercept. By default cy.print serializes even long objects.
1 | cy.wait('@users').its('response.body').print().should('be.an', 'array') |

But we probably do not want to see the entire array. Maybe we want to just see its length, and maybe the first object. cy.print lets you access the properties inside using {0.} notation.
1 | cy.wait('@users') |

Finally, cy.print is a query, so it can be part of the chain that is retried, and it prints the current value.
1 | it('retries', () => { |

Bonus feature: pass your own callback to return the string to print. For anything more complicated, you can pass the callback to cy.print to return the string to print into the Command Log. Want to print just the first names from the array returned by the intercept?
1 | cy.visit('cypress/log-examples.html') |

Nice!