L’ajout de logs dans une application est essentiel. Ceux-ci permettent entre autres d’identifier les erreurs survenues dans celle-ci, de créer des pistes d’audit, de fournir des informations sur l’application ou encore sur le comportement des utilisateurs, bref la liste peut être encore longue. Et vous savez quoi ? Ben, aujourd’hui on ne va pas du tout parler de la bonne utilisation des logs, mais plutôt partir à la découverte d’une librairie qui va nous faciliter la création de ceux-ci 😜.
Le bon vieux module console
La façon, la plus courante de logguer son application Node.js consiste à utiliser les méthodes du module console
. Celui-ci fournit plusieurs méthodes :
console.log
: Affiche des messages informatifs sur la sortie standard (stdout
) ;console.info
etconsole.debug
: Il s’agit d’alias deconsole.log
;console.error
: Affiche des messages d’erreur sur la sortie standard d’erreur (stderr
) ;console.warn
: Il s’agit d’un alias deconsole.error
;console.trace
: Affiche des messages informatifs avec la trace d’exécution sur la sortie standard d’erreur (stderr
).
Voyons un petit exemple :
1 2 3 4 5 6 7 8 9 10 | function consoleMethods() { console.log('log message') console.info('info message') console.debug('debug message') console.error('error message') console.warn('warn message') console.trace('trace message') } consoleMethods() |
Les messages suivants s’affichent dans la console :
1 2 3 4 5 6 7 8 9 10 11 12 13 | log message info message debug message error message warn message Trace: trace message at consoleMethods (file:///D:/dev/perso/ts/labs/src/main.js:7:11) at file:///D:/dev/perso/ts/labs/src/main.js:10:1 at ModuleJob.run (node:internal/modules/esm/module_job:195:25) at async Promise.all (index 0) at async ESMLoader.import (node:internal/modules/esm/loader:337:24) at async loadESM (node:internal/process/esm_loader:88:5) at async handleMainPromise (node:internal/modules/run_main:61:12) |
La persistance des logs est importante pour une future analyse de ceux-ci. On peut donc par exemple les enregistrer dans un fichier via une redirection :
1 | node src\main.js > app.log |
On se retrouve donc avec un fichier app.log
, dont voici le contenu :
1 2 3 | log message info message debug message |
On remarque qu’il nous manque les logs des méthodes error
, warn
et trace
. Comme je l’ai dit, ces méthodes affichent les messages sur la sortie standard d’erreur (stderr
). Nous pouvons donc rediriger cette sortie dans un autre fichier error.log
:
1 | node src\main.js > app.log 2> error.log |
Notez que le 2
avant le chevron >
représente la sortie d’erreur standard. On a donc un fichier error.log
avec le contenu suivant :
1 2 3 4 5 6 7 8 9 10 | error message warn message Trace: trace message at consoleMethods (file:///D:/dev/perso/ts/labs/src/main.js:7:11) at file:///D:/dev/perso/ts/labs/src/main.js:10:1 at ModuleJob.run (node:internal/modules/esm/module_job:195:25) at async Promise.all (index 0) at async ESMLoader.import (node:internal/modules/esm/loader:337:24) at async loadESM (node:internal/process/esm_loader:88:5) at async handleMainPromise (node:internal/modules/run_main:61:12) |
Bien que le module console
soit utile, celui-ci n’est pas adapté pour la production et présente de nombreuses lacunes :
- Gestion du niveau de gravité des messages ;
- Formatage des logs (par exemple au format JSON);
- Horodatages ;
- Gestion de plusieurs destinations des logs (fichiers, serveur de logs ou base de données)
- etc.
C’est pourquoi il devient indispensable de se diriger vers une librairie nous offrant ces fonctionnalités. Il en existe plusieurs plus ou moins connues dans l’écosystème Node.js :
C’est cette dernière qui nous intéresse dans cet article.
Pino c’est quoi ?
Pino est une librairie de logging inspiré de Bunyan (du moins au début) qui a vu le jour en 2016. Information totalement inutile, mais son nom n’a pas toujours été Pino puisqu’elle s’appelait au début Sermon. L’un de ses développeurs est Matteo Collina que vous devez sûrement connaître si vous vous intéressez à l’écosystème Node.js puisqu’il est membre du comité technique de Node.js et l’un des créateurs de Fastify (et de plus de 400 autres librairies, rien que ça).
Le but premier de Pino est de fournir une librairie de logging axé sur les performances. On n’y pense pas toujours, mais les logs peuvent avoir un impact sur les performances de nos applications. Pour la petite anecdote, l’un de mes clients avait des problèmes de performance sur son service Node.js et l’un des problèmes était la librairie de logs fait maison qui a chaque appel à la méthode de log, divisée par deux les performances de l’application.
Bref, ne perdons pas de temps et voyons un petit exemple d’utilisation de Pino. Commençons tout d’abord par son installation :
1 | npm install pino |
Créons un fichier main.js
:
1 2 3 4 | import pino from 'pino' const logger = pino() logger.info('hello world') |
Lançons ensuite notre application :
1 | node main.js |
Et tadam ! Le message suivant s’affiche dans votre terminal :
1 | {"level":30,"time":1661595397712,"pid":33412,"hostname":"DESKTOP-MF8Q884","msg":"hello world"} |
Petit tour d’horizon
Il est temps de faire un petit tour d’horizon de Pino et découvrir les fonctionnalités que celui-ci nous offre.
Niveau de gravité
Tous les messages de logs n’ont pas le même niveau d’importance, c’est pourquoi nous avons besoin de niveau de gravité pour ceux-ci.
Par défaut il existe 7 niveaux gravité possédant chacun une valeur numérique et une méthode associée :
trace
(valeur10
) : Généralement utilisé pour le débogage détaillé de l’application. Vous pouvez l’utiliser pour afficher les différentes instructions d’une fonction ou les étapes d’un algorithme par exemple ;debug
(valeur20
) : Utilisé pour diagnostiquer les problèmes de l’application ou pour s’assurer que tout fonctionne correctement lors de l’exécution dans l’environnement de test ;info
(valeur30
) : Utilisé pour informer des changements d’état de l’application ou de fournir des informations sur son utilisation (démarrage du serveur HTTP, connexion à la base de données réussie, etc.) ;warn
(valeur40
) : Utilisé lorsqu’un problème mineur se produit sans mettre en péril le fonctionnement de l’application (connexion impossible à un service tiers, mais avec tentative de reconnexion, paramétrage manquant, mais gérer par l’application, etc.) ;error
(valeur50
) : Utilisé lorsqu’un problème important se produit lors d’une opération, mais sans mettre en péril le fonctionnement de l’application (impossibilité de se connecter à un service tiers malgré les tentatives de reconnexion, erreur dans une transaction, etc.) ;fatal
(valeur60
) : Utilisé pour indiquer une défaillance globale de l’application qui empêche celle-ci de fonctionner normalement (état incohérent, paramétrages critiques manquants, etc.) ;silent
(valeurInfinity
) : Utilisé pour désactiver l’ensemble des logs.
Il est utile de ne pas enregistrer certains niveaux de logs suivant l’environnement d’exécution ou pour des raisons de performances. C’est pourquoi ces niveaux possèdent un ordre hiérarchique croissant permettant d’enregistrer ou non les logs suivant le niveau minimum choisi :
[wpdatatable id=1 table_view=regular]
Lors de l’initialisation de Pino, nous pouvons fournir l’option level
permettant de définir le niveau de gravité minimum (par défaut il est défini sur info
). Voici un exemple, avec le niveau définit à trace
permettant d’afficher l’ensemble des niveaux :
1 2 3 4 5 6 7 8 9 10 11 12 | import pino from 'pino' const logger = pino({ level: 'trace' }) logger.trace('trace message') logger.debug('debug message') logger.info('info message') logger.warn('warn message') logger.error('error message') logger.fatal('fatal message') |
On obtient le résultat suivant :
1 2 3 4 5 6 | {"level":10,"time":1661611787938,"pid":25572,"hostname":"DESKTOP-MF8Q884","msg":"trace message"} {"level":20,"time":1661611787938,"pid":25572,"hostname":"DESKTOP-MF8Q884","msg":"debug message"} {"level":30,"time":1661611787938,"pid":25572,"hostname":"DESKTOP-MF8Q884","msg":"info message"} {"level":40,"time":1661611787938,"pid":25572,"hostname":"DESKTOP-MF8Q884","msg":"warn message"} {"level":50,"time":1661611787938,"pid":25572,"hostname":"DESKTOP-MF8Q884","msg":"error message"} {"level":60,"time":1661611787938,"pid":25572,"hostname":"DESKTOP-MF8Q884","msg":"fatal message"} |
Utilisons maintenant la valeur warn
:
1 2 3 4 5 6 7 8 9 10 11 | import pino from 'pino' const logger = pino({ level: 'warn' }) logger.trace('trace message') logger.debug('debug message') logger.info('info message') logger.warn('warn message') logger.error('error message') logger.fatal('fatal message') |
Comme vous pouvez le voir, uniquement les messages concernant les niveaux warn
, error
et fatal
sont affichés :
1 2 3 | {"level":40,"time":1661611944933,"pid":34676,"hostname":"DESKTOP-MF8Q884","msg":"warn message"} {"level":50,"time":1661611944934,"pid":34676,"hostname":"DESKTOP-MF8Q884","msg":"error message"} {"level":60,"time":1661611944934,"pid":34676,"hostname":"DESKTOP-MF8Q884","msg":"fatal message"} |
Niveaux personnalisés
Il est également possible d’ajouter des niveaux personnalisés via l’option customLevels
. Il s’agit d’un objet ayant comme clé le nom du niveau et comme valeur, la valeur numérique de celui-ci :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 | import pino from 'pino' const logger = pino({ level: 'trace', customLevels: { critical: 55, boom: 70 } }) // Il est toujours possible d'appeler les méthodes correspondant aux niveaux par défaut logger.trace('trace message') logger.debug('debug message') logger.info('info message') logger.warn('warn message') logger.error('error message') logger.fatal('fatal message') logger.critical('critical message') logger.boom('BOOM !') |
On obtient le résultat suivant :
1 2 3 4 5 6 7 8 | {"level":10,"time":1661687086997,"pid":17964,"hostname":"DESKTOP-MF8Q884","msg":"trace message"} {"level":20,"time":1661687086997,"pid":17964,"hostname":"DESKTOP-MF8Q884","msg":"debug message"} {"level":30,"time":1661687086997,"pid":17964,"hostname":"DESKTOP-MF8Q884","msg":"info message"} {"level":40,"time":1661687086997,"pid":17964,"hostname":"DESKTOP-MF8Q884","msg":"warn message"} {"level":50,"time":1661687086997,"pid":17964,"hostname":"DESKTOP-MF8Q884","msg":"error message"} {"level":60,"time":1661687086997,"pid":17964,"hostname":"DESKTOP-MF8Q884","msg":"fatal message"} {"level":55,"time":1661687086999,"pid":17964,"hostname":"DESKTOP-MF8Q884","msg":"critical message"} {"level":70,"time":1661687086999,"pid":17964,"hostname":"DESKTOP-MF8Q884","msg":"BOOM !"} |
Il est possible de n’utiliser que les niveaux personnalisés via l’option useOnlyCustomLevels
. Les méthodes correspondant aux niveaux par défaut ne seront pas disponibles :
1 2 3 4 5 6 7 8 9 10 11 12 | import pino from 'pino' const logger = pino({ level: 'critical', // Nous devons définir le niveau minimum sur un niveau personnalisé customLevels: { critical: 55 }, useOnlyCustomLevels: true }) logger.critical('critical message') logger.fatal('fatal message') // TypeError: logger.fatal is not a function |
Masquage d’informations
Parfois il est nécessaire de masquer certaines informations sensibles dans les logs. Pino offre cette possibilité via l’option redac
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 | import pino from 'pino' const redact = { paths: ['body.password', 'headers.cookie'], // On définit le chemin des clés à masquer censor: '** HIDDEN **' // La chaîne de caractère à afficher à la place de la valeur } const logger = pino({ redact }) logger.info({ body: { username: 'john', password: 'password' }, headers: { host: 'http://example.com', cookie: "We don't want this exposed in logs" } }) // {"level":30,"time":1661640925320,"pid":27672,"hostname":"DESKTOP-MF8Q884","body":{"username":"john","password":"** HIDDEN **"},"headers":{"host":"http://example.com","cookie":"** HIDDEN **"}} |
Il y a également la possibilité de supprimer les données des logs via l’option remove
de l’objet redact
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 | import pino from 'pino' const redact = { paths: ['body.password', 'headers.cookie'], remove: true } const logger = pino({ redact }) logger.info({ body: { username: 'john', password: 'password' }, headers: { host: 'http://example.com', cookie: `We don't want this exposed in logs` } }) // {"level":30,"time":1661640627410,"pid":17068,"hostname":"DESKTOP-MF8Q884","body":{"username":"john"},"headers":{"host":"http://example.com"}} |
Logger enfant
Pino a un concept de logger enfant permettant de spécialiser un logger pour un sous-composant de votre application, c’est-à-dire permettre d’avoir des propriétés dans les messages de logs propres au sous-composant.
Imaginons que nous ayons un module mailer
et que l’on souhaite afficher dans les logs que ceux-ci proviennent de ce module. Plutôt que d’ajouter cette information manuellement dans chacun des logs nous pouvons utiliser un logger enfant :
1 2 3 4 5 6 7 8 9 10 11 | import pino from 'pino' const logger = pino() const mailerLogger = logger.child({ module: 'mailer' // Affichera "module":"mailer" dans chaque message de log }) mailerLogger.info('message inside mailer') // {"level":30,"time":1661641414746,"pid":42620,"hostname":"DESKTOP-MF8Q884","module":"mailer","msg":"message inside mailer"} |
Streams (destinations) et transports
Jusqu’à maintenant les logs étaient affichés sur la sortie standard. Comment faire pour enregistrer ceux-ci dans un fichier ou les envoyer vers une plateforme de centralisation de logs ? Pino offre deux possibilités, l’utilisation des streams (appelés destinations dans Pino) et les transports.
Les streams (destinations)
Les streams (destinations) sont des flux qui écrivent les données des logs qu’ils reçoivent vers une destination qui peut être par exemple un fichier. Il s’agit simplement de streams d’écriture au sens Node.js du terme. Par défaut la destination est la sortie standard (stdout
).
Voyons dès à présent comment écrire dans un fichier :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | import pino from 'pino' const logger = pino( { level: 'trace' }, pino.destination('./main.log')// On indique le chemin du fichier de log ) logger.trace('trace message') logger.debug('debug message') logger.info('info message') logger.warn('warn message') logger.error('error message') logger.fatal('fatal message') |
Pino fournit la méthode statique destination
permettant de créer un stream d’écriture beaucoup plus performant que celui présent dans le module stream
de Node.js.
On obtient donc le fichier main.log
suivant :
1 2 3 4 5 6 | {"level":10,"time":1661676448699,"pid":37024,"hostname":"DESKTOP-MF8Q884","msg":"trace message"} {"level":20,"time":1661676448699,"pid":37024,"hostname":"DESKTOP-MF8Q884","msg":"debug message"} {"level":30,"time":1661676448699,"pid":37024,"hostname":"DESKTOP-MF8Q884","msg":"info message"} {"level":40,"time":1661676448699,"pid":37024,"hostname":"DESKTOP-MF8Q884","msg":"warn message"} {"level":50,"time":1661676448699,"pid":37024,"hostname":"DESKTOP-MF8Q884","msg":"error message"} {"level":60,"time":1661676448699,"pid":37024,"hostname":"DESKTOP-MF8Q884","msg":"fatal message"} |
Nous pouvons également écrire les logs vers plusieurs destinations à l’aide de la méthode statique multistream
qui prend en paramètres un tableau de stream :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 | import pino from 'pino' const streams = [ { stream: pino.destination(1) // La méthode destination peut également prendre en paramètre un nombre (1 : stdout, 2: stderr) }, { stream: pino.destination('./main.log') } ] const logger = pino( { level: 'trace' }, pino.multistream(streams) ) logger.trace('trace message') logger.debug('debug message') logger.info('info message') logger.warn('warn message') logger.error('error message') logger.fatal('fatal message') |
Les logs sont ainsi affichés à la fois sur la sortie standard et enregistrés dans un fichier main.log
. Il est également possible de personnaliser le niveau de gravité minimum pour chacune des destinations :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 | import pino from 'pino' const streams = [ { level: 'info', stream: pino.destination(1) // La méthode destination peut également prendre en paramètre un nombre (1 : stdout, 2: stderr) }, { level: 'trace', stream: pino.destination('./main.log') } ] const logger = pino( { level: 'trace' // Doit être défini au niveau le plus bas des destinations }, pino.multistream(streams) ) logger.trace('trace message') logger.debug('debug message') logger.info('info message') logger.warn('warn message') logger.error('error message') logger.fatal('fatal message') |
On obtient ainsi sur la sortie standard les messages suivant :
1 2 3 4 | {"level":30,"time":1661677284909,"pid":37104,"hostname":"DESKTOP-MF8Q884","msg":"info message"} {"level":40,"time":1661677284909,"pid":37104,"hostname":"DESKTOP-MF8Q884","msg":"warn message"} {"level":50,"time":1661677284909,"pid":37104,"hostname":"DESKTOP-MF8Q884","msg":"error message"} {"level":60,"time":1661677284909,"pid":37104,"hostname":"DESKTOP-MF8Q884","msg":"fatal message"} |
et dans le fichier main.log
:
1 2 3 4 5 6 | {"level":10,"time":1661677284909,"pid":37104,"hostname":"DESKTOP-MF8Q884","msg":"trace message"} {"level":20,"time":1661677284909,"pid":37104,"hostname":"DESKTOP-MF8Q884","msg":"debug message"} {"level":30,"time":1661677284909,"pid":37104,"hostname":"DESKTOP-MF8Q884","msg":"info message"} {"level":40,"time":1661677284909,"pid":37104,"hostname":"DESKTOP-MF8Q884","msg":"warn message"} {"level":50,"time":1661677284909,"pid":37104,"hostname":"DESKTOP-MF8Q884","msg":"error message"} {"level":60,"time":1661677284909,"pid":37104,"hostname":"DESKTOP-MF8Q884","msg":"fatal message"} |
Bien que l’écriture des logs en utilisant les streams s’effectue de manière asynchrone, celle-ci est gérée par le processus principal et utilise donc la même boucle d’événements. La multiplication des destinations peut donc poser des soucis de performances, c’est là que les transports entrent en jeu.
Les transports
Le traitement des logs que ce soit leurs formatages, leurs transferts vers une destination distante ou l’utilisation de plusieurs destinations devrait, pour des raisons de performances, être séparé du processus principal. C’est justement ce que proposent les transports qui utilisent en interne les worker threads
.
Reprenons l’exemple d’écriture des logs dans un fichier, mais utilisons cette fois-ci un transport :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | import pino from 'pino' const transport = { target: 'pino/file', options: { destination: './main.log' } } const logger = pino({ level: 'trace', transport }) logger.trace('trace message') logger.debug('debug message') logger.info('info message') logger.warn('warn message') logger.error('error message') logger.fatal('fatal message') |
On obtient bien le fichier main.log
suivant :
1 2 3 4 5 6 | {"level":10,"time":1661680594953,"pid":32084,"hostname":"DESKTOP-MF8Q884","msg":"trace message"} {"level":20,"time":1661680594953,"pid":32084,"hostname":"DESKTOP-MF8Q884","msg":"debug message"} {"level":30,"time":1661680594953,"pid":32084,"hostname":"DESKTOP-MF8Q884","msg":"info message"} {"level":40,"time":1661680594953,"pid":32084,"hostname":"DESKTOP-MF8Q884","msg":"warn message"} {"level":50,"time":1661680594953,"pid":32084,"hostname":"DESKTOP-MF8Q884","msg":"error message"} {"level":60,"time":1661680594953,"pid":32084,"hostname":"DESKTOP-MF8Q884","msg":"fatal message"} |
Utilisons maintenant plusieurs transports pour afficher les logs sur la sortie standard et dans un fichier main.log
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 | import pino from 'pino' const transport = { targets: [ { level: 'info', // Nous devons définir le niveau minimum de gravité pour chacune des cibles target: 'pino/file', options: { destination: 1 } // On affiche les lods sur la sortie standard }, { level: 'trace', target: 'pino/file', options: { destination: './main.log' } // On enregistre les logs dans un fichier main.log } ] } const logger = pino({ level: 'trace', transport }) logger.trace('trace message') logger.debug('debug message') logger.info('info message') logger.warn('warn message') logger.error('error message') logger.fatal('fatal message') |
On obtient le même résultat qu’avec les destinations. On a les messages suivant sur sortie standard :
1 2 3 4 | {"level":30,"time":1661677284909,"pid":37104,"hostname":"DESKTOP-MF8Q884","msg":"info message"} {"level":40,"time":1661677284909,"pid":37104,"hostname":"DESKTOP-MF8Q884","msg":"warn message"} {"level":50,"time":1661677284909,"pid":37104,"hostname":"DESKTOP-MF8Q884","msg":"error message"} {"level":60,"time":1661677284909,"pid":37104,"hostname":"DESKTOP-MF8Q884","msg":"fatal message"} |
et dans le fichier main.log
:
1 2 3 4 5 6 | {"level":10,"time":1661677284909,"pid":37104,"hostname":"DESKTOP-MF8Q884","msg":"trace message"} {"level":20,"time":1661677284909,"pid":37104,"hostname":"DESKTOP-MF8Q884","msg":"debug message"} {"level":30,"time":1661677284909,"pid":37104,"hostname":"DESKTOP-MF8Q884","msg":"info message"} {"level":40,"time":1661677284909,"pid":37104,"hostname":"DESKTOP-MF8Q884","msg":"warn message"} {"level":50,"time":1661677284909,"pid":37104,"hostname":"DESKTOP-MF8Q884","msg":"error message"} {"level":60,"time":1661677284909,"pid":37104,"hostname":"DESKTOP-MF8Q884","msg":"fatal message"} |
Il existe de nombreux modules implémentant des transports permettant par exemple d’envoyer les logs sur des plateformes de centralisations :
pino-elasticsearch
: Permets d’envoyer les logs sur une instance d’Elasticsearch ;pino-eventhub
: Permets d’envoyer les logs sur la plateforme Event Hub de Microsoft Azure ;pino-socket
: Permets d’envoyer les logs vers une destination TCP ou UDP ;pino-mongodb
: Permets de stocker les logs dans une base de données MongoDB ;- etc .
Mais les transports offrent également la possibilité de formater les logs comme nous allons le voir.
Format des messages
Les messages de logs sont au format JSON (NDJSON pour être exact) donc si on logue un message qui est une chaîne de caractères et un autre un objet comme suit :
1 2 3 4 5 6 7 8 9 10 11 | import pino from 'pino' const logger = pino() logger.info('info message') logger.info({ hi: { value: 'Hello world !' }, name: 'John Doe' }) |
Nous obtenons le résultat suivant :
1 2 | {"level":30,"time":1661623090217,"pid":40276,"hostname":"DESKTOP-MF8Q884","msg":"info message"} {"level":30,"time":1661623090218,"pid":40276,"hostname":"DESKTOP-MF8Q884","hi":{"value":"Hello world !"},"name":"John Doe"} |
Les données contenues dans le message JSON sont les suivantes :
level
: Il s’agit de la valeur numérique du niveau de gravité du log ;time
: Il s’agit du timestamp en millisecondes du log ;pid
: Il s’agit de l’identifiant de processus de notre application Node.js ;hostname
: Il s’agit du nom d’hôte de la machine sur laquelle l’application Node.js est exécutée ;msg
: Il s’agit simplement de la chaîne de caractères passée à l’une des méthodes de log ;- Lorsqu’un objet est passé en paramètre de l’une des méthodes de log, celui-ci est converti en chaîne JSON et ajouté au message du log.
Les transports nous permettent entre autres de modifier le contenu d’un message de logs, c’est d’ailleurs la méthode recommandée. Il existe ainsi quelques transports développés par l’équipe de Pino ou la communauté permettant de transformer nos messages de logs vers un autre format comme Syslog par exemple.
Mais nous pouvons également rendre les messages plus lisibles lors du développement ou même écrire notre propre transport.
Message plus lisible lors du développement
Le format JSON peut vite devenir lourd lors du développement c’est pourquoi on aimerait avoir des messages plus lisibles pour un être humain.
Nous allons pour cela nous intéresser au module pino-pretty
qui remplit parfaitement cette tâche. Vous pouvez également regarder, si vous êtes curieux, du côté de pino-colada
(oui les développeurs ont beaucoup d’imagination…).
Il existe trois façons d’utiliser pino-pretty
.
Utilisation de la CLI
La première, celle qui est recommandée, est de transmettre la sortie du programme Node.js à la commande pino-pretty
via l’opérateur pipe |
:
1 | node src\main.js | npx pino-pretty |
On obtient ainsi de jolis messages de logs :
Utilisation comme un transport
La seconde est de l’utiliser comme transport au sein de Pino. Nous avons besoin pour cela d’installer pino-pretty
:
1 | npm install -D pino-pretty |
Puis d’ajouter un transport comme option à notre logger :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | import pino from 'pino' const logger = pino({ level: 'trace', transport: { target: 'pino-pretty', options: { // Voir https://github.com/pinojs/pino-pretty#options } } }) logger.trace('trace message') logger.debug('debug message') logger.info('info message') logger.warn('warn message') logger.error('error message') logger.fatal('fatal message') |
Utilisation comme un stream (destination)
La dernière façon est de l’utiliser comme un stream. Nous avons également besoin d’installer pino-pretty
:
1 | npm install -D pino-pretty |
Et d’utiliser celui-ci comme suit :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 | import pino from 'pino' import pretty from 'pino-pretty' const stream = pretty({ // Voir https://github.com/pinojs/pino-pretty#options }) const logger = pino( { level: 'trace' }, stream ) logger.trace('trace message') logger.debug('debug message') logger.info('info message') logger.warn('warn message') logger.error('error message') logger.fatal('fatal message') |
Créer son propre transport de formatage
Nous allons créer notre propre transport permettant de formater un message de log. Pour cela nous aurons besoin de deux modules :
pino-abstract-transport
: Un module facilitant l’écriture des transports ;sonic-boom
: Un module fournissant un stream d’écriture performant.
Installons donc ces deux modules :
1 | npm install sonic-boom pino-abstract-transport |
Créons ensuite un fichier my-transport.js
et écrivons le code basique d’un transport :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 | import build from 'pino-abstract-transport' import SonicBoom from 'sonic-boom' import { once } from 'events' export default async function (opts) { // On crée notre destination (par défaut la sortie standard) const destination = new SonicBoom({ dest: opts.destination || 1, // On peut passer en option une destination (fichier ou autre). Par défaut c'est la sortie standard stdout sync: false }) // On attend que le stream soit prêt await once(destination, 'ready') return build( async function (source) { // obj est l'objet contenant les données d'un log for await (const obj of source) { // On transforme notre objet en chaîne de caractères et vers l'écrit dans la destination const toDrain = !destination.write(`${JSON.stringify(obj)}\n`) // Si le buffer du stream de destination est plain on attend que celui-ci se vide avant de continuer. if (toDrain) { await once(destination, 'drain') } } }, { async close() { // On ferme le stream de destination... destination.end() // et on attend la fermeture de celui-ci await once(destination, 'close') } } ) } |
Utilisons ensuite celui-ci :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 | import pino from 'pino' const transport = { target: './my-transport.js' } const logger = pino({ level: 'trace', transport }) logger.trace('trace message') logger.debug('debug message') logger.info('info message') logger.warn('warn message') logger.error('error message') logger.fatal('fatal message') /* {"level":10,"time":1661672479228,"pid":23868,"hostname":"DESKTOP-MF8Q884","msg":"trace message"} {"level":20,"time":1661672479228,"pid":23868,"hostname":"DESKTOP-MF8Q884","msg":"debug message"} {"level":30,"time":1661672479228,"pid":23868,"hostname":"DESKTOP-MF8Q884","msg":"info message"} {"level":40,"time":1661672479228,"pid":23868,"hostname":"DESKTOP-MF8Q884","msg":"warn message"} {"level":50,"time":1661672479228,"pid":23868,"hostname":"DESKTOP-MF8Q884","msg":"error message"} {"level":60,"time":1661672479228,"pid":23868,"hostname":"DESKTOP-MF8Q884","msg":"fatal message"} */ |
Vous pouvez modifier comme vous le souhaitez le format du log à écrire sur le stream de destination. Une version très simplifié de pino-pretty
pourrait par exemple ressembler à ceci :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 | import build from 'pino-abstract-transport' import SonicBoom from 'sonic-boom' import { once } from 'events' export default async function (opts) { // On créer notre destination (par défaut la sortie standard) const destination = new SonicBoom({ dest: opts.destination || 1, sync: false }) const levels = opts.levels || { 60: 'FATAL', 50: 'ERROR', 40: 'WARN', 30: 'INFO', 20: 'DEBUG', 10: 'TRACE' } // On définit notre chaîne de sortie et les clés à changer par les valeurs du log const outputFormat = '[#time] #level (#pid): #msg' const regex = /#time|#level|#pid|#msg/gi // On attend que le stream soit prêt await once(destination, 'ready') return build( async function (source) { // obj est l'objet contenant les données d'un log for await (const obj of source) { // On remplace les clés contenues dans la chaîne de sortie avec les valeurs contenues dans le log const ouputString = outputFormat.replace(regex, function (matched) { const key = matched.slice(1) let value = obj[key] if (key === 'time') { value = new Date(value).toISOString() } if (key === 'level') { value = levels[value] } return value }) const toDrain = !destination.write(`${ouputString}\n`) // Si le buffer du stream de destination est plain on attend que celui-ci se vide avant de continuer. if (toDrain) { await once(destination, 'drain') } } }, { async close() { // On ferme le stream de destination... destination.end() // et on attend la fermeture de celui-ci await once(destination, 'close') } } ) } |
Si nous testons notre nouveau transport, nous obtenons le résultat suivant :
1 2 3 4 5 6 | [2022-08-28T08:12:26.778Z] TRACE (23156): trace message [2022-08-28T08:12:26.779Z] DEBUG (23156): debug message [2022-08-28T08:12:26.779Z] INFO (23156): info message [2022-08-28T08:12:26.779Z] WARN (23156): warn message [2022-08-28T08:12:26.779Z] ERROR (23156): error message [2022-08-28T08:12:26.779Z] FATAL (23156): fatal message |
L’utilisation d’un transport est recommandée pour formater un log, puisque les modifications sont isolées du reste de l’instance du logger, elle ne concerne donc que le transport. Mais il est tout à faire possible de modifier les logs de manière globale comme nous allons le voir.
Afficher le label du niveau
Vous pouvez si vous le souhaitez, afficher le label du niveau à la place de sa valeur numérique. Pino accepte une option formatters
qui est un objet contenant des fonctions permettant de formater les données du log. Parmi ces fonctions nous avons la fonction level
qui permet de modifier le format du niveau :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | import pino from 'pino' const formatters = { level: (label, number) => { return { level: label // On renvoie le label du niveau plutôt que sa valeur numérique } } } const logger = pino({ formatters }) logger.info('info message') // {"level":"info","time":1661635988640,"pid":14980,"hostname":"DESKTOP-MF8Q884","msg":"info message"} |
Mais rien ne vous empêche également de changer la clé level
par une autre clé :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | import pino from 'pino' const formatters = { level: (label, number) => { return { severity: label.toUpperCase() } } } const logger = pino({ formatters }) logger.info('info message') // {"severity":"INFO","time":1661636110335,"pid":18528,"hostname":"DESKTOP-MF8Q884","msg":"info message"} |
Modifier le format de la date
Le format de la date peut également être modifié via l’option timestamp
. Il est possible soit de passer un booléen pour afficher ou non la date, soit une fonction qui renvoie une chaîne de caractères qui sera concaténé au contenu du message du log :
1 2 3 4 5 6 7 8 9 10 11 12 13 | import pino from 'pino' const logger = pino({ /* On définit la clé (ici time) précédée d'une virgule puisque cette chaîne sera concaténée avec le reste du message du log suivie de sa valeur (ici la date au format ISO) */ timestamp: () => `,"time":"${new Date().toISOString()}"` }) logger.info('info message') // {"level":30,"time":"2022-08-27T21:48:01.608Z","pid":13380,"hostname":"DESKTOP-MF8Q884","msg":"info message"} |
Pino fournit également des fonctions pour le formatage de la date. L’exemple suivant, produit le même résultat :
1 2 3 4 5 6 7 8 9 | import pino from 'pino' const logger = pino({ timestamp: pino.stdTimeFunctions.isoTime }) logger.info('info message') // {"level":30,"time":"2022-08-27T21:48:01.608Z","pid":13380,"hostname":"DESKTOP-MF8Q884","msg":"info message"} |
Sérialisation d’objets
Pino offre la possibilité de définir des fonctions permettant une sérialisation personnalisée d’objets via l’option serializers. Cette option est un objet dont les clés sont les propriétés des objets loggués pour lesquelles les fonctions de sérialisations associées devront être appliquées. Voyons un exemple :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 | import pino from 'pino' const serializers = { hi: ({ value }) => ({ value: value.toUpperCase() }), // Pino fournit des fonctions de sérialisation -> https://github.com/pinojs/pino/blob/master/docs/api.md#pino-stdserializers err: pino.stdSerializers.err } const logger = pino({ serializers }) logger.info({ hi: { value: 'Hello world' } }) logger.error(new Error('oops !')) // {"level":30,"time":1661638225474,"pid":23828,"hostname":"DESKTOP-MF8Q884","hi":{"value":"HELLO WORLD"}} // {"level":50,"time":1661638225474,"pid":23828,"hostname":"DESKTOP-MF8Q884","err":{"type":"Error","message":"oops !","stack":"Error: oops !\n at file:///D:/dev/perso/ts/labs/src/main.js:21:14\n at ModuleJob.run (node:internal/modules/esm/module_job:195:25)\n at async Promise.all (index 0)\n at async ESMLoader.import (node:internal/modules/esm/loader:337:24)\n at async loadESM (node:internal/process/esm_loader:88:5)\n at async handleMainPromise (node:internal/modules/run_main:61:12)"},"msg":"oops !"} |
La première fonction de sérialisation permet de modifier la propriété hi
de n’importe quel objet passé en paramètre d’une méthode de log.
La seconde, fournie par Pino, permet de sérialiser les erreurs en ne gardant que son type, son message et sa stack d’exécution.
D’autres fonctions de sérialisation sont fournies par Pino et permettent entre autres de traiter les objets correspondant aux requêtes et aux réponses HTTP.
Utilisation dans un framework HTTP
Pino s’intègre parfaitement avec les principaux frameworks HTTP. Il est par exemple de base intégré à Fastify :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 | import fastify from 'fastify' const app = fastify({ logger: true }) app.get('/', async (request, reply) => { request.log.info('info message') return { hello: 'world' } }) try { await app.listen({ port: 3000 }) } catch (err) { app.log.error(err) process.exit(1) } /* {"level":30,"time":1661684825028,"pid":40836,"hostname":"DESKTOP-MF8Q884","msg":"Server listening at http://127.0.0.1:3000"} {"level":30,"time":1661684825031,"pid":40836,"hostname":"DESKTOP-MF8Q884","msg":"Server listening at http://[::1]:3000"} {"level":30,"time":1661684828050,"pid":40836,"hostname":"DESKTOP-MF8Q884","reqId":"req-1","req":{"method":"GET","url":"/","hostname":"localhost:3000","remoteAddress":"::1","remotePort":55769},"msg":"incoming request"} {"level":30,"time":1661684828051,"pid":40836,"hostname":"DESKTOP-MF8Q884","reqId":"req-1","msg":"info message"} {"level":30,"time":1661684828055,"pid":40836,"hostname":"DESKTOP-MF8Q884","reqId":"req-1","res":{"statusCode":200},"responseTime":4.117699861526489,"msg":"request completed"} */ |
Mais il existe également des modules pour l’intégrer facilement à Express, Restify ou encore Nest.js. Je vous invite donc à aller lire la documentation si vous voulez plus d’information.
Benchmark
Je vous ai dit en début d’article que Pino était l’une des librairies de log Node.js la plus performante de l’écosystème. Mais à quel point ? Je vous laisse juger par vous-même avec le benchmark de la documentation officielle.
Pour finir…
Pino propose de nombreuses fonctionnalités intéressantes et utilise un minimum de ressources. L’équipe derrière est la même que Fastify et dispose d’un excellent suivi de la part de l’équipe de développement puisque Pino possède des versions LTS assurant un support sur le long terme.
Nous avons fait dans cet article, qu’un bref tour d’horizon de Pino, il n’est pas possible de tout couvrir dans un seul article, d’autres fonctionnalités existent c’est pourquoi je vous laisse le soin de poursuivre la découverte en allant lire la documentation officielle.
Je suis lead developer dans une boîte spécialisée dans l’univers du streaming/gaming, et en parallèle, je m’éclate en tant que freelance. Passionné par l’écosystème JavaScript, je suis un inconditionnel de Node.js depuis 2011. J’adore échanger sur les nouvelles tendances et partager mon expérience avec les autres développeurs.
Si vous avez envie de papoter, n’hésitez pas à me retrouver sur Twitter, m’envoyer un petit email ou même laisser un commentaire.