← Blog
Node.js15 min czytania

LogTape — logger bez zależności, gotowy dla bibliotek

Biblioteka, która loguje, zwykle narzuca swój logger albo nie loguje wcale. LogTape rozwiązuje to jednym rozgraniczeniem: biblioteka wywołuje logger, ale nigdy go nie konfiguruje. Zero zależności, sześć poziomów, hierarchiczne kategorie, redakcja danych wrażliwych w dwóch trybach, adaptery przekierowujące logi do winstona lub Pino oraz pakiet reguł lintera, który pilnuje, żeby nie zniszczyć logowania strukturalnego.

Jest w ekosystemie JavaScriptu problem, którego nie widać, dopóki nie napisze się biblioteki przeznaczonej dla kogoś innego. Biblioteka, która chce logować, ma tylko złe opcje. Może wciągnąć własnego loggera jako zależność i narzucić go każdemu, kto ją zainstaluje. Może przyjmować obiekt loggera w konfiguracji i wymagać, żeby użytkownik go dostarczył. Może pisać na konsolę, ignorując wszystkie ustawienia aplikacji. Albo — najczęściej — może nie logować wcale, i wtedy przy problemie na produkcji jest czarną skrzynką.

LogTape (github.com/dahlia/logtape) rozwiązuje to jednym rozgraniczeniem, tak prostym, że dziwi jego brak w starszych bibliotekach: biblioteka wywołuje logger i loguje, ile chce, ale nigdy go nie konfiguruje. Konfiguracja należy wyłącznie do aplikacji. Do tego zero zależności i ten sam kod działający w Node.js, Denie, Bunie, przeglądarce i funkcjach brzegowych.

Stan projektu

Dane z API GitHuba i npm na 4 października 2026:

  • 1990 gwiazdek, repozytorium założone 16 kwietnia 2024, kod w TypeScripcie, licencja MIT,
  • 1,55 miliona pobrań z npm w ostatnim miesiącu przy zaledwie dziesięciu otwartych zgłoszeniach,
  • wersja 2.3.4 opublikowana w dniu pisania tego tekstu,
  • zero zależności produkcyjnych — sprawdzone w metadanych paczki, nie tylko w README,
  • dystrybucja równolegle przez npm i JSR, monorepozytorium z dwudziestoma pięcioma paczkami.

Jedna rzecz w historii commitów zasługuje na wyróżnienie, bo przy projekcie o tej skali jest niespotykana. Tego samego dnia ukazały się trzy wydania: 2.3.4, 2.2.7 i 2.1.11 — czyli poprawki zostały przeniesione na trzy równolegle utrzymywane linie wydań, z osobnymi gałęziami utrzymaniowymi. Większość bibliotek tej wielkości ma jedną gałąź i zasadę „zaktualizuj się do najnowszej". Tu ktoś świadomie wziął na siebie koszt backportów, i to jest argument za stabilnością mocniejszy niż liczba gwiazdek.

Filozofia biblioteczna: jedno zdanie, które zmienia wszystko

Dokumentacja stawia to jako ostrzeżenie już w rozdziale wprowadzającym: jeśli piszesz bibliotekę, nie konfiguruj w niej LogTape'a — to należy do aplikacji. W bibliotece robisz tylko jedno:

import { getLogger } from "@logtape/logtape";

const logger = getLogger(["moja-biblioteka", "baza-danych"]);

logger.info("Połączenie z bazą ustanowione", {
  host: dbHost,
  port: dbPort,
  username: dbUser,
});

I to wszystko. Jeśli aplikacja nie skonfigurowała LogTape'a, te wywołania nic nie robią — nie rzucają wyjątku, nie zaśmiecają konsoli, nie wymagają niczego. Jeśli skonfigurowała, dostaje kompletny, ustrukturyzowany ślad z Twojej biblioteki, przefiltrowany według swoich reguł i skierowany do swoich odbiorników.

Zalecenia dla autorów bibliotek są krótkie i warte cytowania w każdym przewodniku dla współpracowników: zaczynaj kategorie nazwą swojej biblioteki, żeby uniknąć kolizji; używaj poziomów rozsądnie — poziom błędu rezerwuj na faktyczne błędy; dostarczaj kontekst przez dane strukturalne, a nie przez sklejanie tekstu.

Hierarchia kategorii, czyli sterowanie gadatliwością

