The event loop keeps a weak reference to a running task. If nothing else holds a strong reference, the task can be garbage collected mid-execution and simply stops, with no exception and often no log line. The standard library documentation states this explicitly and recommends keeping references.
The pattern that triggers it is fire-and-forget: asyncio.create_task(send_metrics()) with the return value discarded. It usually works under light load and fails intermittently when a collection happens at the wrong moment, which makes it very hard to reproduce.
The documented workaround is a module-level set: add the task to it, and add a done callback that discards it, so the set holds a strong reference exactly as long as the task runs. Since Python 3.11 the better answer is usually asyncio.TaskGroup, which owns its child tasks, waits for them, and propagates their exceptions. A discarded task also swallows failures: an exception in a task nobody awaits is only reported when the task object is finalised, as Task exception was never retrieved.