Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
5f64995
fix(transport): serialize database flushes and guarantee drain on close
webcoderspeed Jun 10, 2026
ef3b66f
fix(transport): serialize analytics flushes and guarantee drain on close
webcoderspeed Jun 10, 2026
d2917c2
fix(transport): add close() and full-batch drain to cloud transports
webcoderspeed Jun 10, 2026
28049b2
fix(transport): correct averageWriteTime metric and cover manager shu…
webcoderspeed Jun 10, 2026
0e8f915
fix(redact): redact pattern-matching secrets in the log message string
webcoderspeed Jun 10, 2026
85b1c56
fix(shutdown): guard graceful-shutdown handler against concurrent sig…
webcoderspeed Jun 10, 2026
91471c9
fix(transport): give WorkerTransport a close() and stop restart leaks
webcoderspeed Jun 10, 2026
7b70f30
fix(sampling): bound traceConsistent trace sets to prevent memory leak
webcoderspeed Jun 10, 2026
f1e5625
fix(trace): make createTraceMiddleware response-API agnostic
webcoderspeed Jun 10, 2026
748e199
fix(utils): stop safeToString returning undefined and harden error cy…
webcoderspeed Jun 10, 2026
23ec529
fix(nest): tear down inner handler subscription in trace interceptors
webcoderspeed Jun 10, 2026
c5e0158
fix(nest): make formatMessage safe for circular and unserializable me…
webcoderspeed Jun 10, 2026
f14a2e6
fix(transport): implement real gzip rotation and scope cleanup to own…
webcoderspeed Jun 10, 2026
e361bdd
fix(plugin): isolate synchronous throws in onInit/onError/onShutdown …
webcoderspeed Jun 10, 2026
69824eb
fix(formatters): stop circular payloads from crashing JSON/text forma…
webcoderspeed Jun 10, 2026
51cae52
fix(otel,metrics): harden OTel hot path and sanitize Prometheus names
webcoderspeed Jun 10, 2026
2f649f4
fix(middleware): prevent duplicate request-completed logs from finish…
webcoderspeed Jun 10, 2026
09efe2e
perf(search): make BasicLogIndexer.getIndexStats O(1)
webcoderspeed Jun 10, 2026
020a243
fix(search): circular-safe searchable text and bound the engine buffer
webcoderspeed Jun 10, 2026
13f8454
fix(cli): render falsy-but-real table cells (0, false) instead of blanks
webcoderspeed Jun 10, 2026
6bc9a85
fix(browser): drain whole batch and flush on destroy() to avoid log loss
webcoderspeed Jun 10, 2026
ada641b
fix(trace): harden NestJS trace middleware response + ip handling
webcoderspeed Jun 10, 2026
e1d8a1c
fix(internal-log): read silence flag per call, not once at import
webcoderspeed Jun 10, 2026
1418ebd
fix(nest): @LogMethod preserves the sync/async contract of the wrappe…
webcoderspeed Jun 10, 2026
d99cfb2
style: auto-fix formatting and lint [skip ci]
webcoderspeed Jun 10, 2026
55774a6
fix(redact): actually commit the message-string redaction implementation
webcoderspeed Jun 10, 2026
962cdff
Merge branch 'audit/bug-hunt' of webcoderspeed:Logixia/logixia into a…
webcoderspeed Jun 10, 2026
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
166 changes: 166 additions & 0 deletions examples/verify-audit-fixes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
/**
* Verification example for the audit fixes (flush duplication, log loss, redaction).
*
* Reproduces the production incident where a synchronous burst of logs (e.g. a
* NestJS app replaying buffered bootstrap logs) caused a batching transport to
* write the SAME entries many times β€” ~120 logs became thousands of lines.
*
* This drives the AnalyticsTransport batching path (shared by Mixpanel/DataDog/
* Segment/Google Analytics) with a fake provider that just counts what it
* receives, then asserts:
* - every entry is delivered EXACTLY once (no NΒ² duplication)
* - a failed batch is re-buffered and re-sent (no log loss)
* - close() drains everything on shutdown (no loss on deploy)
*
* Run: npx ts-node examples/verify-audit-fixes.ts
*/