Kategoria to tablica łańcuchów znaków, na przykład ["moja-aplikacja", "moduł"]. Mechanizm rozsyłania jest przy tym odwrotny do intuicji i warto go zrozumieć dokładnie: zapis trafia do wszystkich loggerów, których kategorie są przedrostkami kategorii zapisu. Log z kategorii ["app", "moduł", "podmoduł"] zostanie obsłużony także przez logger ["app"] i ["app", "moduł"].

Na tym stoi cała kontrola nad gadatliwością:

await configure({
  sinks: {
    console: getConsoleSink(),
    file:    getFileSink("app.log"),
  },
  loggers: [
    { category: ["app"],           lowestLevel: "info",  sinks: ["file"] },
    { category: ["app", "moduł"],  lowestLevel: "debug", sinks: ["console"] },
  ],
});

Efekt jest dokładnie taki, jakiego się chce w praktyce: zapis poziomu diagnostycznego z modułu idzie tylko na konsolę, a zapis informacyjny — i na konsolę, i do pliku, bo spełnia także próg loggera nadrzędnego. Dziedziczenie odbiornika działa więc warunkowo: odbiornik rodzica jest dziedziczony tylko wtedy, gdy poziom zapisu spełnia próg rodzica. Ta jedna reguła odpowiada na najczęstsze pytanie przy konfiguracji logowania — dlaczego moje logi diagnostyczne nagle zalały plik produkcyjny.

Praktyczna konsekwencja dla nas: skoro biblioteki mają logować pod własnymi kategoriami, to podniesienie gadatliwości jednej biblioteki na produkcji przy diagnozowaniu awarii jest zmianą jednej linii konfiguracji, bez restartu poziomu logowania całej aplikacji.

Poziomy i trzy sposoby logowania

Poziomów jest sześć: trace, debug, info, warning, error, fatal. Zwróć uwagę na trzeci od końca — jest to warning, nie warn. Kto przechodzi z winstona albo z konsoli, potknie się o to raz i zapamięta.

Logować można w trzech stylach, a różnica między nimi nie jest kosmetyczna:

// 1. Tagowany literał szablonowy — wartości stają się danymi strukturalnymi
logger.debug `Użytkownik ${userId} zalogował się z ${ipAddress}.`;

// 2. Wiadomość i obiekt danych
logger.info("Użytkownik zalogowany", { userId, username, loginTime });

// 3. Same dane strukturalne, bez wiadomości
logger.info({ userId, username, loginTime });

Pierwszy styl jest tym, co wyróżnia LogTape'a: wygląda jak zwykła interpolacja, ale nią nie jest. Metody logowania są funkcjami tagującymi literał szablonowy, więc wartości podstawień nie są sklejane w tekst — trafiają do zapisu jako osobne pola strukturalne. Dostajesz czytelną wiadomość dla człowieka i dane nadające się do wyszukiwania w systemie logów, z jednej linii kodu.

Leniwe wyliczanie

Dwa problemy, jedna funkcja. Pierwszy jest oczywisty: nie chcesz płacić za serializację dużego obiektu, gdy logowanie diagnostyczne jest wyłączone. Drugi jest subtelniejszy i dokumentacja opisuje go na przykładzie z aplikacji jednostronicowej: kontekst przypisany przy tworzeniu loggera jest statyczny, więc dane użytkownika doczytane po inicjalizacji nigdy nie pojawią się w logach.

Funkcja lazy(), dostępna od wersji 2.0, odkłada wyliczenie wartości do momentu logowania — więc kontekst jest pobierany w chwili powstania zapisu, nie w chwili utworzenia loggera. Przy długo żyjących procesach i przy kontekście, który dojrzewa w trakcie życia aplikacji, jest to różnica między logiem użytecznym a logiem z pustymi polami.

Redakcja danych wrażliwych w dwóch trybach

Osobna paczka @logtape/redaction daje dwa podejścia, które trzeba rozróżniać, bo działają na różnych etapach i mają różne słabości.

Redakcja po wzorcu owija formater i skanuje jego wyjście wyrażeniami regularnymi. W paczce są gotowe wzorce, w tym EMAIL_ADDRESS_PATTERN i JWT_PATTERN:

const sink = getConsoleSink({
  formatter: redactByPattern(defaultConsoleFormatter, [
    EMAIL_ADDRESS_PATTERN,
    JWT_PATTERN,
  ]),
});

Redakcja po nazwie pola owija natomiast odbiornik i czyści właściwości w zapisie, zanim ten do niego dotrze — domyślnie według zestawu DEFAULT_REDACT_FIELDS obejmującego typowe nazwy w rodzaju password, secret czy token:

