Le principe
L’API ne raconte pas ce que fait son code, elle dit ce qui est arrivé à chaque requête. Une requête produit une seule ligne,request completed, écrite à sa sortie et qui réunit tout ce qu’on
a appris en la traitant : qui, quoi, combien de temps, comment ça s’est terminé.
/wrappys/:id/send pour ce compte »), là où dix lignes éparses obligent à recoller les morceaux.
Le circuit d’une requête
annotate retrouve la requête en cours par contextStorage de Hono : un service l’appelle sans
recevoir de logger. Hors requête (planificateur, script), il n’y a pas de ligne de fin, et
annotate écrit alors son contexte sur une ligne info à part.
Le lien avec le replay
Studio et open initialisent posthog-js avectracing_headers sur l’hôte de l’API : chaque appel
porte X-POSTHOG-SESSION-ID et X-POSTHOG-DISTINCT-ID (autorisés par le CORS de l’API). La ligne de
fin les reprend sous sessionId et posthogDistinctId, les noms que PostHog Logs reconnaît : depuis
une ligne, on ouvre le replay de la session et la personne. posthogDistinctId vaut l’id du compte
quand il est connecté — celui que le studio identifie — et l’id anonyme du navigateur sinon (un
destinataire sur open).
Les appels faits par le serveur du studio (SSR) n’ont pas de session : ces deux champs y sont vides.
Les niveaux
La ligne de fin prend son niveau du statut de la réponse :
Les incidents s’écrivent sur une ligne à part, en
warn (inattendu sans échec) ou error (quelqu’un
doit agir) : litige bancaire, remboursement sans commande, envoi échoué après paiement. C’est aussi
le cas de ce qui se passe après la réponse (promesses lancées sans attendre) ou hors requête : la
ligne de fin est déjà partie.
err : c’est la seule clé que pino
sérialise avec son message et sa stack.
Où partent les logs
L’export vers PostHog se fait dans le process (
packages/observability/src/logger.ts) : un
LoggerProvider OpenTelemetry avec un BatchLogRecordProcessor envoie les logs par lots à
${POSTHOG_HOST}/i/v1/logs, authentifiés par le token projet (phc_…). Le niveau pino devient la
sévérité OTel, le nom du service (wrappy-api) l’attribut de ressource service.name, les autres champs les
attributs du log.
Pas de transport pino pour cet export : un transport tourne dans un worker qui charge son module
depuis
node_modules, absent de l’image de prod qui ne copie que dist/.SIGTERM), shutdownLogs() envoie le dernier lot avant de quitter. Un
plantage hors requête (uncaughtException, unhandledRejection) écrit une ligne fatal
process crashed avec l’erreur, envoie le dernier lot, puis quitte en code 1.
Les serveurs SSR des fronts
Landing, open et studio sont servis par le même serveur Bun (packages/shared/src/server/production-server.ts).
Il reçoit de l’app le logger de @wrappy/observability/logger et suit les mêmes règles que l’API :
- une ligne
request completedpar page rendue (reqId,method,path,status,durationMs), au niveau que mérite le statut, l’erreur souserrsi le rendu plante ; les assets statiques n’en écrivent pas ; - export vers PostHog Logs sous le
service.namewrappy-landing,wrappy-openouwrappy-studioquandPOSTHOG_LOGS_ENABLED=trueet que le token et l’hôte sont fournis au runtime ; - même arrêt propre au
SIGTERMet même lignefatalau plantage.
x-request-id sur chaque requête. Pendant le rendu, studio et open le
transmettent à leurs appels à l’API : la page SSR et la ligne API qu’elle a déclenchée partagent le
même reqId.
Le navigateur
Le navigateur n’écrit pas de logs. Ce qu’il fait passe par PostHog : les événements produit, le replay, et les exceptions (capture_exceptions, plus captureException dans les écrans d’erreur).
Une requête API qui échoue est déjà loguée par l’API, rattachée au replay par sessionId : la
reloguer côté navigateur la compterait deux fois.
Sur open, un lien inconnu ou expiré (404 de l’API) affiche la page introuvable ; tout autre échec
de la route $token est un incident, remonté à PostHog avec un écran « Quelque chose s’est mal
passé ».
Ce qui ne sort pas
Pino masque en[REDACTED] les clés sensibles avant toute écriture, à la racine et un niveau plus
bas : password, token, accessToken, refreshToken, apiKey, secret, email, mail, plus
l’en-tête authorization. Le masquage est un filet, pas une permission : les corps de requête et de réponse ne
sont jamais logués, ni les données personnelles. Pour rattacher une ligne à un compte, userId
suffit.