import { AnalyticsTransport } from '../src/transports/analytics.transport';
import { CloudWatchTransport } from '../src/transports/cloudwatch.transport';
import type { AnalyticsTransportConfig, TransportLogEntry } from '../src/types/transport.types';

class FakeProviderTransport extends AnalyticsTransport {
public readonly received: string[] = [];
public sendBatchCalls = 0;
private failFirst: boolean;

constructor(config: AnalyticsTransportConfig, failFirst = false) {
super('fake-provider', config);
this.failFirst = failFirst;
this.isReady = true;
}

protected initialize(): void {
this.isReady = true;
}
protected async sendEntry(entry: TransportLogEntry): Promise<void> {
this.received.push(entry.message);
}
protected async sendBatch(entries: TransportLogEntry[]): Promise<void> {
this.sendBatchCalls += 1;
// Simulate network latency so concurrent flushes actually overlap.
await new Promise((r) => setTimeout(r, 5));
if (this.failFirst && this.sendBatchCalls === 1) {
throw new Error('simulated provider outage');
}
for (const e of entries) this.received.push(e.message);
}
protected cleanup(): void {
/* no-op */
}
}

function entry(i: number): TransportLogEntry {
return { timestamp: new Date(), level: 'info', message: `event-${i}` };
}

async function main() {
let allPassed = true;
const check = (name: string, cond: boolean, detail: string) => {
console.log(`${cond ? 'βœ… PASS' : '❌ FAIL'} ${name} β€” ${detail}`);
if (!cond) allPassed = false;
};

// ── Scenario 1: synchronous burst, un-awaited writes ───────────────────────
{
const t = new FakeProviderTransport({ apiKey: 'test', batchSize: 50, flushInterval: 0 });
const TOTAL = 600;
const writes: Array<Promise<void>> = [];
for (let i = 0; i < TOTAL; i++) writes.push(t.write(entry(i)));
await Promise.allSettled(writes);
await t.flush();
check(
'burst of 600 logs delivered exactly once',
t.received.length === TOTAL && new Set(t.received).size === TOTAL,
`delivered=${t.received.length}, unique=${new Set(t.received).size}, expected=${TOTAL}`
);
await t.close();
}

// ── Scenario 2: concurrent flush() calls collapse to one drain ─────────────
{
const t = new FakeProviderTransport({ apiKey: 'test', batchSize: 1000, flushInterval: 0 });
for (let i = 0; i < 100; i++) await t.write(entry(i));
await Promise.all([t.flush(), t.flush(), t.flush(), t.flush()]);
check(
'4 concurrent flushes do not duplicate',
t.received.length === 100 && new Set(t.received).size === 100,
`delivered=${t.received.length}, sendBatchCalls=${t.sendBatchCalls}`
);
await t.close();
}

// ── Scenario 3: failed batch is re-buffered, then close() drains it ────────
{
const t = new FakeProviderTransport({ apiKey: 'test', batchSize: 1000, flushInterval: 0 }, true);
for (let i = 0; i < 10; i++) await t.write(entry(i));
await t.flush(); // first attempt fails inside drain β†’ re-buffered
const afterFail = t.received.length;
await t.close(); // retries and drains everything
check(
'no log loss when provider fails then recovers on close',
afterFail === 0 && t.received.length === 10 && new Set(t.received).size === 10,
`afterFailedFlush=${afterFail}, afterClose=${t.received.length}`
);
}

// ── Scenario 4: cloud transport drains its whole batch + close() on shutdown ─
{
const t = new CloudWatchTransport({
logGroupName: '/verify',
batchSize: 10,
flushIntervalMs: 999_999,
});
const recorded: unknown[] = [];
// Stub the private network call so no real AWS request is made.
(t as unknown as { putLogEvents: (e: unknown[]) => Promise<void> }).putLogEvents = async (
events
) => {
recorded.push(...events);
};
for (let i = 0; i < 95; i++) t.write(entry(i)); // 95 > batchSize β†’ tail would be left by old flush()
await t.close(); // must drain ALL 95, not just one chunk, then stop the timer
check(
'cloud transport close() drains the whole batch on shutdown',
recorded.length === 95,
`delivered=${recorded.length}, expected=95`
);
}

// ── Scenario 5: secrets in the MESSAGE string are redacted (security) ───────
{
const { LogixiaLogger } = await import('../src/core/logitron-logger');
const captured: string[] = [];
const origWrite = process.stdout.write.bind(process.stdout);
(process.stdout as NodeJS.WriteStream).write = ((chunk: unknown) => {
captured.push(String(chunk ?? ''));
return true;
}) as typeof process.stdout.write;

const logger = new LogixiaLogger({
appName: 'verify',
outputs: ['console'],
format: { timestamp: false, colorize: false, json: false },
traceId: false,
redact: { patterns: [/Bearer\s+\S+/gi], censor: '[REDACTED]' },
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- example config
} as any);
await logger.info('Auth header was Bearer abc123secrettoken456');
(process.stdout as NodeJS.WriteStream).write = origWrite;

const joined = captured.join('');
check(
'secret in the log MESSAGE string is redacted, not leaked',
!joined.includes('abc123secrettoken456') && joined.includes('[REDACTED]'),
joined.trim()
);
}

console.log(`\n${allPassed ? 'πŸŽ‰ ALL CHECKS PASSED' : 'πŸ”₯ SOME CHECKS FAILED'}`);
process.exit(allPassed ? 0 : 1);
}

