Skip to main content

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é.
Une ligne riche se filtre et se regroupe sur n’importe lequel de ses champs (« tous les 5xx de /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.
annotate ne prend que des scalaires. Un objet ou une réponse d’API dans un log gonfle le volume et finit par y faire entrer des données personnelles.

Le lien avec le replay

Studio et open initialisent posthog-js avec tracing_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.
Le message est une phrase fixe en anglais, l’erreur toujours sous 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/.
À l’arrêt du conteneur (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 completed par page rendue (reqId, method, path, status, durationMs), au niveau que mérite le statut, l’erreur sous err si le rendu plante ; les assets statiques n’en écrivent pas ;
  • export vers PostHog Logs sous le service.name wrappy-landing, wrappy-open ou wrappy-studio quand POSTHOG_LOGS_ENABLED=true et que le token et l’hôte sont fournis au runtime ;
  • même arrêt propre au SIGTERM et même ligne fatal au plantage.
Le serveur pose un 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.