Pasaron cosas: logging efectivo para saber qué sucedió







PyConAr 2020

Martín Gaitán | @tin_nqn_

Sobre mí

  • Python, locro y paternidad
  • Soy ingeniero y docente, laburo en ShipHero
  • Vivo en Anisacate, Córdoba
  • Me llevo bien con mis hermanos

image

Agenda

  • ¿Para qué loggear?
  • Logging 101
  • Structlog
  • Buenas prácticas

¿Logging? ¿Para qué?

  • Planificar en el presente para tener, en el futuro, información útil de ejecuciones (pasadas).
  • Nos permiten debuggear un problema específico, obtener información de uso, trazabilidad, etc.
  • Y también cuantificar para priorizar tareas

Logging 101

  • Logger: expone la interfaz que se usa desde nuestro código.
  • Handler: envía los log records (registros) (creados desde loggers) al destino apropiado (stdout, archivo, red, etc.)
  • Filter: excluye o transforma registros.
  • Formatter: plantilla para la representación de salida de los registros.

Cada registros, los loggers y los handlers tiene asociado un nivel de severidad,

Existe un método que emite registros con su respectivo nivel logger.debug(), logger.info()...

Y los logger tienen un nivel efectivo necesario.

In [1]:
import logging
In [2]:
root = logging.getLogger()    
root
Out[2]:
<RootLogger root (WARNING)>

El logger por defecto "root" tiene nivel mínimo WARNING

In [3]:
root.info("Hola PyConAr")     # info < warning => ignorado
In [4]:
root.warning("Dije hola PyConAr!")
Dije hola PyConAr!

Pero también los Handler tienen nivel

In [5]:
otro_logger = logging.getLogger("ejemplo")    
otro_logger.setLevel(logging.INFO)     # nivel del logger

sh = logging.StreamHandler()
sh.setLevel(logging.WARNING)           # nivel del handler

otro_logger.addHandler(sh)
In [7]:
# alcanza el nivel del logger pero no el del handler
otro_logger.info("Te la debo, no estoy en tema")    # el registro se crea, pero no se maneja en el handler  
In [8]:
otro_logger.warning("veniamos bien, pero pasaron cosas")
veniamos bien, pero pasaron cosas

Recordemos que un logger puede tener varios handlers

In [9]:
fh = logging.FileHandler('charla.log')
fh.setLevel(logging.INFO)

# le agregamos este nuevo Handler al logger que ya teniamos
otro_logger.addHandler(fh)
In [10]:
# esto no va a stdout pero sí al archivo
otro_logger.info("deberían tener angustia de tomar la decisión, querido Rey, de separarse de España")
In [11]:
!cat charla.log
deberían tener angustia de tomar la decisión, querido Rey, de separarse de España

Nombres y propagación

  • Cuando instanciamos un logger con getLogger podemos darle un nombre
  • El nombre determina el namespace (separado por puntos).
  • Logs loggers "hijos" heredan la configuración (y pueden sobrecargarla)

La convención es usar la jerarquía completa del módulo

logger = logging.getLogger(__name__)
In [12]:
# reusamos la config del namespace "ejemplo" que tenia `otro_logger`

logger = logging.getLogger('ejemplo.hijo')     
logger.info("Hay lugares donde falta el agua, y lugares donde sobra")   
In [13]:
!cat charla.log
deberían tener angustia de tomar la decisión, querido Rey, de separarse de España
Hay lugares donde falta el agua, y lugares donde sobra

propagate=True pasa el registro a "loggers padres".

Filtros

Transformar o descartar registros

In [14]:
class ShortMsgFilter(logging.Filter):

    def filter(self, record):
        msg = record.msg
        if isinstance(msg, str):
            return len(msg) > 10
        return False
    
logger.addFilter(ShortMsgFilter())
logger.warning("hola")
In [15]:
logger.warning("buscaremos otros jueces que nos representen")
buscaremos otros jueces que nos representen

¿Cómo configuramos?

In [1]:
import logging.config

DEFAULT_LOGGING = {
    'version': 1,
    'disable_existing_loggers': True,  
    'loggers': {
        'ejemplo': {
            'level': 'DEBUG',
            'handlers': ["console_handler"]
        },
    }, 
    "handlers": {
        'console_handler': {
            'level': 'DEBUG',
            'formatter': 'info',
            'class': 'logging.StreamHandler',
        },
    },
    "formatters": {
        "info": {
            'format': "%(asctime)s [%(levelname)s]  %(message)s"
        },
    }
}