const sink = redactByField(getConsoleSink());

Różnica jest istotna: wzorce łapią wrażliwe dane gdziekolwiek się pojawią, także wklejone w tekst wiadomości, ale kosztują przeszukiwanie każdego wyjścia i mogą trafić za dużo. Nazwy pól są tanie i precyzyjne, ale chronią tylko dane strukturalne — hasło sklejone w tekst wiadomości przejdzie przez nie bez zmian.

Dokumentacja dokłada do tego ostrzeżenie, którego zwykle w opisach takich funkcji brakuje: żaden system redakcji nie jest doskonały, a lepiej nie logować danych wrażliwych w ogóle, niż liczyć na ich wyczyszczenie. To zdanie warto pokazać zespołowi przy pierwszym wdrożeniu redakcji, bo istnienie takiej funkcji łatwo pomylić z rozwiązaniem problemu.

Adaptery, czyli droga bez migracji

Ta funkcja rozstrzyga najczęstsze zastrzeżenie: „nie będę zmieniał całego logowania w aplikacji, żeby użyć jednej biblioteki". Nie musisz. Adaptery przekierowują wszystkie logi LogTape'a do loggera, którego już używasz:

import { install } from "@logtape/adaptor-winston";
import winston from "winston";

const logger = winston.createLogger({ /* istniejąca konfiguracja */ });

install(logger);

Jedno wywołanie i logi z bibliotek korzystających z LogTape'a płyną Twoim istniejącym potokiem, w Twoim formacie, do Twojego systemu zbierania logów. Adaptery są dla winstona, Pino, log4js i Bunyana — czyli praktycznie całego zastanego krajobrazu logowania w Node.js. Migracja do LogTape'a staje się opcjonalna i możliwa krok po kroku, a nie warunkiem wstępnym.

Odbiornik to funkcja

README obiecuje „odbiorniki banalnie proste" i to nie jest chwalenie się — cały interfejs rozszerzeń mieści się w jednym aliasie typu:

export type Sink = (record: LogRecord) => void;

To wszystko. Odbiornik jest funkcją przyjmującą zapis, więc własny da się napisać wprost w konfiguracji, bez klasy, bez rejestracji i bez implementowania interfejsu:

await configure({
  sinks: {
    console(record) {
      console.log(record.message);
    },
  },
  // reszta pominięta
});

Tak samo wygląda formater konsolowy — funkcja zwracająca tablicę argumentów przekazywanych potem do metod konsoli. Ma to bardzo praktyczne znaczenie w projekcie klienckim: wysłanie logów do systemu, którego nikt nie przewidział — wewnętrznego kolektora, kolejki, webhooka na Slacku — to kilka linii w konfiguracji, a nie szukanie gotowego transportu i sprawdzanie, czy jeszcze jest utrzymywany. Dokumentacja dodaje przy tym uczciwą uwagę o rdzeniu: sam LogTape dostarcza tylko odbiorniki konsolowy i strumieniowy, a wszystko inne jest osobną paczką albo Twoim kodem.

Umiejętność dla asystentów w paczce

Wątek, który przewija się przez tę serię od kilku wpisów, dostaje tu kolejną odsłonę — i najbliższą naszej codziennej pracy. LogTape dostarcza w paczce umiejętność dla asystentów AI zgodną ze standardem Agent Skills, obok pliku llms.txt z ogólnym opisem biblioteki. Wskazywane narzędzia to między innymi Claude Code, GitHub Copilot, Cursor i Windsurf.

Najciekawsza jest lista tego, czego ta umiejętność uczy, bo pokrywa się dokładnie z pułapkami wypisanymi w tym wpisie: składni z nazwanymi podstawieniami, a nie interpolacji łańcuchów; zasady, że w kodzie biblioteki nigdy nie wywołuje się configure(); zarządzania kontekstem przez with(), withContext() i lazy(); konfiguracji odbiorników i formaterów; integracji z winstonem, Pino i log4js; wzorców testowych oraz — wprost — typowych błędów, których należy unikać.

Instalacja idzie przez narzędzie skanujące katalog zależności w poszukiwaniu paczek deklarujących umiejętności i tworzące odpowiednie odnośniki w katalogach asystentów. Kierunek jest wart odnotowania: autor biblioteki wie o niej więcej niż model, więc zamiast liczyć na to, że asystent zgadnie API, dokłada instrukcję do paczki. W poprzednim wpisie tej serii widzieliśmy wariant tej samej idei z podpisanymi artefaktami OCI; tutaj jest ona sprowadzona do rzeczy, którą można zrobić w każdym projekcie od zaraz.

