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
8 changes: 2 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1814,9 +1814,7 @@ const logger = createLogger({
timeout: 10_000, // wait up to 10 s; force-exits after
signals: ['SIGTERM', 'SIGINT', 'SIGHUP'],
},
transports: {
/* ... */
},
transports: {/* ... */},
});
```

Expand Down Expand Up @@ -1908,9 +1906,7 @@ import { usePlugin } from 'logixia';
usePlugin(myPlugin);

// All loggers created from this point forward will run myPlugin.
const logger = createLogger({
/* ... */
});
const logger = createLogger({/* ... */});
```

### Per-logger plugins
Expand Down
5 changes: 5 additions & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,11 @@ export default [
// search utility functions (highest observed: transport.manager = 49).
'sonarjs/cognitive-complexity': ['warn', 55],
'sonarjs/todo-tag': 'off',
// With `exactOptionalPropertyTypes: true` (tsconfig.json), `?: T` and
// `?: T | undefined` are NOT equivalent β€” the latter permits explicitly
// assigning `undefined`, which callers throughout this codebase rely on.
// This rule is purely syntactic and doesn't account for that tsconfig flag.
'sonarjs/no-redundant-optional': 'off',
},
},

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -307,7 +307,7 @@
"eslint": "^10.0.3",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-simple-import-sort": "^12.1.1",
"eslint-plugin-sonarjs": "^4.0.2",
"eslint-plugin-sonarjs": "4.2.0",
"eslint-plugin-unicorn": "^63.0.0",
"express": "^5.2.1",
"globals": "^17.4.0",
Expand Down
33 changes: 21 additions & 12 deletions src/__tests__/plugin.comprehensive.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,18 +74,27 @@ describe('PluginRegistry', () => {
});

it('swallows errors thrown by async onInit (fire and forget)', async () => {
const rejected = false;
registry.register({
name: 'bad-async-init',
onInit: async () => {
await Promise.resolve();
throw new Error('async init failed');
},
});
await Promise.resolve();
await Promise.resolve();
// No unhandled rejection β€” error was swallowed
expect(rejected).toBe(false);
// Synchronous throws from onInit ARE propagated (it's called synchronously).
// Only async onInit errors are swallowed. Verify async path.
let rejected = false;
const onUnhandledRejection = () => {
rejected = true;
};
process.on('unhandledRejection', onUnhandledRejection);
try {
registry.register({
name: 'bad-async-init',
onInit: async () => {
await Promise.resolve();
throw new Error('async init failed');
},
});
await Promise.resolve();
await Promise.resolve();
expect(rejected).toBe(false);
} finally {
process.off('unhandledRejection', onUnhandledRejection);
}
});

