@owlmeans/viable-log 0.1.18-rc.0
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 +15 -0
- package/agent-meta/manifest.json +16 -0
- package/agent-meta/skills/viable-log/SKILL.md +41 -0
- package/build/consts.d.ts +17 -0
- package/build/consts.d.ts.map +1 -0
- package/build/consts.js +17 -0
- package/build/consts.js.map +1 -0
- package/build/event.d.ts +10 -0
- package/build/event.d.ts.map +1 -0
- package/build/event.js +58 -0
- package/build/event.js.map +1 -0
- package/build/index.d.ts +5 -0
- package/build/index.d.ts.map +1 -0
- package/build/index.js +4 -0
- package/build/index.js.map +1 -0
- package/build/plugins.d.ts +22 -0
- package/build/plugins.d.ts.map +1 -0
- package/build/plugins.js +72 -0
- package/build/plugins.js.map +1 -0
- package/build/types.d.ts +34 -0
- package/build/types.d.ts.map +1 -0
- package/build/types.js +2 -0
- package/build/types.js.map +1 -0
- package/package.json +36 -0
- package/src/consts.ts +21 -0
- package/src/event.ts +63 -0
- package/src/index.ts +5 -0
- package/src/plugins.ts +83 -0
- package/src/types.ts +32 -0
- package/tests/viable-log.spec.ts +112 -0
- package/tsconfig.json +11 -0
package/README.md
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
<!-- owlmeans:agent-guidance:start -->
|
|
2
|
+
## Agent guidance
|
|
3
|
+
|
|
4
|
+
This package ships embedded agent skills under `agent-meta/`. After installing your
|
|
5
|
+
`@owlmeans/*` packages, run the OwlMeans agent-skills installer to place them into
|
|
6
|
+
your project's skill store (`.agents/skills/`):
|
|
7
|
+
|
|
8
|
+
```sh
|
|
9
|
+
npx @owlmeans/agent-skills@^0.1.18-rc.47
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
The embedded files are version-matched to this package release. Do not edit them
|
|
13
|
+
directly — they are regenerated on each publish. To contribute guidance edits,
|
|
14
|
+
open a PR against the source monorepo.
|
|
15
|
+
<!-- owlmeans:agent-guidance:end -->
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schemaVersion": 2,
|
|
3
|
+
"package": "@owlmeans/viable-log",
|
|
4
|
+
"version": "0.1.18-rc.0",
|
|
5
|
+
"generatedAt": "2026-10-03T20:03:44.509Z",
|
|
6
|
+
"canonicalRepo": "https://github.com/owlmeans/common",
|
|
7
|
+
"entries": [
|
|
8
|
+
{
|
|
9
|
+
"kind": "skill",
|
|
10
|
+
"name": "viable-log",
|
|
11
|
+
"category": "package-specific",
|
|
12
|
+
"file": "skills/viable-log/SKILL.md",
|
|
13
|
+
"canonicalPath": ".agents/skills/viable-log/SKILL.md"
|
|
14
|
+
}
|
|
15
|
+
]
|
|
16
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: viable-log
|
|
3
|
+
description: How to use @owlmeans/viable-log — a generated (target) application's link to the OwlMeans Viable platform, as two @owlmeans/log plugins. viablePreviewPlugin sends the target web's analytics events to the manager frame through the preview reporter's channel; viableSlotPlugin writes the target backend's error records and analytics events as marked JSON stdout lines for the publisher to relay. Also the shared contract the platform reads them with (TARGET_EVENT_MARKER, parseTargetEventLine, PREVIEW_REPORTER_FLAG). Auto-invoked when wiring the platform link into a generated app's web or backend, importing viablePreviewPlugin, viableSlotPlugin or parseTargetEventLine, or changing how the preview reporter or the publisher takes events from a target.
|
|
4
|
+
user-invocable: false
|
|
5
|
+
---
|
|
6
|
+
<!-- AUTO-GENERATED — do not edit. Regenerate via sync-agent-meta. -->
|
|
7
|
+
|
|
8
|
+
# @owlmeans/viable-log
|
|
9
|
+
|
|
10
|
+
**Layer:** Domain (depends on `@owlmeans/log` only)
|
|
11
|
+
**Install:** `"@owlmeans/viable-log": "^0.1.18-rc.0"` in `dependencies`
|
|
12
|
+
|
|
13
|
+
The platform is "just one of the plugins" of a target's logging: a generated app logs through
|
|
14
|
+
`@owlmeans/log`, and registers these two plugins so what it says reaches Viable. Both are inert
|
|
15
|
+
wherever the platform is absent — a production runtime, a laptop, a framed-less page — so they are
|
|
16
|
+
registered unconditionally.
|
|
17
|
+
|
|
18
|
+
```ts
|
|
19
|
+
// web: once, at startup
|
|
20
|
+
addLogPlugin(viablePreviewPlugin())
|
|
21
|
+
// backend (api, worker): once, at startup
|
|
22
|
+
addLogPlugin(viableSlotPlugin())
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
| Plugin | Sends | Through | Active when |
|
|
26
|
+
|---|---|---|---|
|
|
27
|
+
| `viablePreviewPlugin()` | analytics events | `window[PREVIEW_REPORTER_FLAG].post` — the channel the publisher's injected reporter owns, which attaches the document's identity and posts to the manager frame | the page is a framed preview (the reporter installed the channel) |
|
|
28
|
+
| `viableSlotPlugin()` | error-level records and analytics events | one stdout line per event: `TARGET_EVENT_MARKER` + JSON (`TargetEvent`) | the publisher that runs the process set `OWLMEANS_SLOT_EVENTS=1` |
|
|
29
|
+
|
|
30
|
+
Errors in the **browser** need no plugin: a record carrying an `Error` reaches the reporter's own
|
|
31
|
+
`console.error` hook through the logger's sink, exactly as a bare `console.error(error)` did.
|
|
32
|
+
|
|
33
|
+
## The contract
|
|
34
|
+
|
|
35
|
+
`parseTargetEventLine(line)` is how the platform's publisher reads a stdout line — **the input is
|
|
36
|
+
untrusted, written by generated code**: the marker, JSON, version
|
|
37
|
+
(`TARGET_EVENT_VERSION`), kind and level are checked, every text field is clipped, unknown fields are
|
|
38
|
+
dropped. `targetEventLine(event)` writes one, cutting a stack and then data to stay under
|
|
39
|
+
`TARGET_EVENT_MAX`. Change a field's shape only with a new `TARGET_EVENT_VERSION`. An application writes events only
|
|
40
|
+
through `@owlmeans/log` (`log.error(…)`, or a call with an `analytics` option) — never by building a
|
|
41
|
+
marked line or posting a preview message itself.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The property of the preview document's `window` that the platform's injected reporter claims.
|
|
3
|
+
* It holds the reporter's channel ({@link PreviewChannel}) once installed — in a framed preview
|
|
4
|
+
* only, never in a production runtime.
|
|
5
|
+
*/
|
|
6
|
+
export declare const PREVIEW_REPORTER_FLAG = "__owlmeansPreviewReporter";
|
|
7
|
+
/** The `type` of a preview message that carries an analytics event. Mirrors `PreviewEventType.Analytics`. */
|
|
8
|
+
export declare const PREVIEW_ANALYTICS_TYPE = "OWLMEANS_PREVIEW_ANALYTICS";
|
|
9
|
+
/** The prefix of a stdout line that carries a {@link TargetEvent}. */
|
|
10
|
+
export declare const TARGET_EVENT_MARKER = "[owl:event] ";
|
|
11
|
+
/** Set to `1` by the platform's publisher in the environment of a target's backend process. */
|
|
12
|
+
export declare const SLOT_EVENTS_ENV = "OWLMEANS_SLOT_EVENTS";
|
|
13
|
+
/** Format version of {@link TargetEvent}. */
|
|
14
|
+
export declare const TARGET_EVENT_VERSION = 1;
|
|
15
|
+
/** The longest line a backend writes and the publisher accepts: a stack is cut, never a boundary. */
|
|
16
|
+
export declare const TARGET_EVENT_MAX: number;
|
|
17
|
+
//# sourceMappingURL=consts.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"consts.d.ts","sourceRoot":"","sources":["../src/consts.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,eAAO,MAAM,qBAAqB,8BAA8B,CAAA;AAEhE,6GAA6G;AAC7G,eAAO,MAAM,sBAAsB,+BAA+B,CAAA;AAElE,sEAAsE;AACtE,eAAO,MAAM,mBAAmB,iBAAiB,CAAA;AAEjD,+FAA+F;AAC/F,eAAO,MAAM,eAAe,yBAAyB,CAAA;AAErD,6CAA6C;AAC7C,eAAO,MAAM,oBAAoB,IAAI,CAAA;AAErC,qGAAqG;AACrG,eAAO,MAAM,gBAAgB,QAAY,CAAA"}
|
package/build/consts.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The property of the preview document's `window` that the platform's injected reporter claims.
|
|
3
|
+
* It holds the reporter's channel ({@link PreviewChannel}) once installed — in a framed preview
|
|
4
|
+
* only, never in a production runtime.
|
|
5
|
+
*/
|
|
6
|
+
export const PREVIEW_REPORTER_FLAG = '__owlmeansPreviewReporter';
|
|
7
|
+
/** The `type` of a preview message that carries an analytics event. Mirrors `PreviewEventType.Analytics`. */
|
|
8
|
+
export const PREVIEW_ANALYTICS_TYPE = 'OWLMEANS_PREVIEW_ANALYTICS';
|
|
9
|
+
/** The prefix of a stdout line that carries a {@link TargetEvent}. */
|
|
10
|
+
export const TARGET_EVENT_MARKER = '[owl:event] ';
|
|
11
|
+
/** Set to `1` by the platform's publisher in the environment of a target's backend process. */
|
|
12
|
+
export const SLOT_EVENTS_ENV = 'OWLMEANS_SLOT_EVENTS';
|
|
13
|
+
/** Format version of {@link TargetEvent}. */
|
|
14
|
+
export const TARGET_EVENT_VERSION = 1;
|
|
15
|
+
/** The longest line a backend writes and the publisher accepts: a stack is cut, never a boundary. */
|
|
16
|
+
export const TARGET_EVENT_MAX = 16 * 1024;
|
|
17
|
+
//# sourceMappingURL=consts.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"consts.js","sourceRoot":"","sources":["../src/consts.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,MAAM,CAAC,MAAM,qBAAqB,GAAG,2BAA2B,CAAA;AAEhE,6GAA6G;AAC7G,MAAM,CAAC,MAAM,sBAAsB,GAAG,4BAA4B,CAAA;AAElE,sEAAsE;AACtE,MAAM,CAAC,MAAM,mBAAmB,GAAG,cAAc,CAAA;AAEjD,+FAA+F;AAC/F,MAAM,CAAC,MAAM,eAAe,GAAG,sBAAsB,CAAA;AAErD,6CAA6C;AAC7C,MAAM,CAAC,MAAM,oBAAoB,GAAG,CAAC,CAAA;AAErC,qGAAqG;AACrG,MAAM,CAAC,MAAM,gBAAgB,GAAG,EAAE,GAAG,IAAI,CAAA"}
|
package/build/event.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { TargetEvent } from './types.js';
|
|
2
|
+
/** The stdout line of an event. Over-long ones lose their stack, then their data — never their head. */
|
|
3
|
+
export declare const targetEventLine: (event: TargetEvent) => string;
|
|
4
|
+
/**
|
|
5
|
+
* The event a stdout line carries, or `undefined` when the line is not one — absent marker, broken
|
|
6
|
+
* JSON, unknown version, or a shape the platform does not accept. The input is untrusted: it is
|
|
7
|
+
* written by generated code, and nothing outside this function may read fields of it unchecked.
|
|
8
|
+
*/
|
|
9
|
+
export declare const parseTargetEventLine: (line: string) => TargetEvent | undefined;
|
|
10
|
+
//# sourceMappingURL=event.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"event.d.ts","sourceRoot":"","sources":["../src/event.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,YAAY,CAAA;AAI7C,wGAAwG;AACxG,eAAO,MAAM,eAAe,UAAW,WAAW,KAAG,MASpD,CAAA;AAKD;;;;GAIG;AACH,eAAO,MAAM,oBAAoB,SAAU,MAAM,KAAG,WAAW,GAAG,SAqCjE,CAAA"}
|
package/build/event.js
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { TARGET_EVENT_MARKER, TARGET_EVENT_MAX, TARGET_EVENT_VERSION } from './consts.js';
|
|
2
|
+
const LEVELS = ['debug', 'info', 'warn', 'error'];
|
|
3
|
+
/** The stdout line of an event. Over-long ones lose their stack, then their data — never their head. */
|
|
4
|
+
export const targetEventLine = (event) => {
|
|
5
|
+
let line = TARGET_EVENT_MARKER + JSON.stringify(event);
|
|
6
|
+
if (line.length > TARGET_EVENT_MAX && event.error?.stack != null) {
|
|
7
|
+
line = TARGET_EVENT_MARKER + JSON.stringify({ ...event, error: { ...event.error, stack: undefined } });
|
|
8
|
+
}
|
|
9
|
+
if (line.length > TARGET_EVENT_MAX) {
|
|
10
|
+
line = TARGET_EVENT_MARKER + JSON.stringify({ ...event, data: undefined });
|
|
11
|
+
}
|
|
12
|
+
return line.length > TARGET_EVENT_MAX ? line.slice(0, TARGET_EVENT_MAX) : line;
|
|
13
|
+
};
|
|
14
|
+
const text = (value, max) => typeof value === 'string' ? value.slice(0, max) : undefined;
|
|
15
|
+
/**
|
|
16
|
+
* The event a stdout line carries, or `undefined` when the line is not one — absent marker, broken
|
|
17
|
+
* JSON, unknown version, or a shape the platform does not accept. The input is untrusted: it is
|
|
18
|
+
* written by generated code, and nothing outside this function may read fields of it unchecked.
|
|
19
|
+
*/
|
|
20
|
+
export const parseTargetEventLine = (line) => {
|
|
21
|
+
const at = line.indexOf(TARGET_EVENT_MARKER);
|
|
22
|
+
if (at < 0 || line.length > TARGET_EVENT_MAX + 64) {
|
|
23
|
+
return undefined;
|
|
24
|
+
}
|
|
25
|
+
let raw;
|
|
26
|
+
try {
|
|
27
|
+
raw = JSON.parse(line.slice(at + TARGET_EVENT_MARKER.length));
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return undefined;
|
|
31
|
+
}
|
|
32
|
+
if (raw == null || typeof raw !== 'object') {
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
const event = raw;
|
|
36
|
+
if (event.v !== TARGET_EVENT_VERSION || (event.kind !== 'error' && event.kind !== 'analytics')
|
|
37
|
+
|| typeof event.ts !== 'number' || !LEVELS.includes(event.level)) {
|
|
38
|
+
return undefined;
|
|
39
|
+
}
|
|
40
|
+
const error = event.error != null && typeof event.error === 'object' ? event.error : undefined;
|
|
41
|
+
return {
|
|
42
|
+
v: TARGET_EVENT_VERSION,
|
|
43
|
+
kind: event.kind,
|
|
44
|
+
ts: event.ts,
|
|
45
|
+
level: event.level,
|
|
46
|
+
scope: text(event.scope, 200) ?? 'target',
|
|
47
|
+
message: text(event.message, 2000) ?? '',
|
|
48
|
+
event: text(event.event, 200),
|
|
49
|
+
data: event.data,
|
|
50
|
+
error: error != null
|
|
51
|
+
? {
|
|
52
|
+
name: text(error.name, 200) ?? 'Error', message: text(error.message, 2000) ?? '',
|
|
53
|
+
stack: text(error.stack, 8000), incidentId: text(error.incidentId, 100),
|
|
54
|
+
}
|
|
55
|
+
: undefined,
|
|
56
|
+
};
|
|
57
|
+
};
|
|
58
|
+
//# sourceMappingURL=event.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"event.js","sourceRoot":"","sources":["../src/event.ts"],"names":[],"mappings":"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"}
|
package/build/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,mBAAmB,YAAY,CAAA;AAC/B,cAAc,aAAa,CAAA;AAC3B,cAAc,YAAY,CAAA;AAC1B,cAAc,cAAc,CAAA"}
|
package/build/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAEA,cAAc,aAAa,CAAA;AAC3B,cAAc,YAAY,CAAA;AAC1B,cAAc,cAAc,CAAA"}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { LogPlugin } from '@owlmeans/log';
|
|
2
|
+
import type { PreviewChannel } from './types.js';
|
|
3
|
+
/** The reporter's channel of the current document, or `undefined` outside a framed preview. */
|
|
4
|
+
export declare const previewChannel: () => PreviewChannel | undefined;
|
|
5
|
+
/**
|
|
6
|
+
* The target web's link to the platform: analytics events go to the manager frame that embeds the
|
|
7
|
+
* preview, through the channel the platform's injected reporter owns.
|
|
8
|
+
*
|
|
9
|
+
* It is inert wherever that channel is absent — a production runtime, an unframed page, a server —
|
|
10
|
+
* so it is registered unconditionally and costs nothing for real users. Errors need no help from
|
|
11
|
+
* here: a record carrying an `Error` reaches the reporter's own `console.error` hook through the
|
|
12
|
+
* logger's console sink.
|
|
13
|
+
*/
|
|
14
|
+
export declare const viablePreviewPlugin: () => LogPlugin;
|
|
15
|
+
/**
|
|
16
|
+
* The target backend's link to the platform. When the publisher that runs the process asks for it
|
|
17
|
+
* (`OWLMEANS_SLOT_EVENTS=1`), every error-level record and every analytics event becomes one marked
|
|
18
|
+
* JSON line on stdout; the publisher takes it from there. Elsewhere — a production pod, a laptop —
|
|
19
|
+
* the plugin does nothing.
|
|
20
|
+
*/
|
|
21
|
+
export declare const viableSlotPlugin: () => LogPlugin;
|
|
22
|
+
//# sourceMappingURL=plugins.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"plugins.d.ts","sourceRoot":"","sources":["../src/plugins.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAa,MAAM,eAAe,CAAA;AAGzD,OAAO,KAAK,EAAE,cAAc,EAAe,MAAM,YAAY,CAAA;AAE7D,+FAA+F;AAC/F,eAAO,MAAM,cAAc,QAAO,cAAc,GAAG,SAOlD,CAAA;AAED;;;;;;;;GAQG;AACH,eAAO,MAAM,mBAAmB,QAAO,SAKrC,CAAA;AAsBF;;;;;GAKG;AACH,eAAO,MAAM,gBAAgB,QAAO,SAyBnC,CAAA"}
|
package/build/plugins.js
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { PREVIEW_ANALYTICS_TYPE, PREVIEW_REPORTER_FLAG, SLOT_EVENTS_ENV, TARGET_EVENT_VERSION } from './consts.js';
|
|
2
|
+
import { targetEventLine } from './event.js';
|
|
3
|
+
/** The reporter's channel of the current document, or `undefined` outside a framed preview. */
|
|
4
|
+
export const previewChannel = () => {
|
|
5
|
+
if (typeof window === 'undefined') {
|
|
6
|
+
return undefined;
|
|
7
|
+
}
|
|
8
|
+
const channel = window[PREVIEW_REPORTER_FLAG];
|
|
9
|
+
return channel != null && typeof channel === 'object' && typeof channel.post === 'function'
|
|
10
|
+
? channel : undefined;
|
|
11
|
+
};
|
|
12
|
+
/**
|
|
13
|
+
* The target web's link to the platform: analytics events go to the manager frame that embeds the
|
|
14
|
+
* preview, through the channel the platform's injected reporter owns.
|
|
15
|
+
*
|
|
16
|
+
* It is inert wherever that channel is absent — a production runtime, an unframed page, a server —
|
|
17
|
+
* so it is registered unconditionally and costs nothing for real users. Errors need no help from
|
|
18
|
+
* here: a record carrying an `Error` reaches the reporter's own `console.error` hook through the
|
|
19
|
+
* logger's console sink.
|
|
20
|
+
*/
|
|
21
|
+
export const viablePreviewPlugin = () => ({
|
|
22
|
+
name: 'viable-preview',
|
|
23
|
+
track: event => {
|
|
24
|
+
previewChannel()?.post(PREVIEW_ANALYTICS_TYPE, event);
|
|
25
|
+
},
|
|
26
|
+
});
|
|
27
|
+
const stdout = () => globalThis.process?.stdout;
|
|
28
|
+
const slotEventsEnabled = () => globalThis.process?.env?.[SLOT_EVENTS_ENV] === '1';
|
|
29
|
+
const errorOf = (record) => {
|
|
30
|
+
const error = record.error;
|
|
31
|
+
if (error == null) {
|
|
32
|
+
return undefined;
|
|
33
|
+
}
|
|
34
|
+
const incidentId = error.incidentId;
|
|
35
|
+
return {
|
|
36
|
+
name: error.name, message: error.message, stack: error.stack,
|
|
37
|
+
incidentId: typeof incidentId === 'string' ? incidentId : undefined,
|
|
38
|
+
};
|
|
39
|
+
};
|
|
40
|
+
/**
|
|
41
|
+
* The target backend's link to the platform. When the publisher that runs the process asks for it
|
|
42
|
+
* (`OWLMEANS_SLOT_EVENTS=1`), every error-level record and every analytics event becomes one marked
|
|
43
|
+
* JSON line on stdout; the publisher takes it from there. Elsewhere — a production pod, a laptop —
|
|
44
|
+
* the plugin does nothing.
|
|
45
|
+
*/
|
|
46
|
+
export const viableSlotPlugin = () => {
|
|
47
|
+
const emit = (event) => {
|
|
48
|
+
const out = stdout();
|
|
49
|
+
if (out != null && slotEventsEnabled()) {
|
|
50
|
+
out.write(`${targetEventLine(event)}\n`);
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
return {
|
|
54
|
+
name: 'viable-slot',
|
|
55
|
+
log: record => {
|
|
56
|
+
if (record.level !== 'error') {
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
emit({
|
|
60
|
+
v: TARGET_EVENT_VERSION, kind: 'error', ts: record.time, level: 'error', scope: record.scope,
|
|
61
|
+
message: record.message, event: record.event, data: record.data, error: errorOf(record),
|
|
62
|
+
});
|
|
63
|
+
},
|
|
64
|
+
track: event => {
|
|
65
|
+
emit({
|
|
66
|
+
v: TARGET_EVENT_VERSION, kind: 'analytics', ts: event.time, level: event.level, scope: event.scope,
|
|
67
|
+
message: event.message, event: event.event, data: event.data,
|
|
68
|
+
});
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
};
|
|
72
|
+
//# sourceMappingURL=plugins.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"plugins.js","sourceRoot":"","sources":["../src/plugins.ts"],"names":[],"mappings":"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"}
|
package/build/types.d.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { AnalyticsEvent } from '@owlmeans/log';
|
|
2
|
+
/** What a preview document's reporter offers a plugin: send one message to the manager frame. */
|
|
3
|
+
export interface PreviewChannel {
|
|
4
|
+
v: number;
|
|
5
|
+
post: (type: string, payload: unknown) => void;
|
|
6
|
+
}
|
|
7
|
+
export type TargetEventKind = 'error' | 'analytics';
|
|
8
|
+
/**
|
|
9
|
+
* One event of a target's BACKEND process, as a single stdout line (`TARGET_EVENT_MARKER` + JSON).
|
|
10
|
+
*
|
|
11
|
+
* The publisher runs the process, so the line is the only channel it needs: it is parsed there,
|
|
12
|
+
* kept out of the application's own output and relayed to the platform.
|
|
13
|
+
*/
|
|
14
|
+
export interface TargetEvent {
|
|
15
|
+
v: number;
|
|
16
|
+
kind: TargetEventKind;
|
|
17
|
+
/** Epoch milliseconds. */
|
|
18
|
+
ts: number;
|
|
19
|
+
level: 'debug' | 'info' | 'warn' | 'error';
|
|
20
|
+
scope: string;
|
|
21
|
+
message: string;
|
|
22
|
+
/** The analytics event name, or the `event` an error record carried. */
|
|
23
|
+
event?: string;
|
|
24
|
+
data?: unknown;
|
|
25
|
+
error?: {
|
|
26
|
+
name: string;
|
|
27
|
+
message: string;
|
|
28
|
+
stack?: string;
|
|
29
|
+
incidentId?: string;
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
/** The payload of a preview `Analytics` message (browser → manager). */
|
|
33
|
+
export type PreviewAnalyticsPayload = AnalyticsEvent;
|
|
34
|
+
//# sourceMappingURL=types.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,eAAe,CAAA;AAEnD,iGAAiG;AACjG,MAAM,WAAW,cAAc;IAC7B,CAAC,EAAE,MAAM,CAAA;IACT,IAAI,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,OAAO,KAAK,IAAI,CAAA;CAC/C;AAED,MAAM,MAAM,eAAe,GAAG,OAAO,GAAG,WAAW,CAAA;AAEnD;;;;;GAKG;AACH,MAAM,WAAW,WAAW;IAC1B,CAAC,EAAE,MAAM,CAAA;IACT,IAAI,EAAE,eAAe,CAAA;IACrB,0BAA0B;IAC1B,EAAE,EAAE,MAAM,CAAA;IACV,KAAK,EAAE,OAAO,GAAG,MAAM,GAAG,MAAM,GAAG,OAAO,CAAA;IAC1C,KAAK,EAAE,MAAM,CAAA;IACb,OAAO,EAAE,MAAM,CAAA;IACf,wEAAwE;IACxE,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,IAAI,CAAC,EAAE,OAAO,CAAA;IACd,KAAK,CAAC,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAC;QAAC,KAAK,CAAC,EAAE,MAAM,CAAC;QAAC,UAAU,CAAC,EAAE,MAAM,CAAA;KAAE,CAAA;CAC/E;AAED,wEAAwE;AACxE,MAAM,MAAM,uBAAuB,GAAG,cAAc,CAAA"}
|
package/build/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":""}
|
package/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@owlmeans/viable-log",
|
|
3
|
+
"version": "0.1.18-rc.0",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"build": "tsc -b",
|
|
8
|
+
"dev": "sleep 2 && nodemon -e ts,json --watch src --exec \"tsc -p ./tsconfig.json\"",
|
|
9
|
+
"watch": "tsc -b -w --preserveWatchOutput --pretty",
|
|
10
|
+
"test": "bun test ./tests"
|
|
11
|
+
},
|
|
12
|
+
"main": "build/index.js",
|
|
13
|
+
"module": "build/index.js",
|
|
14
|
+
"types": "build/index.d.ts",
|
|
15
|
+
"exports": {
|
|
16
|
+
".": {
|
|
17
|
+
"import": "./build/index.js",
|
|
18
|
+
"require": "./build/index.js",
|
|
19
|
+
"default": "./build/index.js",
|
|
20
|
+
"module": "./build/index.js",
|
|
21
|
+
"types": "./build/index.d.ts"
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
"dependencies": {
|
|
25
|
+
"@owlmeans/log": "^0.1.18-rc.0"
|
|
26
|
+
},
|
|
27
|
+
"devDependencies": {
|
|
28
|
+
"@owlmeans/dep-config": "workspace:*",
|
|
29
|
+
"@types/bun": "^1.4.0",
|
|
30
|
+
"nodemon": "^3.1.14",
|
|
31
|
+
"typescript": "^7.0.2"
|
|
32
|
+
},
|
|
33
|
+
"publishConfig": {
|
|
34
|
+
"access": "public"
|
|
35
|
+
}
|
|
36
|
+
}
|
package/src/consts.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The property of the preview document's `window` that the platform's injected reporter claims.
|
|
3
|
+
* It holds the reporter's channel ({@link PreviewChannel}) once installed — in a framed preview
|
|
4
|
+
* only, never in a production runtime.
|
|
5
|
+
*/
|
|
6
|
+
export const PREVIEW_REPORTER_FLAG = '__owlmeansPreviewReporter'
|
|
7
|
+
|
|
8
|
+
/** The `type` of a preview message that carries an analytics event. Mirrors `PreviewEventType.Analytics`. */
|
|
9
|
+
export const PREVIEW_ANALYTICS_TYPE = 'OWLMEANS_PREVIEW_ANALYTICS'
|
|
10
|
+
|
|
11
|
+
/** The prefix of a stdout line that carries a {@link TargetEvent}. */
|
|
12
|
+
export const TARGET_EVENT_MARKER = '[owl:event] '
|
|
13
|
+
|
|
14
|
+
/** Set to `1` by the platform's publisher in the environment of a target's backend process. */
|
|
15
|
+
export const SLOT_EVENTS_ENV = 'OWLMEANS_SLOT_EVENTS'
|
|
16
|
+
|
|
17
|
+
/** Format version of {@link TargetEvent}. */
|
|
18
|
+
export const TARGET_EVENT_VERSION = 1
|
|
19
|
+
|
|
20
|
+
/** The longest line a backend writes and the publisher accepts: a stack is cut, never a boundary. */
|
|
21
|
+
export const TARGET_EVENT_MAX = 16 * 1024
|
package/src/event.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { TARGET_EVENT_MARKER, TARGET_EVENT_MAX, TARGET_EVENT_VERSION } from './consts.js'
|
|
2
|
+
import type { TargetEvent } from './types.js'
|
|
3
|
+
|
|
4
|
+
const LEVELS = ['debug', 'info', 'warn', 'error']
|
|
5
|
+
|
|
6
|
+
/** The stdout line of an event. Over-long ones lose their stack, then their data — never their head. */
|
|
7
|
+
export const targetEventLine = (event: TargetEvent): string => {
|
|
8
|
+
let line = TARGET_EVENT_MARKER + JSON.stringify(event)
|
|
9
|
+
if (line.length > TARGET_EVENT_MAX && event.error?.stack != null) {
|
|
10
|
+
line = TARGET_EVENT_MARKER + JSON.stringify({ ...event, error: { ...event.error, stack: undefined } })
|
|
11
|
+
}
|
|
12
|
+
if (line.length > TARGET_EVENT_MAX) {
|
|
13
|
+
line = TARGET_EVENT_MARKER + JSON.stringify({ ...event, data: undefined })
|
|
14
|
+
}
|
|
15
|
+
return line.length > TARGET_EVENT_MAX ? line.slice(0, TARGET_EVENT_MAX) : line
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const text = (value: unknown, max: number): string | undefined =>
|
|
19
|
+
typeof value === 'string' ? value.slice(0, max) : undefined
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The event a stdout line carries, or `undefined` when the line is not one — absent marker, broken
|
|
23
|
+
* JSON, unknown version, or a shape the platform does not accept. The input is untrusted: it is
|
|
24
|
+
* written by generated code, and nothing outside this function may read fields of it unchecked.
|
|
25
|
+
*/
|
|
26
|
+
export const parseTargetEventLine = (line: string): TargetEvent | undefined => {
|
|
27
|
+
const at = line.indexOf(TARGET_EVENT_MARKER)
|
|
28
|
+
if (at < 0 || line.length > TARGET_EVENT_MAX + 64) {
|
|
29
|
+
return undefined
|
|
30
|
+
}
|
|
31
|
+
let raw: unknown
|
|
32
|
+
try {
|
|
33
|
+
raw = JSON.parse(line.slice(at + TARGET_EVENT_MARKER.length))
|
|
34
|
+
} catch {
|
|
35
|
+
return undefined
|
|
36
|
+
}
|
|
37
|
+
if (raw == null || typeof raw !== 'object') {
|
|
38
|
+
return undefined
|
|
39
|
+
}
|
|
40
|
+
const event = raw as Record<string, unknown>
|
|
41
|
+
if (event.v !== TARGET_EVENT_VERSION || (event.kind !== 'error' && event.kind !== 'analytics')
|
|
42
|
+
|| typeof event.ts !== 'number' || !LEVELS.includes(event.level as string)) {
|
|
43
|
+
return undefined
|
|
44
|
+
}
|
|
45
|
+
const error = event.error != null && typeof event.error === 'object' ? event.error as Record<string, unknown> : undefined
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
v: TARGET_EVENT_VERSION,
|
|
49
|
+
kind: event.kind,
|
|
50
|
+
ts: event.ts,
|
|
51
|
+
level: event.level as TargetEvent['level'],
|
|
52
|
+
scope: text(event.scope, 200) ?? 'target',
|
|
53
|
+
message: text(event.message, 2000) ?? '',
|
|
54
|
+
event: text(event.event, 200),
|
|
55
|
+
data: event.data,
|
|
56
|
+
error: error != null
|
|
57
|
+
? {
|
|
58
|
+
name: text(error.name, 200) ?? 'Error', message: text(error.message, 2000) ?? '',
|
|
59
|
+
stack: text(error.stack, 8000), incidentId: text(error.incidentId, 100),
|
|
60
|
+
}
|
|
61
|
+
: undefined,
|
|
62
|
+
}
|
|
63
|
+
}
|
package/src/index.ts
ADDED
package/src/plugins.ts
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import type { LogPlugin, LogRecord } from '@owlmeans/log'
|
|
2
|
+
import { PREVIEW_ANALYTICS_TYPE, PREVIEW_REPORTER_FLAG, SLOT_EVENTS_ENV, TARGET_EVENT_VERSION } from './consts.js'
|
|
3
|
+
import { targetEventLine } from './event.js'
|
|
4
|
+
import type { PreviewChannel, TargetEvent } from './types.js'
|
|
5
|
+
|
|
6
|
+
/** The reporter's channel of the current document, or `undefined` outside a framed preview. */
|
|
7
|
+
export const previewChannel = (): PreviewChannel | undefined => {
|
|
8
|
+
if (typeof window === 'undefined') {
|
|
9
|
+
return undefined
|
|
10
|
+
}
|
|
11
|
+
const channel = (window as unknown as Record<string, unknown>)[PREVIEW_REPORTER_FLAG] as Partial<PreviewChannel> | undefined
|
|
12
|
+
return channel != null && typeof channel === 'object' && typeof channel.post === 'function'
|
|
13
|
+
? channel as PreviewChannel : undefined
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The target web's link to the platform: analytics events go to the manager frame that embeds the
|
|
18
|
+
* preview, through the channel the platform's injected reporter owns.
|
|
19
|
+
*
|
|
20
|
+
* It is inert wherever that channel is absent — a production runtime, an unframed page, a server —
|
|
21
|
+
* so it is registered unconditionally and costs nothing for real users. Errors need no help from
|
|
22
|
+
* here: a record carrying an `Error` reaches the reporter's own `console.error` hook through the
|
|
23
|
+
* logger's console sink.
|
|
24
|
+
*/
|
|
25
|
+
export const viablePreviewPlugin = (): LogPlugin => ({
|
|
26
|
+
name: 'viable-preview',
|
|
27
|
+
track: event => {
|
|
28
|
+
previewChannel()?.post(PREVIEW_ANALYTICS_TYPE, event)
|
|
29
|
+
},
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
interface Stdout { write: (chunk: string) => unknown }
|
|
33
|
+
|
|
34
|
+
const stdout = (): Stdout | undefined =>
|
|
35
|
+
(globalThis as { process?: { stdout?: Stdout } }).process?.stdout
|
|
36
|
+
|
|
37
|
+
const slotEventsEnabled = (): boolean =>
|
|
38
|
+
(globalThis as { process?: { env?: Record<string, string | undefined> } }).process?.env?.[SLOT_EVENTS_ENV] === '1'
|
|
39
|
+
|
|
40
|
+
const errorOf = (record: LogRecord): TargetEvent['error'] => {
|
|
41
|
+
const error = record.error
|
|
42
|
+
if (error == null) {
|
|
43
|
+
return undefined
|
|
44
|
+
}
|
|
45
|
+
const incidentId = (error as { incidentId?: unknown }).incidentId
|
|
46
|
+
return {
|
|
47
|
+
name: error.name, message: error.message, stack: error.stack,
|
|
48
|
+
incidentId: typeof incidentId === 'string' ? incidentId : undefined,
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The target backend's link to the platform. When the publisher that runs the process asks for it
|
|
54
|
+
* (`OWLMEANS_SLOT_EVENTS=1`), every error-level record and every analytics event becomes one marked
|
|
55
|
+
* JSON line on stdout; the publisher takes it from there. Elsewhere — a production pod, a laptop —
|
|
56
|
+
* the plugin does nothing.
|
|
57
|
+
*/
|
|
58
|
+
export const viableSlotPlugin = (): LogPlugin => {
|
|
59
|
+
const emit = (event: TargetEvent): void => {
|
|
60
|
+
const out = stdout()
|
|
61
|
+
if (out != null && slotEventsEnabled()) {
|
|
62
|
+
out.write(`${targetEventLine(event)}\n`)
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return {
|
|
66
|
+
name: 'viable-slot',
|
|
67
|
+
log: record => {
|
|
68
|
+
if (record.level !== 'error') {
|
|
69
|
+
return
|
|
70
|
+
}
|
|
71
|
+
emit({
|
|
72
|
+
v: TARGET_EVENT_VERSION, kind: 'error', ts: record.time, level: 'error', scope: record.scope,
|
|
73
|
+
message: record.message, event: record.event, data: record.data, error: errorOf(record),
|
|
74
|
+
})
|
|
75
|
+
},
|
|
76
|
+
track: event => {
|
|
77
|
+
emit({
|
|
78
|
+
v: TARGET_EVENT_VERSION, kind: 'analytics', ts: event.time, level: event.level, scope: event.scope,
|
|
79
|
+
message: event.message, event: event.event, data: event.data,
|
|
80
|
+
})
|
|
81
|
+
},
|
|
82
|
+
}
|
|
83
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { AnalyticsEvent } from '@owlmeans/log'
|
|
2
|
+
|
|
3
|
+
/** What a preview document's reporter offers a plugin: send one message to the manager frame. */
|
|
4
|
+
export interface PreviewChannel {
|
|
5
|
+
v: number
|
|
6
|
+
post: (type: string, payload: unknown) => void
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export type TargetEventKind = 'error' | 'analytics'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* One event of a target's BACKEND process, as a single stdout line (`TARGET_EVENT_MARKER` + JSON).
|
|
13
|
+
*
|
|
14
|
+
* The publisher runs the process, so the line is the only channel it needs: it is parsed there,
|
|
15
|
+
* kept out of the application's own output and relayed to the platform.
|
|
16
|
+
*/
|
|
17
|
+
export interface TargetEvent {
|
|
18
|
+
v: number
|
|
19
|
+
kind: TargetEventKind
|
|
20
|
+
/** Epoch milliseconds. */
|
|
21
|
+
ts: number
|
|
22
|
+
level: 'debug' | 'info' | 'warn' | 'error'
|
|
23
|
+
scope: string
|
|
24
|
+
message: string
|
|
25
|
+
/** The analytics event name, or the `event` an error record carried. */
|
|
26
|
+
event?: string
|
|
27
|
+
data?: unknown
|
|
28
|
+
error?: { name: string, message: string, stack?: string, incidentId?: string }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The payload of a preview `Analytics` message (browser → manager). */
|
|
32
|
+
export type PreviewAnalyticsPayload = AnalyticsEvent
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
|
|
2
|
+
import { addLogPlugin, logger, nativeConsole, resetLog } from '@owlmeans/log'
|
|
3
|
+
import {
|
|
4
|
+
PREVIEW_ANALYTICS_TYPE, PREVIEW_REPORTER_FLAG, SLOT_EVENTS_ENV, TARGET_EVENT_MARKER, TARGET_EVENT_MAX,
|
|
5
|
+
parseTargetEventLine, targetEventLine, viablePreviewPlugin, viableSlotPlugin,
|
|
6
|
+
} from '@owlmeans/viable-log'
|
|
7
|
+
import type { TargetEvent } from '@owlmeans/viable-log'
|
|
8
|
+
|
|
9
|
+
const sample = (patch: Partial<TargetEvent> = {}): TargetEvent => ({
|
|
10
|
+
v: 1, kind: 'error', ts: 1, level: 'error', scope: 'api', message: 'boom', ...patch,
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
describe('target event line', () => {
|
|
14
|
+
test('round-trips through targetEventLine and parseTargetEventLine', () => {
|
|
15
|
+
const event = sample({ data: { id: 1 }, error: { name: 'Error', message: 'x', stack: 's', incidentId: 'i' }, event: 'e' })
|
|
16
|
+
expect(parseTargetEventLine(targetEventLine(event))).toEqual(event)
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
test('finds the marker anywhere in a line (prefixed stdout)', () => {
|
|
20
|
+
expect(parseTargetEventLine(`12:00 ${targetEventLine(sample())}`)?.message).toBe('boom')
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
test('rejects a line without a marker, broken JSON, a wrong version or kind, a bad level', () => {
|
|
24
|
+
expect(parseTargetEventLine('plain')).toBeUndefined()
|
|
25
|
+
expect(parseTargetEventLine(`${TARGET_EVENT_MARKER}{nope`)).toBeUndefined()
|
|
26
|
+
expect(parseTargetEventLine(`${TARGET_EVENT_MARKER}${JSON.stringify({ ...sample(), v: 2 })}`)).toBeUndefined()
|
|
27
|
+
expect(parseTargetEventLine(`${TARGET_EVENT_MARKER}${JSON.stringify({ ...sample(), kind: 'x' })}`)).toBeUndefined()
|
|
28
|
+
expect(parseTargetEventLine(`${TARGET_EVENT_MARKER}${JSON.stringify({ ...sample(), level: 'fatal' })}`)).toBeUndefined()
|
|
29
|
+
expect(parseTargetEventLine(`${TARGET_EVENT_MARKER}null`)).toBeUndefined()
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
test('clips untrusted text fields and ignores unknown fields', () => {
|
|
33
|
+
const line = `${TARGET_EVENT_MARKER}${JSON.stringify({ ...sample(), message: 'm'.repeat(5000), extra: 'x', scope: 7 })}`
|
|
34
|
+
const parsed = parseTargetEventLine(line)!
|
|
35
|
+
expect(parsed.message.length).toBe(2000)
|
|
36
|
+
expect(parsed.scope).toBe('target')
|
|
37
|
+
expect('extra' in parsed).toBe(false)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
test('an over-long event loses its stack first, then its data, and stays under the cap', () => {
|
|
41
|
+
const stackless = targetEventLine(sample({ error: { name: 'E', message: 'm', stack: 's'.repeat(TARGET_EVENT_MAX * 2) } }))
|
|
42
|
+
expect(stackless.length).toBeLessThanOrEqual(TARGET_EVENT_MAX)
|
|
43
|
+
expect(parseTargetEventLine(stackless)?.error?.message).toBe('m')
|
|
44
|
+
const dataless = targetEventLine(sample({ data: 'd'.repeat(TARGET_EVENT_MAX * 2) }))
|
|
45
|
+
expect(dataless.length).toBeLessThanOrEqual(TARGET_EVENT_MAX)
|
|
46
|
+
expect(parseTargetEventLine(dataless)?.message).toBe('boom')
|
|
47
|
+
})
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
describe('viablePreviewPlugin', () => {
|
|
51
|
+
const win = globalThis as unknown as { window?: Record<string, unknown> }
|
|
52
|
+
beforeEach(() => { resetLog() })
|
|
53
|
+
afterEach(() => { delete win.window; resetLog() })
|
|
54
|
+
|
|
55
|
+
test('posts analytics events through the reporter channel', () => {
|
|
56
|
+
const posted: unknown[][] = []
|
|
57
|
+
win.window = { [PREVIEW_REPORTER_FLAG]: { v: 2, post: (...args: unknown[]) => posted.push(args) } }
|
|
58
|
+
addLogPlugin(viablePreviewPlugin())
|
|
59
|
+
logger('ui').info('Opened', { page: 'home' }, { analytics: 'page_view' })
|
|
60
|
+
expect(posted).toHaveLength(1)
|
|
61
|
+
expect(posted[0][0]).toBe(PREVIEW_ANALYTICS_TYPE)
|
|
62
|
+
expect(posted[0][1]).toMatchObject({ event: 'page_view', scope: 'ui', data: { page: 'home' } })
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
test('is inert without the channel — a flag that is a bare `true` is not a channel', () => {
|
|
66
|
+
addLogPlugin(viablePreviewPlugin())
|
|
67
|
+
expect(() => logger('ui').info('x', undefined, { analytics: true })).not.toThrow()
|
|
68
|
+
win.window = { [PREVIEW_REPORTER_FLAG]: true }
|
|
69
|
+
expect(() => logger('ui').info('x', undefined, { analytics: true })).not.toThrow()
|
|
70
|
+
})
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
describe('viableSlotPlugin', () => {
|
|
74
|
+
const written: string[] = []
|
|
75
|
+
const proc = globalThis.process
|
|
76
|
+
const realWrite = proc.stdout.write.bind(proc.stdout)
|
|
77
|
+
let saved: ReturnType<typeof nativeConsole>
|
|
78
|
+
|
|
79
|
+
beforeEach(() => {
|
|
80
|
+
resetLog()
|
|
81
|
+
written.length = 0
|
|
82
|
+
saved = { ...nativeConsole() }
|
|
83
|
+
for (const key of Object.keys(nativeConsole()) as (keyof ReturnType<typeof nativeConsole>)[]) nativeConsole()[key] = () => undefined
|
|
84
|
+
;(proc.stdout as { write: unknown }).write = (chunk: string) => { written.push(String(chunk)); return true }
|
|
85
|
+
})
|
|
86
|
+
afterEach(() => {
|
|
87
|
+
;(proc.stdout as { write: unknown }).write = realWrite
|
|
88
|
+
Object.assign(nativeConsole(), saved)
|
|
89
|
+
delete proc.env[SLOT_EVENTS_ENV]
|
|
90
|
+
resetLog()
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
test('writes nothing unless the publisher asked for it', () => {
|
|
94
|
+
addLogPlugin(viableSlotPlugin())
|
|
95
|
+
logger('api').error('boom', new Error('x'))
|
|
96
|
+
expect(written).toEqual([])
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
test('writes error records and analytics events as marked lines when asked', () => {
|
|
100
|
+
proc.env[SLOT_EVENTS_ENV] = '1'
|
|
101
|
+
addLogPlugin(viableSlotPlugin())
|
|
102
|
+
const error = Object.assign(new Error('db down'), { incidentId: 'inc-1' })
|
|
103
|
+
logger('api').error('Request failed', error)
|
|
104
|
+
logger('api').info('Signed up', { plan: 'free' }, { analytics: 'signup' })
|
|
105
|
+
logger('api').info('plain info')
|
|
106
|
+
logger('api').warn('plain warn')
|
|
107
|
+
const events = written.map(line => parseTargetEventLine(line)!)
|
|
108
|
+
expect(events.map(e => [e.kind, e.event ?? e.message])).toEqual([['error', 'Request failed'], ['analytics', 'signup']])
|
|
109
|
+
expect(events[0].error).toMatchObject({ name: 'Error', message: 'db down', incidentId: 'inc-1' })
|
|
110
|
+
expect(events[1].data).toEqual({ plan: 'free' })
|
|
111
|
+
})
|
|
112
|
+
})
|
package/tsconfig.json
ADDED