Dwadzieścia pięć paczek

Rdzeń ma zero zależności, bo wszystko poza nim jest osobną paczką. Instalujesz tylko to, czego używasz:

  • odbiorniki — file, otel (OpenTelemetry), sentry, syslog, cloudwatch-logs, windows-eventlog,
  • formatowanie — pretty dla czytelnego wyjścia w terminalu,
  • integracje z frameworkami — express, fastify, hono, koa, elysia, graphql-yoga oraz drizzle-orm, z automatycznym logowaniem żądań HTTP, serwera GraphQL i zapytań do bazy,
  • testowanie — testing plus osobne integracje dla node, bun, deno i vitest. Cztery paczki na testy to sygnał, że o sprawdzalności logów pomyślano poważnie, a nie na końcu,
  • konfiguracja i higiena — config dla konfiguracji obiektowej oraz lint, o którym niżej,
  • adaptery do czterech starszych loggerów.

Reguły lintera dla logowania

Paczka @logtape/lint jest pomysłem, którego nie widziałem w żadnej innej bibliotece logującej: reguły dla ESLinta, Oxlinta i Deno Lint wykrywające typowe błędy użycia już na etapie pisania kodu. Reguł jest pięć, z przemyślanymi domyślnymi surowościami:

  • no-message-interpolation — poziom błędu. Najważniejsza z całego zestawu: pilnuje, żeby nie sklejać wartości w tekst wiadomości, bo to niszczy logowanie strukturalne, zamieniając pola nadające się do wyszukiwania w nierozróżnialny łańcuch znaków,
  • no-unawaited-log — poziom błędu, z poprawką warunkową,
  • prefer-lazy-evaluation — ostrzeżenie, z automatyczną poprawką,
  • require-meta-sink — ostrzeżenie,
  • no-dynamic-message — domyślnie wyłączona, dostępna w zestawie surowym.

Sam wybór, co jest błędem, a co ostrzeżeniem, jest tu wart odnotowania: autor uznał zniszczenie logowania strukturalnego za błąd, a nieoptymalną wydajność za ostrzeżenie. To właściwa kolejność priorytetów i dobrze, że jest wymuszona narzędziem, nie prośbą w dokumentacji.

Pułapki

  • warning, nie warn — pierwsze potknięcie każdego, kto przechodzi z innego loggera,
  • Nie wywołuj configure() w bibliotece. To nie jest sugestia stylistyczna: konfiguracja w bibliotece odbiera aplikacji kontrolę nad jej własnymi logami,
  • Bez konfiguracji w aplikacji logi po prostu nie istnieją. Zaleta dla bibliotek, zaskoczenie dla osoby, która pierwszy raz uruchamia aplikację i nie widzi niczego na konsoli,
  • Dziedziczenie odbiorników jest warunkowe — odbiornik rodzica dostaje zapis tylko wtedy, gdy jego poziom spełnia próg rodzica. Nieznajomość tej reguły daje albo brakujące logi, albo zalany plik,
  • Redakcja po nazwach pól nie chroni tekstu wiadomości. Hasło wpisane w treść przejdzie; do tego potrzebne są wzorce,
  • Redakcja nie zwalnia z myślenia — dokumentacja mówi to sama,
  • Kontekst jest statyczny, dopóki nie użyjesz lazy(), co przy długo żyjących procesach daje puste pola tam, gdzie spodziewasz się danych,
  • Wersja główna zmieniała się już dwa razy w dwa lata (1.0, potem 2.0). Trzy utrzymywane linie wydań to ratunek, ale przy aktualizacji warto przeczytać notatki, a nie tylko podnieść numer,
  • Projekt jednego autora. Dwadzieścia pięć paczek, trzy gałęzie utrzymaniowe i dziesięć otwartych zgłoszeń wskazują na wysoką dyscyplinę, ale ryzyko jednej osoby pozostaje ryzykiem jednej osoby,
  • Integracje dotyczą frameworków, których w naszej pracy zwykle nie ma. Express, Fastify, Hono, Koa i Elysia to lista przydatna, jeśli piszesz backend w JavaScripcie — a nie, jeśli backend stoi na Laravelu.

Gdzie to ma sens w naszej pracy