logging.config.dictConfig(DEFAULT_LOGGING)
In [2]:
logger = logging.getLogger("ejemplo")
logger.debug("No logramos lograr ese logro")
2020-10-26 02:02:34,519 [DEBUG]  No logramos lograr ese logro

¿Pero no es muy complicado todo esto?

  • La configuración no es trivial
  • Los default son malos
  • ¡Ni siquiera respeta PEP8!

Structlog es a logging lo que requests a httplib

It makes logging in Python less painful and more powerful by adding structure to your log entries.

-- https://structlog.org

In [1]:
import structlog

# drop-in replacement! 
logger = structlog.get_logger()     
logger.info("El oceáno es muy grande y el submarino es muy pequeño")
2020-10-26 11:19.00 El oceáno es muy grande y el submarino es muy pequeño

Lo importante: La mayoría de las veces necesitamos registrar datos

(Ejemplos reales del código de mi trabajo, permiso)

In [ ]:
logger.info(
    "Reserving inventory for (%s, %d, %d, %d, %d, %d)",
    sku,
    warehouse_id,
    account_id,
    priority,
    available_inventory,
    required_quantity,
)
In [ ]:
logger.warning(
    "LineItem points to an non-existent SKU\n"
    "LineItem (id, sku, account_id): (%d, %s, %s)\n"
    "Order (id, order_number): (%d, %s)",
    li.id,
    li.sku,
    li.account_id,
    li.order.id,
    li.order.order_number,
)
In [ ]:
logger.info(
    "kit_allocations: %r",
    [
        {
            "sku": li.get("sku"),
            "quantity": li.get("quantity"),
            "quantity_pending_fulfillment": li.get("quantity_pending_fulfillment"),
            "quantity_pending_allocation": li.get("quantity_pending_allocation"),
        }
        for li in kit_allocations
    ],
)

La manera de structlog es la manera pythonica: keyword arguments!

In [2]:
logger.error("Iniciamos conversaciones", interlocutor="FMI", tipo_acuerdo="stand-by", monto=52e9, moneda="usd")
2020-10-26 11:19.09 Iniciamos conversaciones       interlocutor=FMI moneda=usd monto=52000000000.0 tipo_acuerdo=stand-by

In [ ]:
structlog.configure

Procesadores

En structlog los formatters y filters se unifican en processors,

In [10]:
structlog.configure(
    processors=[
        structlog.stdlib.add_log_level,
        structlog.processors.TimeStamper(fmt="iso"),
        structlog.processors.JSONRenderer()
    ]
)

logger = structlog.get_logger()
logger.warning("todas las tormentas juntas", tipo_conferencia="grabada", lira_turca=25.3)
{"tipo_conferencia": "grabada", "lira_turca": 25.3, "event": "todas las tormentas juntas", "level": "warning", "timestamp": "2020-10-26T04:10:34.829984Z"}

JSONRenderer usa json.dumps by default (configurable) pero es inteligente

In [13]:
class Periodo:
    def __repr__(self):
        return "50 años"   

p = Periodo()
In [14]:
import json 
json.dumps(p)  
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
<ipython-input-14-b4c9add0615f> in <module>
      1 import json
----> 2 json.dumps(p)

/usr/lib/python3.7/json/__init__.py in dumps(obj, skipkeys, ensure_ascii, check_circular, allow_nan, cls, indent, separators, default, sort_keys, **kw)
    229         cls is None and indent is None and separators is None and
    230         default is None and not sort_keys and not kw):
--> 231         return _default_encoder.encode(obj)
    232     if cls is None:
    233         cls = JSONEncoder

/usr/lib/python3.7/json/encoder.py in encode(self, o)
    197         # exceptions aren't as detailed.  The list call should be roughly
    198         # equivalent to the PySequence_Fast that ''.join() would do.
--> 199         chunks = self.iterencode(o, _one_shot=True)
    200         if not isinstance(chunks, (list, tuple)):
    201             chunks = list(chunks)

/usr/lib/python3.7/json/encoder.py in iterencode(self, o, _one_shot)
    255                 self.key_separator, self.item_separator, self.sort_keys,
    256                 self.skipkeys, _one_shot)
