Core
Logger v1.x
2

Browser Logging #

kuzzle-logger/browser is a logger for frontend applications, with the same API as the Node logger. It prints entries to the devtools console and sends them, in batches, to the application's Kuzzle backend. The backend then writes them with its own logger, so browser logs end up in the same place as backend logs (stdout, Loki, Elasticsearch...).

Browser                         Kuzzle backend                        Transports
KuzzleLogger ── batches ──▶ browser-logs:push ──▶ app.log.child('browser') ──▶ Loki, ...

The browser never holds credentials for the log storage, and it cannot set labels: the backend validates, sanitizes and enriches every entry. See Ingesting browser logs in Kuzzle for the backend side.

The browser entry point is an ES module (dist/esm), meant to be used with a bundler such as Vite or webpack. It has no Node dependency.

Installation #

npm install kuzzle-logger

Creating the logger #

import { KuzzleLogger, createKuzzleSender } from 'kuzzle-logger/browser';
import { Kuzzle, WebSocket } from 'kuzzle-sdk';

const sdk = new Kuzzle(new WebSocket('api.example.com', { port: 443, ssl: true }));

await sdk.connect();

export const logger = new KuzzleLogger({
  console: import.meta.env.DEV ? true : 'warn',
  level: 'info',
  namespace: 'dashboard',
  sender: createKuzzleSender(sdk, {
    app: { name: 'dashboard', version: '1.2.0' },
  }),
});
OptionDefaultDescription
levelinfoMinimum level of the logged entries: trace, debug, info, warn, error, fatal or silent. It can be changed at runtime with logger.level = 'debug'.
consoletruePrints entries to the devtools console: true for every logged entry, false for none, or a level (entries at or above it). Only entries at or above level are printed.
namespace–Base namespace of the entries.
getMergingObject–Called on each log: its result is merged into the entry context (e.g. the current route). If it throws or does not return an object, the entry is logged without it.
sender–Where entries are sent. Without a sender, the logger only prints to the console.

Logging #

The API is the same as the Node logger:

logger.info('Dashboard loaded');
logger.debug({ assetId, duration }, 'Asset %s loaded', assetId);

try {
  await loadMap();
} catch (error) {
  logger.error(error, 'Failed to load the map');
  // or: logger.error({ err: error, mapId }, 'Failed to load the map');
}

const mapLogger = logger.child('map'); // namespace: "dashboard:map"
mapLogger.warn('No asset to display');
  • Namespaces may only contain letters, digits, :, _ and -, up to 64 characters in total (with the parent namespaces): other characters are replaced with _ (MapView.vue becomes MapView_vue) and longer namespaces are truncated. Keep them static, e.g. map, not map-${mapId}.
  • context and errors (with their cause chain) are kept up to 5 levels deep: deeper objects and arrays are replaced with [Object] and [Array].
  • Unlike console.log, extra arguments are only used for %s/%d/%o placeholders in the message: logger.info('Loaded', data) drops data. Write logger.info({ data }, 'Loaded').
  • A child logger follows the level of its parent, until its own level is set (child.level = 'debug').
  • namespace is a reserved key: a namespace field of the logged object (or of getMergingObject) sets the entry namespace and is not kept in context. When the logger has a namespace, the logged field is dropped. Use another name, e.g. logger.info({ tenantNamespace }, 'Loaded').

Errors are serialized with their name, message, stack, cause and custom properties. Each entry is sent with this shape (payload v1):

{
  "level": "error",
  "time": 1759312800000,
  "namespace": "dashboard:map",
  "msg": "Failed to load the map",
  "context": { "mapId": "site-42" },
  "err": {
    "name": "TypeError",
    "message": "x is undefined",
    "stack": "TypeError: x is undefined\n    at ..."
  }
}

Logging never throws, even when getMergingObject or the sender fails.

Senders #

Senders buffer entries and send them in batches, one at a time:

  • when a batch is full (maxBatchSize entries or maxBatchBytes bytes), or flushInterval after the first entry;
  • immediately for error and fatal entries;
  • when the page is hidden or closed (pagehide, visibilitychange), with fetch keepalive.

When sending fails #

  • A failed batch is retried with an exponential backoff (maxRetries, retryDelay), then dropped. The backend is told how many entries were dropped.
  • A rejected batch (4xx response, except 408 and 429) is dropped without retry.
  • After a dropped batch, error and fatal entries wait for flushInterval instead of being sent immediately, until a batch goes through again. An application that logs its failed requests at error level therefore does not start a request loop.
  • Senders never log, and entries logged while a batch is in flight go in the next batch: a failing backend cannot cause a logging loop.
  • When the page closes, every pending batch is sent at once, within the browser keepalive limit (64 KB in total). A batch still in flight is sent again, since the page may be gone before it completes: the backend can receive it twice.

