Skip to content

Commit ab6a1d6

Browse files
committed
Cache scoped filter lookup
Cache the resolved scoped filter list per category so repeated log records avoid walking category prefixes on every emit. Keep the existing JSON-based category key so single segment categories containing separators do not collide with multi-segment categories. #188 (comment) #188 (comment) #188 (comment) Assisted-by: Codex:gpt-5.5
1 parent bde3be7 commit ab6a1d6

2 files changed

Lines changed: 68 additions & 2 deletions

File tree

packages/logtape/src/config.test.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
compileScopedConfig,
1111
disposeScopedConfig,
1212
disposeScopedConfigSync,
13+
emitWithScopedConfig,
1314
scopedConfigHasSink,
1415
} from "./scoped-config.ts";
1516
import type { Sink } from "./sink.ts";
@@ -1257,6 +1258,52 @@ test("scoped configuration caches sink dispatch plans", () => {
12571258
assert.strictEqual(scopedConfig.dispatchCache.size, 1);
12581259
});
12591260

1261+
test("scoped configuration caches filter lookup", () => {
1262+
const logs: LogRecord[] = [];
1263+
const filter: Filter = (record) => record.category[0] === "app";
1264+
const scopedConfig = compileScopedConfig(
1265+
{
1266+
sinks: { sink: logs.push.bind(logs) },
1267+
filters: { filter },
1268+
loggers: [
1269+
{ category: "app", filters: ["filter"], sinks: ["sink"] },
1270+
{ category: ["app:child"], sinks: ["sink"] },
1271+
],
1272+
},
1273+
true,
1274+
(message) => new ConfigError(message),
1275+
);
1276+
const emit = (category: readonly string[]): void => {
1277+
const record: LogRecord = {
1278+
category,
1279+
level: "info",
1280+
message: ["cached"],
1281+
properties: {},
1282+
rawMessage: "cached",
1283+
timestamp: Date.now(),
1284+
};
1285+
emitWithScopedConfig(
1286+
scopedConfig,
1287+
record,
1288+
undefined,
1289+
(sink) => sink(record),
1290+
);
1291+
};
1292+
1293+
assert.strictEqual(scopedConfig.filterCache.size, 0);
1294+
emit(["app", "child"]);
1295+
assert.strictEqual(scopedConfig.filterCache.size, 1);
1296+
emit(["app", "child"]);
1297+
assert.strictEqual(scopedConfig.filterCache.size, 1);
1298+
emit(["app:child"]);
1299+
1300+
assert.strictEqual(scopedConfig.filterCache.size, 2);
1301+
assert.deepStrictEqual(
1302+
logs.map((record) => record.category),
1303+
[["app", "child"], ["app", "child"], ["app:child"]],
1304+
);
1305+
});
1306+
12601307
test("withConfig() preserves callback and disposal errors", async () => {
12611308
const callbackError = new Error("callback failed");
12621309
const disposeError = new Error("dispose failed");

packages/logtape/src/scoped-config.ts

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,10 @@ export interface ScopedLoggerConfigLike<
3333
export interface CompiledScopedConfig {
3434
readonly nodes: ReadonlyMap<string, CompiledScopedLogger>;
3535
readonly dispatchCache: Map<string, ScopedSinkDispatchPlan>;
36+
readonly filterCache: Map<
37+
string,
38+
readonly ((record: LogRecord) => boolean)[]
39+
>;
3640
parent: CompiledScopedConfig | undefined;
3741
disposed: boolean;
3842
readonly syncFilters: Set<Disposable>;
@@ -59,6 +63,7 @@ const defaultScopedLogger: CompiledScopedLogger = {
5963
parentSinks: "inherit",
6064
sinks: [],
6165
};
66+
const noFilters: readonly ((record: LogRecord) => boolean)[] = [];
6267

6368
export function compileScopedConfig<
6469
TSinkId extends string,
@@ -199,6 +204,7 @@ export function compileScopedConfig<
199204
asyncSinks,
200205
dispatchCache: new Map(),
201206
disposed: false,
207+
filterCache: new Map(),
202208
nodes,
203209
parent: undefined,
204210
syncFilters,
@@ -473,14 +479,27 @@ function filterScopedRecord(
473479
category: readonly string[],
474480
record: LogRecord,
475481
): boolean {
482+
const key = categoryKey(category);
483+
let filters = scopedConfig.filterCache.get(key);
484+
if (filters == null) {
485+
filters = getScopedFilters(scopedConfig, category);
486+
scopedConfig.filterCache.set(key, filters);
487+
}
488+
return filters.every((filter) => filter(record));
489+
}
490+
491+
function getScopedFilters(
492+
scopedConfig: CompiledScopedConfig,
493+
category: readonly string[],
494+
): readonly ((record: LogRecord) => boolean)[] {
476495
for (let length = category.length; length >= 0; length--) {
477496
const logger = scopedConfig.nodes.get(
478497
categoryKey(category.slice(0, length)),
479498
);
480499
if (logger == null || logger.filters.length < 1) continue;
481-
return logger.filters.every((filter) => filter(record));
500+
return logger.filters;
482501
}
483-
return true;
502+
return noFilters;
484503
}
485504

486505
function disposeSyncDisposables(

0 commit comments

Comments
 (0)