@holz/log-collector 0.8.0-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +75 -0
- package/dist/holz-log-collector.cjs +1 -0
- package/dist/holz-log-collector.d.ts +37 -0
- package/dist/holz-log-collector.js +16 -0
- package/package.json +53 -0
- package/src/__tests__/__snapshots__/json-backend.test.ts.snap +9 -0
- package/src/__tests__/log-collector.test.ts +76 -0
- package/src/global.ts +45 -0
- package/src/index.ts +2 -0
- package/src/log-collector.ts +22 -0
package/README.md
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# `@holz/log-collector`
|
|
2
|
+
|
|
3
|
+
Supports replacing the global logging destination for all loggers in your app.
|
|
4
|
+
|
|
5
|
+
## Purpose
|
|
6
|
+
|
|
7
|
+
Let's say you're building an app, and your app uses libraries that manage logs with Holz. Your app uploads logs to a central logging backend and you want to include logs from some of those libraries. That's where `@holz/log-collector` comes in.
|
|
8
|
+
|
|
9
|
+
On startup you set a global log collector. Libraries detect this automatically and **replace** their log destination with whatever you provide. Now you control all logs and can selectively upload to your backend.
|
|
10
|
+
|
|
11
|
+
Other use cases:
|
|
12
|
+
|
|
13
|
+
- **Central Log Files:** Redirect all server logs to a file, including logs from specific libraries.
|
|
14
|
+
- **External Debuggers:** Depending on your environment, it may be useful to view logs in a separate application like [OTel Desktop Viewer](https://github.com/CtrlSpice/otel-desktop-viewer) or [DebugView](https://docs.microsoft.com/en-us/sysinternals/downloads/debugview). A log collector can act as the central exporter.
|
|
15
|
+
- **Interactive TUIs:** Renderers like [Ink](https://github.com/vadimdemedes/ink) expect control of the screen. Logs can damage rendering. A solution might redirect logs to a file stream or a custom logs window.
|
|
16
|
+
- **Patching Broken Logs:** If an unexpected input starts throwing errors, you have the ability to patch it without making upstream changes.
|
|
17
|
+
|
|
18
|
+
## Usage in Apps
|
|
19
|
+
|
|
20
|
+
Set a global log collector as soon as the app starts. This is typically done in the main entry file of your app.
|
|
21
|
+
|
|
22
|
+
```typescript
|
|
23
|
+
import { setGlobalLogCollector } from '@holz/log-collector';
|
|
24
|
+
|
|
25
|
+
setGlobalLogCollector({
|
|
26
|
+
// Called for every log in your app so long as `condition` returns true.
|
|
27
|
+
processor: (log) => {
|
|
28
|
+
// ...
|
|
29
|
+
},
|
|
30
|
+
|
|
31
|
+
// [Optional] Decide which logs to collect. Defaults to all logs.
|
|
32
|
+
condition: (log) => log.origin[0] === 'some-library',
|
|
33
|
+
});
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
- **processor:** Called for every log so long as `condition` returns true. The signature is a Holz plugin, which means you can pass your app's log pipeline verbatim (whatever you passed to `createLogger`).
|
|
37
|
+
- **condition:** A function that decides which logs to collect. By default it captures everything. If it returns `false`, logs are sent to their original destination.
|
|
38
|
+
|
|
39
|
+
---
|
|
40
|
+
|
|
41
|
+
To remove a global log collector and restore defaults, call `unsetGlobalLogCollector`:
|
|
42
|
+
|
|
43
|
+
```typescript
|
|
44
|
+
import { unsetGlobalLogCollector } from '@holz/log-collector';
|
|
45
|
+
|
|
46
|
+
unsetGlobalLogCollector();
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
This is primarily used in tests.
|
|
50
|
+
|
|
51
|
+
## Usage in Libraries
|
|
52
|
+
|
|
53
|
+
> [!NOTE]
|
|
54
|
+
> This plugin is included by default with `@holz/logger`.
|
|
55
|
+
|
|
56
|
+
This should be the **first plugin** in your log pipeline.
|
|
57
|
+
|
|
58
|
+
```typescript
|
|
59
|
+
import { createLogCollector } from '@holz/log-collector';
|
|
60
|
+
import { createLogger } from '@holz/core';
|
|
61
|
+
|
|
62
|
+
const logger = createLogger(
|
|
63
|
+
createLogCollector({
|
|
64
|
+
fallback: (log) => {
|
|
65
|
+
// Default log backend. Called if no global log collector is set.
|
|
66
|
+
},
|
|
67
|
+
}),
|
|
68
|
+
);
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
- **fallback:** Called if no global log collector is set, or if `condition` returns false. This is the default log backend. The signature is a Holz plugin.
|
|
72
|
+
|
|
73
|
+
The expectation is the collector captures logs before any side effects happen, such as logging to the console or sending to a file.
|
|
74
|
+
|
|
75
|
+
Be aware that `logger.withMiddleware(...)` runs before the default logging backend, so take care to avoid side effects in those handlers.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"use strict";Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});const n=()=>!0,s=({processor:t,condition:o=n})=>{e[c]={processor:t,condition:o}},r=()=>{delete e[c]},e=globalThis,c=Symbol.for("@holz/log-collector"),a=t=>o=>{const l=e[c];l!=null&&l.condition(o)?l.processor(o):t.fallback(o)};exports.createLogCollector=a;exports.setGlobalLogCollector=s;exports.unsetGlobalLogCollector=r;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { Log } from '@holz/core';
|
|
2
|
+
import { LogProcessor } from '@holz/core';
|
|
3
|
+
|
|
4
|
+
declare interface Config {
|
|
5
|
+
fallback: LogProcessor;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Redirects logs to a global collector if one is configured. This is designed
|
|
10
|
+
* with apps in mind, which may want to aggregate logs from multiple sources.
|
|
11
|
+
*/
|
|
12
|
+
export declare const createLogCollector: (config: Config) => LogProcessor;
|
|
13
|
+
|
|
14
|
+
declare interface InterceptorConfig {
|
|
15
|
+
/** Plugin handling all logs captured by the interceptor. */
|
|
16
|
+
processor: LogProcessor;
|
|
17
|
+
/**
|
|
18
|
+
* Only capture logs matching this function. Return `false` to use the
|
|
19
|
+
* default log destination. Defaults to capture all logs.
|
|
20
|
+
*/
|
|
21
|
+
condition?: (log: Log) => boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Overrides the default log destination for all loggers in the app. Call this
|
|
26
|
+
* early on startup. If logs are sent before registering the collector, they
|
|
27
|
+
* will be lost.
|
|
28
|
+
*/
|
|
29
|
+
export declare const setGlobalLogCollector: ({ processor, condition: match, }: InterceptorConfig) => void;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Remove the global log collector restoring defaults. Safe to call regardless
|
|
33
|
+
* of whether one is set.
|
|
34
|
+
*/
|
|
35
|
+
export declare const unsetGlobalLogCollector: () => void;
|
|
36
|
+
|
|
37
|
+
export { }
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
const n = () => !0, s = ({
|
|
2
|
+
processor: t,
|
|
3
|
+
condition: o = n
|
|
4
|
+
}) => {
|
|
5
|
+
c[e] = { processor: t, condition: o };
|
|
6
|
+
}, r = () => {
|
|
7
|
+
delete c[e];
|
|
8
|
+
}, c = globalThis, e = Symbol.for("@holz/log-collector"), i = (t) => (o) => {
|
|
9
|
+
const l = c[e];
|
|
10
|
+
l != null && l.condition(o) ? l.processor(o) : t.fallback(o);
|
|
11
|
+
};
|
|
12
|
+
export {
|
|
13
|
+
i as createLogCollector,
|
|
14
|
+
s as setGlobalLogCollector,
|
|
15
|
+
r as unsetGlobalLogCollector
|
|
16
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@holz/log-collector",
|
|
3
|
+
"version": "0.8.0-rc.1",
|
|
4
|
+
"description": "Send all logs to a central collector",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "https://github.com/PsychoLlama/holz",
|
|
9
|
+
"directory": "packages/holz-log-collector"
|
|
10
|
+
},
|
|
11
|
+
"exports": {
|
|
12
|
+
".": {
|
|
13
|
+
"types": "./dist/holz-log-collector.d.ts",
|
|
14
|
+
"require": "./dist/holz-log-collector.cjs",
|
|
15
|
+
"import": "./dist/holz-log-collector.js"
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
"publishConfig": {
|
|
19
|
+
"access": "public"
|
|
20
|
+
},
|
|
21
|
+
"author": "Jesse Gibson",
|
|
22
|
+
"license": "MIT",
|
|
23
|
+
"sideEffects": false,
|
|
24
|
+
"files": [
|
|
25
|
+
"dist",
|
|
26
|
+
"src"
|
|
27
|
+
],
|
|
28
|
+
"keywords": [
|
|
29
|
+
"holz",
|
|
30
|
+
"collector",
|
|
31
|
+
"interceptor",
|
|
32
|
+
"aggregator"
|
|
33
|
+
],
|
|
34
|
+
"scripts": {
|
|
35
|
+
"prepack": "vite build",
|
|
36
|
+
"test:unit": "vitest --color --passWithNoTests",
|
|
37
|
+
"test:types": "tsc"
|
|
38
|
+
},
|
|
39
|
+
"peerDependencies": {
|
|
40
|
+
"@holz/core": "^0.8.0-rc.2"
|
|
41
|
+
},
|
|
42
|
+
"devDependencies": {
|
|
43
|
+
"@holz/core": "^0.8.0-rc.2",
|
|
44
|
+
"@types/node": "^22.0.0",
|
|
45
|
+
"@vitest/coverage-v8": "^3.0.8",
|
|
46
|
+
"typescript": "^5.8.2",
|
|
47
|
+
"vite": "^6.0.0",
|
|
48
|
+
"vite-plugin-dts": "^4.5.3",
|
|
49
|
+
"vite-tsconfig-paths": "^5.1.4",
|
|
50
|
+
"vitest": "^3.0.8"
|
|
51
|
+
},
|
|
52
|
+
"stableVersion": "0.7.0"
|
|
53
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
|
2
|
+
|
|
3
|
+
exports[`JSON backend > prints the logs to the writable stream 1`] = `
|
|
4
|
+
"{"level":"debug","time":"2020-06-15T12:00:00.000Z","msg":"shout"}
|
|
5
|
+
{"level":"info","time":"2020-06-15T12:00:00.000Z","msg":"normal"}
|
|
6
|
+
{"level":"warn","time":"2020-06-15T12:00:00.000Z","msg":"hmmmm"}
|
|
7
|
+
{"level":"error","time":"2020-06-15T12:00:00.000Z","msg":"oh no"}
|
|
8
|
+
"
|
|
9
|
+
`;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { createLogger, level } from '@holz/core';
|
|
2
|
+
import {
|
|
3
|
+
createLogCollector,
|
|
4
|
+
unsetGlobalLogCollector,
|
|
5
|
+
setGlobalLogCollector,
|
|
6
|
+
} from '../index';
|
|
7
|
+
|
|
8
|
+
const CURRENT_TIME = new Date('2010-10-01T00:00:00Z').getTime();
|
|
9
|
+
|
|
10
|
+
vi.setSystemTime(CURRENT_TIME);
|
|
11
|
+
|
|
12
|
+
describe('log collector', () => {
|
|
13
|
+
afterEach(() => {
|
|
14
|
+
unsetGlobalLogCollector();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
describe('createLogInterceptor', () => {
|
|
18
|
+
it('sends logs to the default backend', () => {
|
|
19
|
+
const fallback = vi.fn();
|
|
20
|
+
const logger = createLogger(createLogCollector({ fallback }));
|
|
21
|
+
|
|
22
|
+
logger.info('test message');
|
|
23
|
+
expect(fallback).toHaveBeenCalledOnce();
|
|
24
|
+
expect(fallback).toHaveBeenCalledWith({
|
|
25
|
+
timestamp: CURRENT_TIME,
|
|
26
|
+
message: 'test message',
|
|
27
|
+
level: level.info,
|
|
28
|
+
origin: [],
|
|
29
|
+
context: {},
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('sends logs to the interceptor if defined', () => {
|
|
34
|
+
const interceptor = vi.fn();
|
|
35
|
+
const fallback = vi.fn();
|
|
36
|
+
const logger = createLogger(createLogCollector({ fallback }));
|
|
37
|
+
|
|
38
|
+
setGlobalLogCollector({
|
|
39
|
+
processor: interceptor,
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
logger.info('test message');
|
|
43
|
+
expect(fallback).not.toHaveBeenCalled();
|
|
44
|
+
expect(interceptor).toHaveBeenCalledOnce();
|
|
45
|
+
expect(interceptor).toHaveBeenCalledWith({
|
|
46
|
+
timestamp: CURRENT_TIME,
|
|
47
|
+
message: 'test message',
|
|
48
|
+
level: level.info,
|
|
49
|
+
origin: [],
|
|
50
|
+
context: {},
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('uses the default backend if the interceptor does not match', () => {
|
|
55
|
+
const interceptor = vi.fn();
|
|
56
|
+
const fallback = vi.fn();
|
|
57
|
+
const logger = createLogger(createLogCollector({ fallback }));
|
|
58
|
+
|
|
59
|
+
setGlobalLogCollector({
|
|
60
|
+
processor: interceptor,
|
|
61
|
+
condition: () => false,
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
logger.info('test message');
|
|
65
|
+
expect(interceptor).not.toHaveBeenCalled();
|
|
66
|
+
expect(fallback).toHaveBeenCalledOnce();
|
|
67
|
+
expect(fallback).toHaveBeenCalledWith({
|
|
68
|
+
timestamp: CURRENT_TIME,
|
|
69
|
+
message: 'test message',
|
|
70
|
+
level: level.info,
|
|
71
|
+
origin: [],
|
|
72
|
+
context: {},
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
});
|
package/src/global.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { Log, LogProcessor } from '@holz/core';
|
|
2
|
+
|
|
3
|
+
const MATCH_ALL = () => true;
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Overrides the default log destination for all loggers in the app. Call this
|
|
7
|
+
* early on startup. If logs are sent before registering the collector, they
|
|
8
|
+
* will be lost.
|
|
9
|
+
*/
|
|
10
|
+
export const setGlobalLogCollector = ({
|
|
11
|
+
processor,
|
|
12
|
+
condition: match = MATCH_ALL,
|
|
13
|
+
}: InterceptorConfig) => {
|
|
14
|
+
globalScope[COLLECTOR_SYMBOL] = { processor, condition: match };
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Remove the global log collector restoring defaults. Safe to call regardless
|
|
19
|
+
* of whether one is set.
|
|
20
|
+
*/
|
|
21
|
+
export const unsetGlobalLogCollector = () => {
|
|
22
|
+
delete globalScope[COLLECTOR_SYMBOL];
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
interface InterceptorConfig {
|
|
26
|
+
/** Plugin handling all logs captured by the interceptor. */
|
|
27
|
+
processor: LogProcessor;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Only capture logs matching this function. Return `false` to use the
|
|
31
|
+
* default log destination. Defaults to capture all logs.
|
|
32
|
+
*/
|
|
33
|
+
condition?: (log: Log) => boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// As of 2025-04-12 there is no way to declare a symbol on `globalThis`.
|
|
37
|
+
export const globalScope = globalThis as unknown as GlobalContext;
|
|
38
|
+
|
|
39
|
+
// Stored in the global scope. Uses the symbol registry in case multiple
|
|
40
|
+
// versions of Holz are loaded in the same context.
|
|
41
|
+
export const COLLECTOR_SYMBOL = Symbol.for('@holz/log-collector');
|
|
42
|
+
|
|
43
|
+
interface GlobalContext {
|
|
44
|
+
[COLLECTOR_SYMBOL]?: Required<InterceptorConfig>;
|
|
45
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { LogProcessor } from '@holz/core';
|
|
2
|
+
import { globalScope, COLLECTOR_SYMBOL } from './global';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Redirects logs to a global collector if one is configured. This is designed
|
|
6
|
+
* with apps in mind, which may want to aggregate logs from multiple sources.
|
|
7
|
+
*/
|
|
8
|
+
export const createLogCollector =
|
|
9
|
+
(config: Config): LogProcessor =>
|
|
10
|
+
(log) => {
|
|
11
|
+
const collector = globalScope[COLLECTOR_SYMBOL];
|
|
12
|
+
|
|
13
|
+
if (collector?.condition(log)) {
|
|
14
|
+
collector.processor(log);
|
|
15
|
+
} else {
|
|
16
|
+
config.fallback(log);
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export interface Config {
|
|
21
|
+
fallback: LogProcessor;
|
|
22
|
+
}
|