--> 257         return _iterencode(o, 0)
    258 
    259 def _make_iterencode(markers, _default, _encoder, _indent, _floatstr,

/usr/lib/python3.7/json/encoder.py in default(self, o)
    177 
    178         """
--> 179         raise TypeError(f'Object of type {o.__class__.__name__} '
    180                         f'is not JSON serializable')
    181 

TypeError: Object of type Periodo is not JSON serializable
In [15]:
logger.info("el mejor equipo", periodo=p)
{"periodo": "50 a\u00f1os", "event": "el mejor equipo", "level": "info", "timestamp": "2020-10-26T04:20:58.496938Z"}
In [16]:
class PeriodoReal(C):
    def __structlog__(self):
        return "hasta el 11 de agosto"

logger.info("gobierno economico", hasta=PeriodoReal())
{"hasta": "hasta el 11 de agosto", "event": "gobierno economico", "level": "info", "timestamp": "2020-10-26T04:22:28.040188Z"}

Formatos de salida

structlog tiene procesadores "amigables" con el desarrollo

In [3]:
import structlog

structlog.configure( 
        processors=[ 
            structlog.stdlib.add_log_level, 
            structlog.processors.TimeStamper(fmt="%Y-%m-%d %H:%M.%S"), 
            structlog.dev.ConsoleRenderer() 
        ]
) 
logger = structlog.get_logger() 

logger.info("es todo mentira", tema="autopistas")
2020-10-26 14:40.10 [info     ] es todo mentira                tema=autopistas

En producción: json

  • JSON un formato amigable con humanos, y universal para compartir datos.
  • No hace falta regex ni nada para parsear, vienen con estructura y tipo de datos básicos!
  • Muchos sistemas habilitan busqueda avanzada

**Ejemplo de Cloudwatch

Contextos

In [1]:
%%writefile a_module.py

from structlog import get_logger

logger = get_logger()

def some_function():
    logger.error("escuchas", tipo="ilegales", respuesta="cuentapropistas") 
Overwriting a_module.py
In [8]:
import sys
import logging
import structlog
from a_module import some_function

logging.basicConfig(
    format="%(message)s", stream=sys.stdout, level=logging.INFO
)
structlog.configure(
    processors=[
        structlog.processors.TimeStamper(fmt="iso"),
        structlog.processors.KeyValueRenderer(
            key_order=["timestamp", "event"]
        ),
    ],
    context_class=structlog.threadlocal.wrap_dict(dict),
    logger_factory=structlog.stdlib.LoggerFactory(),
)
In [9]:
def entrevista():
    log = logger.new(periodista="amigo")     # set a new context. any futher event from the logger will get x for free
    log.warning("inicio entrevista")
    log = log.bind(sobre=4)   # update the context
    some_function()                 
    
    log = log.unbind("periodista")
    log.warning("final entrevista")
    
entrevista()
timestamp='2020-10-26T04:58:01.123994Z' event='inicio entrevista' periodista='amigo'
timestamp='2020-10-26T04:58:01.125263Z' event='escuchas' periodista='amigo' sobre=4 tipo='ilegales' respuesta='cuentapropistas'
timestamp='2020-10-26T04:58:01.126142Z' event='final entrevista' sobre=4

Consejos / Buenas Prácticas

  • A veces loggeamos mucho pero no siempre efectivamente.

    • Loggear transiciones de estado
    • Mientras más "raro" el evento, mayor nivel y detalle
    • ¡Quitar ruido!
  • Es barato pero no gratis.

    • Usar bien los niveles y filtros.
    • No duplicar handlers
    • Delegar todo lo que se pueda a la "infra" (storage, rotación de archivos)
  • Además de timestamp, agregar info de trazabilidad: request_id, thread_id, user_id, etc.

    • Timestamps en UTC
    • Tokens de trazabilidad únicos
  • Structlog es fácil, poderoso y pythónico: úsenlo!

    • también miren Loguru
  • Configuración

    • Tan tarde como se pueda, antes de que comience a ejecutarse tu código
    • Procurá que se haga una única vez
    • Adaptarse a convenciones del framework (si existen)
    • Podés reconfigurar loggers de terceros
    • Structlog justo despues de logging estándar, via stdlib.LoggerFactory
  • Usen barbijo y cuídense

Caricias significativas con el codo

image