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 —
prettydla czytelnego wyjścia w terminalu, - integracje z frameworkami —
express,fastify,hono,koa,elysia,graphql-yogaorazdrizzle-orm, z automatycznym logowaniem żądań HTTP, serwera GraphQL i zapytań do bazy, - testowanie —
testingplus osobne integracje dlanode,bun,denoivitest. Cztery paczki na testy to sygnał, że o sprawdzalności logów pomyślano poważnie, a nie na końcu, - konfiguracja i higiena —
configdla konfiguracji obiektowej orazlint, 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, niewarn— 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, niewarn, - 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.