Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 60 additions & 2 deletions src/__tests__/browser.test.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,20 @@
/**
* Tests for the browser logger's remote transport.
* Tests for the browser logger and its remote transport.
*
* Regressions:
* - flush() spliced only ONE batchSize chunk, leaving a tail when the batch
* exceeded batchSize. It must drain the whole batch.
* - destroy() cleared the timer but did NOT flush, losing buffered logs on page
* unload / teardown. It must flush remaining entries.
* - BrowserLogger.child() dropped the parent's `json` option, so children of a
* JSON logger printed pretty lines, and it stripped a key named `_` instead
* of keeping every context field.
*
* fetch is mocked so no real network call is made.
*/

import type { BrowserLogEntry } from '../browser';
import { BrowserRemoteTransport } from '../browser';
import { BrowserLogger, BrowserRemoteTransport } from '../browser';

function makeEntry(i: number): BrowserLogEntry {
return { timestamp: '2026-01-01T00:00:00.000Z', level: 'info', appName: 'a', message: `b-${i}` };
Expand Down Expand Up @@ -166,3 +169,58 @@ describe('BrowserRemoteTransport', () => {
jest.useRealTimers();
});
});

describe('BrowserLogger.child()', () => {
let logSpy: jest.SpyInstance;

beforeEach(() => {
logSpy = jest.spyOn(console, 'log').mockImplementation(() => {});
});

afterEach(() => {
logSpy.mockRestore();
});

function lastJsonEntry(): BrowserLogEntry {
const calls = logSpy.mock.calls;
return JSON.parse(calls[calls.length - 1]![0] as string) as BrowserLogEntry;
}

it('keeps json output for a child of a json: true logger', () => {
const parent = new BrowserLogger({ appName: 'app', json: true });
const child = parent.child({ requestId: 'r-1' });

child.info('hello');

expect(logSpy).toHaveBeenCalledTimes(1);
const entry = lastJsonEntry();
expect(entry.message).toBe('hello');
expect(entry.payload).toEqual({ requestId: 'r-1' });
});

it('merges the parent context with the new fields, including context', () => {
const parent = new BrowserLogger({
appName: 'app',
json: true,
context: { service: 'checkout' },
});
const child = parent.child({ context: 'AuthService', userId: '42' });

child.info('Login succeeded');

expect(lastJsonEntry().payload).toEqual({
service: 'checkout',
context: 'AuthService',
userId: '42',
});
});

it('keeps both levels of context in a grandchild', () => {
const parent = new BrowserLogger({ appName: 'app', json: true });
const grandchild = parent.child({ requestId: 'r-1' }).child({ userId: '42' });

grandchild.info('nested');

expect(lastJsonEntry().payload).toEqual({ requestId: 'r-1', userId: '42' });
});
});
15 changes: 9 additions & 6 deletions src/browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -379,7 +379,9 @@ export class BrowserRemoteTransport implements IBrowserTransport {
* Node.js dependencies. It can be bundled for any runtime.
*/
export class BrowserLogger {
private readonly config: Required<Pick<BrowserLoggerConfig, 'appName' | 'level' | 'silent'>> & {
private readonly config: Required<
Pick<BrowserLoggerConfig, 'appName' | 'level' | 'silent' | 'json'>
> & {
context: Record<string, unknown> | undefined;
};
private readonly transports: IBrowserTransport[];
Expand All @@ -389,13 +391,14 @@ export class BrowserLogger {
appName: config.appName ?? 'App',
level: config.level ?? 'info',
silent: config.silent ?? false,
json: config.json ?? false,
context: config.context,
};

if (config.silent) {
this.transports = [];
} else {
const builtIn = new BrowserConsoleTransport({ json: config.json ?? false });
const builtIn = new BrowserConsoleTransport({ json: this.config.json });
this.transports = [builtIn, ...(config.transports ?? [])];
}
}
Expand Down Expand Up @@ -441,14 +444,14 @@ export class BrowserLogger {
* ```
*/
child(extraContext: Record<string, unknown>): BrowserLogger {
// Exclude 'context' string label from data fields (used for identification only)
const { _, ...rest } = extraContext;
// _context (string label) is intentionally unused — it's stripped from the data payload
// Every key, including `context`, is kept and merged over the parent's
// context, as the example above shows.
return new BrowserLogger({
appName: this.config.appName,
level: this.config.level,
silent: this.config.silent,
context: { ...this.config.context, ...rest },
json: this.config.json,
context: { ...this.config.context, ...extraContext },
// Reuse existing transport list
transports: this.transports.filter((t) => t.name !== 'browser-console'),
});
Expand Down
Loading