Rozgraniczenie trzeba postawić uczciwie: LogTape nie ma nic do zaoferowania aplikacji laravelowej, która ma Monolog i skonfigurowane kanały. Miejsc, w których się przydaje, jest jednak w naszych projektach więcej, niż się wydaje na pierwszy rzut oka:

  • Serwer renderowania po stronie serwera. Proces Node.js obsługujący SSR Inertii jest miejscem, w którym awarie są najtrudniejsze do zdiagnozowania, bo nie ma tam ani logów Laravela, ani konsoli przeglądarki. Logger z kategoriami i odbiornikiem plikowym rozwiązuje to w kilka linii,
  • Skrypty budujące i narzędzia wewnętrzne. Wszystko, co uruchamiamy w Node.js poza aplikacją — generatory, migratory danych, integracje uruchamiane z crona,
  • Funkcje brzegowe i workery. Ten sam kod loggera działa w środowisku brzegowym, gdzie większość loggerów napisanych dla Node.js nie startuje,
  • Paczki, które sami publikujemy. Jeśli wydzielamy wspólny kod do prywatnej paczki npm używanej w kilku projektach klienta, to jest dokładnie ten przypadek, dla którego LogTape zaprojektowano: paczka loguje, a każdy projekt decyduje, co z tym zrobić,
  • Odbiornik do Sentry albo OpenTelemetry bez pisania własnego transportu — jedna paczka i wpis w konfiguracji,
  • Testy. Cztery paczki testowe pozwalają sprawdzić w teście, że kod faktycznie zalogował to, co miał — a to jedyny sposób, żeby logi nie zgniły po pierwszej refaktoryzacji.

Podsumowanie

  • Biblioteka loguje, aplikacja konfiguruje — jedno rozgraniczenie, na którym stoi cały projekt,
  • Zero zależności w rdzeniu, a każda dodatkowa funkcja to osobna paczka z dwudziestu pięciu,
  • Bez konfiguracji wywołania loggera nic nie robią — biblioteka może logować bezpiecznie,
  • Kategorie są tablicami, a zapis trafia do loggerów o kategoriach będących jego przedrostkami; dziedziczenie odbiornika jest warunkowane progiem rodzica,
  • Sześć poziomów, i to warning, nie warn,
  • Tagowany literał szablonowy nie skleja tekstu — podstawienia stają się polami strukturalnymi,
  • lazy() odkłada wyliczenie do chwili logowania, co ratuje zarówno wydajność, jak i kontekst dojrzewający w trakcie życia procesu,
  • Redakcja ma dwa tryby: wzorce na wyjściu formatera i nazwy pól przed odbiornikiem. Używaj obu świadomie i nie ufaj żadnemu bezgranicznie,
  • Adaptery przekierowują logi do winstona, Pino, log4js i Bunyana — można korzystać z bibliotek na LogTape bez migracji własnego logowania,
  • Reguły lintera traktują zniszczenie logowania strukturalnego jako błąd, a nie jako kwestię gustu,
  • Odbiornik to jedna funkcja, więc wysłanie logów w dowolne miejsce to kilka linii w konfiguracji,
  • W paczce jest umiejętność dla asystentów AI ucząca dokładnie tych zasad, o które najłatwiej się potknąć,
  • Trzy równolegle utrzymywane linie wydań — poprawki tego samego dnia w 2.1, 2.2 i 2.3.

Licencja: LogTape jest na MIT, w wariancie bez żadnych komplikacji: wolno używać komercyjnie, modyfikować, wpinać w produkty zamknięte i redystrybuować, przy zachowaniu noty licencyjnej i tekstu licencji. Nie ma tu open core, edycji korporacyjnej ani funkcji za paywallem — wszystkie dwadzieścia pięć paczek monorepozytorium jest na tej samej licencji, włącznie z odbiornikami do usług komercyjnych i regułami lintera. Warto natomiast zwrócić uwagę na coś, co przy tej konstrukcji ma większe znaczenie praktyczne niż sama licencja: zero zależności produkcyjnych w rdzeniu oznacza, że dodanie loggera nie wnosi do projektu ani jednej cudzej licencji, ani jednego cudzego łańcucha zależności do audytu — a to przy przeglądzie u klienta z procedurami bywa argumentem cięższym niż lista funkcji. Osobno pamiętaj, że paczki integracyjne wnoszą zależności właściwe swoim celom: odbiornik do CloudWatch wymaga SDK Amazona, odbiornik do Sentry — ich klienta, a adaptery — obecności odpowiedniego loggera. Rdzeń pozostaje czysty, ale rachunek licencyjny robi się przy każdej dołożonej paczce od nowa.