main().catch((err) => {
console.error('Example crashed:', err);
process.exit(1);
});
86 changes: 86 additions & 0 deletions src/__tests__/browser.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
/**
* Tests for the browser logger's 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.
*
* fetch is mocked so no real network call is made.
*/

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

function makeEntry(i: number): BrowserLogEntry {
return { timestamp: '2026-01-01T00:00:00.000Z', level: 'info', appName: 'a', message: `b-${i}` };
}

describe('BrowserRemoteTransport', () => {
let fetchMock: jest.Mock;
let originalFetch: typeof globalThis.fetch | undefined;

beforeEach(() => {
originalFetch = globalThis.fetch;
fetchMock = jest.fn().mockResolvedValue({ ok: true });
(globalThis as { fetch: unknown }).fetch = fetchMock;
});

afterEach(() => {
(globalThis as { fetch: unknown }).fetch = originalFetch;
});

it('drains the whole batch across multiple POSTs when it exceeds batchSize', async () => {
const t = new BrowserRemoteTransport({ url: 'https://logs.example/ingest', batchSize: 10 });
for (let i = 0; i < 35; i += 1) t.write(makeEntry(i));

await t.flush();

// 35 entries / batchSize 10 β†’ 4 POSTs (10+10+10+5).
const totalSent = fetchMock.mock.calls.reduce((sum, call) => {
const body = JSON.parse((call[1] as { body: string }).body) as unknown[];
return sum + body.length;
}, 0);
expect(totalSent).toBe(35);
t.destroy();
});

it('flushes remaining buffered entries on destroy()', async () => {
const t = new BrowserRemoteTransport({
url: 'https://logs.example/ingest',
batchSize: 1000, // never auto-flushes
flushIntervalMs: 999_999,
});
for (let i = 0; i < 5; i += 1) t.write(makeEntry(i));
expect(fetchMock).not.toHaveBeenCalled(); // still buffered

t.destroy();
// destroy() kicks off the flush; let the microtask settle.
await Promise.resolve();
await Promise.resolve();

expect(fetchMock).toHaveBeenCalledTimes(1);
const body = JSON.parse(fetchMock.mock.calls[0]![1].body) as unknown[];
expect(body).toHaveLength(5);
});

it('re-buffers entries when the POST fails (no loss)', async () => {
fetchMock.mockRejectedValueOnce(new Error('network down'));
const t = new BrowserRemoteTransport({ url: 'https://logs.example/ingest', batchSize: 1000 });
for (let i = 0; i < 3; i += 1) t.write(makeEntry(i));

await t.flush(); // fails β†’ re-buffered
await t.flush(); // succeeds

const lastBody = JSON.parse(
fetchMock.mock.calls[fetchMock.mock.calls.length - 1]![1].body
) as unknown[];
expect(lastBody).toHaveLength(3);
t.destroy();
});

it('rejects a non-http(s) url scheme', () => {
expect(() => new BrowserRemoteTransport({ url: 'javascript:alert(1)' })).toThrow();
});
});
96 changes: 96 additions & 0 deletions src/__tests__/metrics.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
/**
* Tests for the Prometheus metrics plugin.
*
* Focus: the Prometheus exposition format is strict β€” an invalid metric or label
* NAME makes the scraper reject the ENTIRE endpoint, not just that metric. So
* names must be sanitized to [a-zA-Z_][a-zA-Z0-9_]*. Also covers basic
* counter / histogram / gauge extraction and rendering.
*/