it('swallows a SYNCHRONOUS throw from onInit (does not break register)', () => {
Expand Down
29 changes: 7 additions & 22 deletions src/cli/__tests__/explore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,34 +49,19 @@ describe('formatTime', () => {

test('truncates and pads non-ISO strings', () => {
const result = formatTime('not-a-date');
expect(result.length).toBe(12);
expect(result).toHaveLength(12);
});
});

// ── syntaxColorJson ───────────────────────────────────────────────────────────

describe('syntaxColorJson', () => {
test('strips ANSI and leaves structure intact', () => {
const line = ' "level": "error",';
const result = syntaxColorJson(line);
expect(strip(result)).toBe(line);
});

test('preserves numeric values in output', () => {
const line = ' "duration": 142,';
const result = syntaxColorJson(line);
// The plain-text content must be unchanged after stripping ANSI
expect(strip(result)).toBe(line);
});

test('preserves boolean values in output', () => {
const line = ' "active": true';
const result = syntaxColorJson(line);
expect(strip(result)).toBe(line);
});

test('handles null values', () => {
const line = ' "value": null';
test.each([
['string value', ' "level": "error",'],
['numeric value', ' "duration": 142,'],
['boolean value', ' "active": true'],
['null value', ' "value": null'],
])('preserves plain-text content after stripping ANSI: %s', (_label, line) => {
const result = syntaxColorJson(line);
expect(strip(result)).toBe(line);
});
Expand Down
19 changes: 8 additions & 11 deletions src/context/__tests__/async-context.comprehensive.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,10 @@ describe('createExpressContextMiddleware', () => {
it('calls next()', (done) => {
const mw = createExpressContextMiddleware();
const req: Record<string, unknown> = { headers: {} };
mw(req, {}, done);
mw(req, {}, (...args: unknown[]) => {
expect(args).toEqual([]);
done();
});
});

it('sets a traceId in the context', (done) => {
Expand Down Expand Up @@ -237,15 +240,6 @@ describe('createExpressContextMiddleware', () => {
});
});

it('uses custom traceIdHeader when provided', (done) => {
const mw = createExpressContextMiddleware({ traceIdHeader: 'x-my-trace-id' });
const req: Record<string, unknown> = { headers: { 'x-my-trace-id': 'my-trace' } };
mw(req, {}, () => {
expect(LogixiaContext.get()?.traceId).toBe('my-trace');
done();
});
});

it('calls the enrich function and merges its result', (done) => {
const mw = createExpressContextMiddleware({
enrich: (req) => ({ userId: req['userId'] as string }),
Expand Down Expand Up @@ -273,7 +267,10 @@ describe('createExpressContextMiddleware', () => {
describe('createFastifyContextHook', () => {
it('calls done()', (done) => {
const hook = createFastifyContextHook();
hook({ headers: {} }, {}, done);
hook({ headers: {} }, {}, (...args: unknown[]) => {
expect(args).toEqual([]);
done();
});
});

it('sets a traceId in the context', (done) => {
Expand Down
26 changes: 7 additions & 19 deletions src/core/__tests__/logitron-logger.v11.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,28 +103,16 @@ afterEach(() => {
// ── Feature 5: Adaptive log level ────────────────────────────────────────────

describe('Feature 5 β€” Adaptive log level', () => {
it('uses DEBUG level when NODE_ENV=development and no config level set', () => {
process.env['NODE_ENV'] = 'development';
test.each([
['debug', 'development'],
['warn', 'test'],
['info', 'production'],
])('uses %s level when NODE_ENV=%s and no config level set', (expectedLevel, nodeEnv) => {
process.env['NODE_ENV'] = nodeEnv;
delete process.env['LOGIXIA_LEVEL'];

const logger = new LogixiaLogger({ ...BASE_CONFIG, levelOptions: undefined });
expect(logger.getLevel()).toBe('debug');
});

it('uses WARN level when NODE_ENV=test', () => {
process.env['NODE_ENV'] = 'test';
delete process.env['LOGIXIA_LEVEL'];

const logger = new LogixiaLogger({ ...BASE_CONFIG, levelOptions: undefined });
expect(logger.getLevel()).toBe('warn');
});

it('uses INFO level when NODE_ENV=production', () => {
process.env['NODE_ENV'] = 'production';
delete process.env['LOGIXIA_LEVEL'];

const logger = new LogixiaLogger({ ...BASE_CONFIG, levelOptions: undefined });
expect(logger.getLevel()).toBe('info');
expect(logger.getLevel()).toBe(expectedLevel);
});

it('uses INFO level when CI=true and no NODE_ENV', () => {
Expand Down
18 changes: 4 additions & 14 deletions src/core/logitron-nestjs.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,14 +32,7 @@ import { LogLevel } from '../types';
* duplicate / conflicting signatures.
*/
type _ServiceBuiltinLevels =
| 'error'
| 'warn'
| 'info'
| 'debug'
| 'verbose'
| 'trace'
| 'log'
| 'logLevel';
'error' | 'warn' | 'info' | 'debug' | 'verbose' | 'trace' | 'log' | 'logLevel';

/**
* Mapped type that adds one method per *custom* level (i.e. every key in TLevels
Expand Down Expand Up @@ -112,12 +105,9 @@ type _ExtractCustomLevelNames<T> = T extends { levelOptions?: { levels?: infer L
*/
export type LogixiaServiceWith<T extends string | Record<string, unknown>> =
LogixiaLoggerService & {
readonly [K in T extends string
? Exclude<T, _ServiceBuiltinLevels>
: _ExtractCustomLevelNames<T>]: (
message: string,
data?: Record<string, unknown>
) => Promise<void>;
readonly [
K in T extends string ? Exclude<T, _ServiceBuiltinLevels> : _ExtractCustomLevelNames<T>
]: (message: string, data?: Record<string, unknown>) => Promise<void>;
};
import { safeToString } from '../utils/coerce.utils';
import { internalError } from '../utils/internal-log';
Expand Down
6 changes: 2 additions & 4 deletions src/correlation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -346,8 +346,7 @@ export function childFromRequest<TLogger extends { child(ctx: Record<string, unk
const ip =
(headers['x-forwarded-for'] as string | undefined)?.split(',')[0]?.trim() ??
((req['socket'] as Record<string, unknown> | undefined)?.['remoteAddress'] as
| string
| undefined);
string | undefined);

return logger.child({
correlationId,
Expand Down Expand Up @@ -393,8 +392,7 @@ export function extractMessageCorrelationId(message: Record<string, unknown>): s

// SQS: message.MessageAttributes
const sqsAttrs = message['MessageAttributes'] as
| Record<string, { StringValue?: string }>
| undefined;
Record<string, { StringValue?: string }> | undefined;
if (sqsAttrs) {
return (
sqsAttrs['x-correlation-id']?.StringValue ??
Expand Down
6 changes: 3 additions & 3 deletions src/exceptions/__tests__/exception.comprehensive.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,12 @@ describe('LogixiaException β€” construction', () => {

it('is an instance of Error', () => {
const ex = new LogixiaException(baseOptions);
expect(ex instanceof Error).toBe(true);
expect(ex).toBeInstanceOf(Error);
});

it('is an instance of LogixiaException', () => {
const ex = new LogixiaException(baseOptions);
expect(ex instanceof LogixiaException).toBe(true);
expect(ex).toBeInstanceOf(LogixiaException);
});

it('has name "LogixiaException"', () => {
Expand Down Expand Up @@ -107,7 +107,7 @@ describe('LogixiaException β€” optional fields', () => {
details,
});
expect(ex.details).toEqual(details);
expect(ex.details!.length).toBe(2);
expect(ex.details!).toHaveLength(2);
expect(ex.details![0].field).toBe('email');
expect(ex.details![1].code).toBe('required');
});
Expand Down
2 changes: 1 addition & 1 deletion src/middleware/http-logger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
* ```
*/

/* eslint-disable sonarjs/void-use -- intentional fire-and-forget in sync middleware callbacks */
import type { IBaseLogger } from '../types';

// ── Shared types ─────────────────────────────────────────────────────────────
Expand Down
2 changes: 1 addition & 1 deletion src/search/core/__tests__/basic-search-engine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ describe('BasicSearchEngine β€” search', () => {
results = await eng.search('message');
})()
).resolves.toBeUndefined();
expect(results.length).toBe(2);
expect(results).toHaveLength(2);
});

it('correlates logs by trace id, sorted by timestamp', async () => {
Expand Down
2 changes: 1 addition & 1 deletion src/search/engines/nlp-search-engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,7 @@ export class NLPSearchEngine extends BasicSearchEngine {
['user_id', /user\s+(?:id\s+)?['"]?(\w+)['"]?/i],
['trace_id', /trace\s+(?:id\s+)?['"]?([\w-]+)['"]?/i],
['time', /(?:last|past|previous)\s+(\d+)\s+(second|minute|hour|day|week)s?/i],
// eslint-disable-next-line sonarjs/slow-regex -- bounded pattern, DoS risk is acceptable for log search
// eslint-disable-next-line sonarjs/super-linear-regex -- bounded pattern, DoS risk is acceptable for log search
['error_type', /(\w+Error|\w+Exception)/i],
]);
}
Expand Down
15 changes: 2 additions & 13 deletions src/search/types/search.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,12 +179,7 @@ export interface SearchSuggestion {
* Suggestion type
*/
export type SuggestionType =
| 'field'
| 'value'
| 'operator'
| 'query_history'
| 'pattern'
| 'filter';
'field' | 'value' | 'operator' | 'query_history' | 'pattern' | 'filter';

/**
* Search statistics
Expand Down Expand Up @@ -284,13 +279,7 @@ export interface QueryEntity {
* Entity type
*/
export type EntityType =
| 'level'
| 'service'
| 'user_id'
| 'trace_id'
| 'time'
| 'error_type'
| 'field_value';
'level' | 'service' | 'user_id' | 'trace_id' | 'time' | 'error_type' | 'field_value';

/**
* Search preset for saving common searches
Expand Down
2 changes: 1 addition & 1 deletion src/transports/file.transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,7 @@ export class FileTransport implements ITransport, IBatchTransport {
}

private parseInterval(interval: string): number {
// eslint-disable-next-line sonarjs/slow-regex -- simple bounded pattern for interval parsing
// eslint-disable-next-line sonarjs/super-linear-regex -- simple bounded pattern for interval parsing
const match = new RegExp(/(\d+)([hdwmy])/i).exec(interval);
if (!match) return 24 * 60 * 60 * 1000; // Default 1 day

Expand Down
14 changes: 14 additions & 0 deletions src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,20 @@ export interface RedactConfig {
* @example `['req.headers.authorization', '*.password', 'user.creditCard']`
*/
paths?: string[];
/**
* Dot-notation field paths exempt from `patterns` / `autoDetect` value-pattern
* scanning. Use this for internal identifiers whose *value* happens to look
* like PII to a pattern (e.g. a vendor reference id embedding an epoch
* timestamp digit-run that the aggressive phone-number pattern matches).
*
* Checked before pattern-based redaction (step 3): a matched field's value
* is passed through untouched, skipping the pattern scan entirely. This does
* NOT override `paths` β€” if a field matches both `paths` and `excludePaths`,
* `paths` wins (censored), since `paths` is checked first.
*
* @example `['**.vendorRefId', '**.traceId', '**.interactionId']`
*/
excludePaths?: string[];
/**
* Regex patterns applied to string values β€” replaces matches with the censor string.
* @example `[/Bearer\s+\S+/gi, /sk-[a-z0-9]{32,}/gi]`
Expand Down
6 changes: 3 additions & 3 deletions src/utils/__tests__/error.utils.comprehensive.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -266,19 +266,19 @@ describe('normalizeError', () => {

it('wraps a string into an Error', () => {
const result = normalizeError('something bad');
expect(result instanceof Error).toBe(true);
expect(result).toBeInstanceOf(Error);
expect(result.message).toBe('something bad');
});

it('wraps an object with a message field', () => {
const result = normalizeError({ message: 'obj error' });
expect(result instanceof Error).toBe(true);
expect(result).toBeInstanceOf(Error);
expect(result.message).toBe('obj error');
});

it('wraps an object without message as "Unknown error"', () => {
const result = normalizeError({ code: 'ERR' });
expect(result instanceof Error).toBe(true);
expect(result).toBeInstanceOf(Error);
expect(result.message).toBe('Unknown error');
});

Expand Down
Loading