Kuzzle SDK sender #

createKuzzleSender(sdk, options?) sends batches with an existing SDK instance (sdk.query), so requests are authenticated as the logged-in user, over WebSocket or HTTP.

OptionDefaultDescription
controllerbrowser-logsController the ingestion controller is registered under.
actionpushAction name.
keepaliveUrl–HTTP route of the action, e.g. https://api.example.com/_/browser-logs/_push. When set, batches sent while the page is hidden use fetch with keepalive and the SDK token, since a WebSocket request may not survive the page unload.

HTTP sender #

createHttpSender(options?) posts batches with fetch. Use it when the frontend has no SDK instance.

OptionDefaultDescription
url/_/browser-logs/_pushEndpoint receiving the batches (the Kuzzle HTTP route, on the same origin).
headers–Extra headers, or a function returning them for each request (e.g. () => ({ Authorization: `Bearer ${getToken()}` })). Without an Authorization header, the request is anonymous.

Batching options #

Both senders accept these options:

OptionDefaultDescription
app–{ name, version } of the frontend, sent with each batch. Informational.
flushInterval5000Delay before sending a batch that is not full, in milliseconds.
maxBatchBytes60000Maximum size of a batch (JSON, in bytes). Keep it below the backend limits.maxPayloadSize (65536 by default). A larger entry is reduced to its level, time, namespace, message and error (truncated), with context: { truncated: true }.
maxBatchSize20Maximum number of entries per batch. Keep it below the backend limits.maxEntries (100 by default).
maxBufferSize500Maximum number of entries kept in memory. The oldest entries are dropped first, and the backend logs how many were dropped.
maxRetries3Retries of a failed batch before it is dropped.
retryDelay1000First retry delay, in milliseconds. It doubles at each attempt, up to 60 seconds.

await logger.flush() sends the pending entries now, even during a retry backoff. It resolves once the request is answered: if it fails, the entries stay buffered and are retried later. sender.close() stops the timers and removes the page listeners, without sending the pending entries (call flush() first).

Capturing uncaught errors #

Errors should be logged where they are handled. Two opt-in helpers also capture the errors nobody handled.

Global errors #

captureGlobalErrors(logger, options?) logs window error events (uncaught exceptions) and unhandledrejection events (rejected promises nobody awaited) at the error level. It returns a function that removes the listeners.

import { captureGlobalErrors } from 'kuzzle-logger/browser';

const stop = captureGlobalErrors(logger.child('global'));
  • Each entry has an event context field (error or unhandledrejection), and the error location (file, line, column) for uncaught exceptions. A rejected value that is not an error is logged in the reason field.
  • Identical errors received within dedupeInterval milliseconds (5000 by default) are logged once, so an error thrown in a loop or a timer does not flood the backend.
  • The events are not cancelled: the browser still prints them to the console, and the logger does not print them a second time.
  • Errors are captured once per target: while a capture is active (e.g. after a hot module reload, or when both a platform and the application call it), later calls add no listener and return the same function, and the first logger keeps logging. A new call after stop() captures again.
  • Errors thrown by scripts from another origin only give Script error., without a stack. Serve the scripts with CORS headers and the crossorigin attribute to get the details.

Vue error handler #

createVueErrorHandler(logger, options?) returns a handler for app.config.errorHandler (Vue 3) or Vue.config.errorHandler (Vue 2). It logs the error with the component name (component) and the Vue info string (render function, setup function, watcher callback...). It does not depend on vue.

OptionDefaultDescription
dedupeInterval5000Identical errors (same name, message, info and component) received within this delay, in milliseconds, are logged once, so an error thrown on each render does not flood the backend. 0 disables it.
previous–Handler called after logging, with the same arguments: the handler set before, or the one of an error tracking tool. Errors it throws are ignored.

Deduplicated errors are still printed and passed to previous.

import { createApp } from 'vue';
import { captureGlobalErrors, createVueErrorHandler } from 'kuzzle-logger/browser';

import App from './App.vue';
import { logger } from './logger';

const app = createApp(App);

app.config.errorHandler = createVueErrorHandler(logger.child('vue'), {
  previous: app.config.errorHandler,
});
captureGlobalErrors(logger.child('global'));

app.mount('#app');

Vue stops printing errors once an error handler is set: the handler prints them with console.error, as Vue does in production, whatever the logger console option.

Since Vue 3.4, production builds give info as a link (https://vuejs.org/error-reference/#runtime-1) instead of a string: the handler maps the known codes back to Vue's strings (render function), and keeps unknown ones as they are.

Privacy #

Browser logs are sent to your backend and stored with your other logs. Do not log personal data or secrets. The backend redacts the values of sensitive keys (password, token, authorization...) and tokens found in strings, but it cannot detect everything.