import { createMetricsPlugin } from '../metrics';
import type { LogEntry } from '../types/index';

function entry(level: string, payload?: Record<string, unknown>): LogEntry {
return {
timestamp: '2026-01-01T00:00:00.000Z',
level,
appName: 'TestApp',
message: 'm',
...(payload ? { payload } : {}),
};
}

describe('MetricsPlugin β€” name sanitization', () => {
it('sanitizes an invalid metric name in the output', () => {
const m = createMetricsPlugin({ 'my-bad.metric': { type: 'counter' } });
m.onLog(entry('info'));
const out = m.render();
// The TYPE/metric lines must use a valid sanitized identifier.
expect(out).toContain('logixia_my_bad_metric');
expect(out).toMatch(/^logixia_my_bad_metric 1$/m);
});

it('sanitizes invalid label names', () => {
const m = createMetricsPlugin({
reqs: { type: 'counter', labels: ['status code'] },
});
m.onLog(entry('info', { 'status code': '200' }));
const out = m.render();
expect(out).toContain('status_code="200"');
expect(out).not.toContain('status code=');
});

it('prefixes a leading-digit name with an underscore', () => {
const m = createMetricsPlugin({ '5xx': { type: 'counter' } });
m.onLog(entry('error'));
expect(m.render()).toContain('logixia__5xx');
});
});

describe('MetricsPlugin β€” extraction', () => {
it('counts entries matching a level filter', () => {
const m = createMetricsPlugin({
error_count: { type: 'counter', levelFilter: 'error' },
});
m.onLog(entry('error'));
m.onLog(entry('info'));
m.onLog(entry('error'));
expect(m.render()).toMatch(/^logixia_error_count 2$/m);
});

it('observes a histogram field with cumulative buckets and +Inf', () => {
const m = createMetricsPlugin({
dur: { type: 'histogram', field: 'duration', buckets: [10, 100] },
});
m.onLog(entry('info', { duration: 5 }));
m.onLog(entry('info', { duration: 50 }));
const out = m.render();
expect(out).toContain('logixia_dur_bucket{le="10"} 1'); // only the 5
expect(out).toContain('logixia_dur_bucket{le="100"} 2'); // 5 and 50
expect(out).toContain('logixia_dur_bucket{le="+Inf"} 2');
expect(out).toContain('logixia_dur_count 2');
expect(out).toContain('logixia_dur_sum 55');
});

it('tracks the latest value of a gauge', () => {
const m = createMetricsPlugin({
conns: { type: 'gauge', field: 'connections' },
});
m.onLog(entry('info', { connections: 3 }));
m.onLog(entry('info', { connections: 7 }));
expect(m.render()).toMatch(/^logixia_conns 7$/m);
});

it('reset() clears accumulated state', () => {
const m = createMetricsPlugin({ c: { type: 'counter' } });
m.onLog(entry('info'));
m.reset();
expect(m.render()).toMatch(/^logixia_c 0$/m);
});

it('onLog passes the entry through unchanged', () => {
const m = createMetricsPlugin({ c: { type: 'counter' } });
const e = entry('info', { x: 1 });
expect(m.onLog(e)).toBe(e);
});
});
Loading
Loading