@feasibleone/blong-gogo 1.34.0 → 1.35.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/CHANGELOG.md +17 -0
- package/bin/blong.ts +13 -0
- package/package.json +5 -3
- package/src/AdapterBase.ts +54 -2
- package/src/BrowserLog.ts +16 -2
- package/src/Gateway.ts +175 -3
- package/src/Log.ts +73 -3
- package/src/Registry.ts +7 -0
- package/src/RpcServer.ts +14 -3
- package/src/SemanticLog.test.ts +509 -0
- package/src/SemanticLog.ts +264 -0
- package/src/Watch.ts +69 -21
- package/src/callTrace.test.ts +80 -0
- package/src/callTrace.ts +126 -0
- package/src/cliHelp.ts +2 -0
- package/src/devKeys.ts +38 -0
- package/src/exit.intent.test.ts +21 -6
- package/src/frameworkRealms.test.ts +177 -0
- package/src/grant.test.ts +168 -0
- package/src/grant.ts +273 -0
- package/src/handlerProxy.ts +72 -3
- package/src/layerProxy.ts +9 -1
- package/src/lib.ts +69 -0
- package/src/load.ts +410 -34
- package/src/loadServer.ts +7 -0
- package/src/orchestrator/common/dispatch.ts +30 -6
- package/src/pino-pretty.ts +1 -1
- package/src/semanticContext.test.ts +322 -0
- package/src/semanticContext.ts +351 -0
- package/src/semanticRecord.ts +54 -0
- package/template/index.browser.ts +4 -0
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The framework's server-side logger, backed by `@feasibleone/semantic-log`.
|
|
3
|
+
*
|
|
4
|
+
* It is the alternative to `Log` (pino) and implements the same `ILog` contract,
|
|
5
|
+
* so `Internal`'s `this.log`, `api.createLog` and every existing call site are
|
|
6
|
+
* unaffected by which one is selected. The emitter's own log — construction, one
|
|
7
|
+
* logger per component, retention, the cluster sink and the lifecycle — is
|
|
8
|
+
* `LogBase` in `@feasibleone/semantic-log/emitter`; what is left here is what is
|
|
9
|
+
* blong's, and there are three things:
|
|
10
|
+
*
|
|
11
|
+
* - **The envelope.** The framework's call sites pass `{$meta: {mtid, method}}`,
|
|
12
|
+
* and its call records carry the same envelope (`callTrace.ts`), so both are
|
|
13
|
+
* read here — an envelope is not something semantic-log could know about.
|
|
14
|
+
* - **The level contract.** `logger(level, bindings)` binds *downwards*: the
|
|
15
|
+
* original implementation uses a switch with no `break`s, so asking for `info`
|
|
16
|
+
* binds `info` through `fatal` and leaves `trace`/`debug` undefined. Call sites
|
|
17
|
+
* depend on that: a `this.log?.debug?.()` at info level is a silent no-op rather
|
|
18
|
+
* than a call. `ILog.logger` also promises `progress`, which is blong-lib's.
|
|
19
|
+
* - **The call gate.** Whether a flow records its calls is the framework's
|
|
20
|
+
* configuration and its `blong grant` tokens (`callTrace.ts`), read at the
|
|
21
|
+
* boundary that mints a flow rather than here.
|
|
22
|
+
*
|
|
23
|
+
* ## Why this *holds* a base rather than extending one
|
|
24
|
+
*
|
|
25
|
+
* `LogBase` is what blong would extend, except that it cannot: the loader decides
|
|
26
|
+
* that a module is a component by testing `prototype instanceof Internal`
|
|
27
|
+
* (`load.ts`), and semantic-log's side of the world knows nothing of `Internal` —
|
|
28
|
+
* nor should it. So the framework's class extends `Internal` as every other
|
|
29
|
+
* component does, and delegates to a `LogBase` it owns. The hooks the base would
|
|
30
|
+
* otherwise ask a subclass for are options, which is what makes that possible.
|
|
31
|
+
*
|
|
32
|
+
* The emitter is reached through `@feasibleone/semantic-log/emitter`, not the
|
|
33
|
+
* package root: the root re-exports the cluster service, which imports `fastify`,
|
|
34
|
+
* and this module is on a path `loadBrowser.ts` shares. Keeping the service out of
|
|
35
|
+
* the import graph is what stops a browser bundle from trying to resolve a server
|
|
36
|
+
* HTTP framework.
|
|
37
|
+
*
|
|
38
|
+
* ## `fatal` does not exit
|
|
39
|
+
*
|
|
40
|
+
* The emitter's `fatal` writes a record and then calls `exit`, because its own
|
|
41
|
+
* process-failure hooks install it that way. The framework's `fatal` has always
|
|
42
|
+
* meant "log this at fatal level" — pino ends nothing — and `LogBase` leaves `exit`
|
|
43
|
+
* a no-op for exactly that reason. Installing the emitter's failure capture is a
|
|
44
|
+
* separate decision, and not one this class makes for every process that logs.
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
import {merge, withProgress} from '@feasibleone/blong-lib';
|
|
48
|
+
import {Internal, type ICallLog, type ILog, type ILogger} from '@feasibleone/blong/types';
|
|
49
|
+
import {
|
|
50
|
+
LogBase,
|
|
51
|
+
resolveHome,
|
|
52
|
+
type ClusterOptions,
|
|
53
|
+
type Format,
|
|
54
|
+
type LevelName,
|
|
55
|
+
type LoggerFace,
|
|
56
|
+
type PayloadStore,
|
|
57
|
+
type RecordStore,
|
|
58
|
+
type RetentionOptions,
|
|
59
|
+
} from '@feasibleone/semantic-log/emitter';
|
|
60
|
+
import type {Level, Logger as PinoLogger} from 'pino';
|
|
61
|
+
import {callRecord, configureCallTrace, type CallTraceConfig} from './callTrace.ts';
|
|
62
|
+
import {toLogCall} from './semanticRecord.ts';
|
|
63
|
+
|
|
64
|
+
export {resolveHome};
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The threshold's type, taken from the contract rather than from pino.
|
|
68
|
+
*
|
|
69
|
+
* `ILog.logger`'s first parameter is pino's level, and the framework's own type
|
|
70
|
+
* file imports that name without re-exporting it — so the parameter is named
|
|
71
|
+
* through the interface that owns it, as `types.ts` itself does elsewhere.
|
|
72
|
+
*/
|
|
73
|
+
type LogLevel = Parameters<ILog['logger']>[0];
|
|
74
|
+
|
|
75
|
+
/** What a suite or realm may configure under `log` when `impl` is `semantic`. */
|
|
76
|
+
export interface SemanticLogConfig {
|
|
77
|
+
/** Threshold. Records below it are not emitted at all. */
|
|
78
|
+
level?: LogLevel;
|
|
79
|
+
/** `human` (one readable line) or `json` (one JSON object per record). */
|
|
80
|
+
format?: Format;
|
|
81
|
+
/** ANSI colour in human format. Opt-in, as the emitter's own option is. */
|
|
82
|
+
color?: boolean;
|
|
83
|
+
/** The service name carried on every record; the framework's own by default. */
|
|
84
|
+
service?: string;
|
|
85
|
+
/**
|
|
86
|
+
* Retain records on disk so a printed reference resolves later. The directory is
|
|
87
|
+
* the framework's log cache, which the pino transport also writes — one store,
|
|
88
|
+
* shared, so a `semantic-log://record/<id>` reference resolves whichever
|
|
89
|
+
* implementation emitted it.
|
|
90
|
+
*/
|
|
91
|
+
cache?: RetentionOptions;
|
|
92
|
+
/**
|
|
93
|
+
* The call channel: the framework's own account of the calls this process
|
|
94
|
+
* makes and answers, and the switch that decides whether it is produced.
|
|
95
|
+
*
|
|
96
|
+
* `enabled` and `off` are the framework's decision (see `callTrace.ts`) —
|
|
97
|
+
* `off` names the entry methods whose flows are too frequent to record. This
|
|
98
|
+
* implementation adds `stdout`: the call records reach the retention store
|
|
99
|
+
* and the cluster service whether or not it is set, and setting it is what
|
|
100
|
+
* puts them on a terminal as well.
|
|
101
|
+
*/
|
|
102
|
+
calls?: CallTraceConfig;
|
|
103
|
+
/**
|
|
104
|
+
* The semantic-log cluster service: where records go to be assembled into
|
|
105
|
+
* templates, flows, diagrams and incidents. `LogBase` owns what each field
|
|
106
|
+
* means — the framework's own concern here is only that a development run may
|
|
107
|
+
* start one and a deployment may point at one.
|
|
108
|
+
*/
|
|
109
|
+
cluster?: ClusterOptions;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* A pino level, as an emitter threshold.
|
|
114
|
+
*
|
|
115
|
+
* pino's vocabulary has one name the emitter does not: `silent`. It means "no
|
|
116
|
+
* records at all", which is a threshold above every level, so it is translated
|
|
117
|
+
* rather than passed through — a component configured silent must not fall back
|
|
118
|
+
* to the root's threshold and start emitting again, which is exactly what
|
|
119
|
+
* happened while `child` ignored this option.
|
|
120
|
+
*/
|
|
121
|
+
function thresholdOf(level: Level | 'silent' | number | undefined): LevelName | number | undefined {
|
|
122
|
+
if (level === undefined) return undefined;
|
|
123
|
+
return level === 'silent' ? Number.POSITIVE_INFINITY : (level as LevelName | number);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export default class SemanticLog extends Internal implements ILog {
|
|
127
|
+
#config: SemanticLogConfig;
|
|
128
|
+
#base: LogBase;
|
|
129
|
+
|
|
130
|
+
public constructor(config: SemanticLogConfig) {
|
|
131
|
+
super();
|
|
132
|
+
const merged = merge<SemanticLogConfig>(
|
|
133
|
+
{level: 'info' as LogLevel, format: 'human' as Format, color: false},
|
|
134
|
+
config,
|
|
135
|
+
) as SemanticLogConfig;
|
|
136
|
+
this.#config = merged;
|
|
137
|
+
this.#base = new LogBase({
|
|
138
|
+
service: merged.service ?? 'blong',
|
|
139
|
+
level: thresholdOf(merged.level),
|
|
140
|
+
format: merged.format,
|
|
141
|
+
color: merged.color,
|
|
142
|
+
cache: merged.cache,
|
|
143
|
+
// Only the printing switch is the base's business; `enabled`/`off` are
|
|
144
|
+
// the gate's, and are read where a flow is minted.
|
|
145
|
+
calls: merged.calls,
|
|
146
|
+
cluster: merged.cluster,
|
|
147
|
+
// Lazy on purpose: this module is on a path the browser bootstrap shares,
|
|
148
|
+
// and the service entry reaches `fastify`. A process with no cluster
|
|
149
|
+
// configured never loads it at all.
|
|
150
|
+
openCluster: async () =>
|
|
151
|
+
(await import('@feasibleone/semantic-log/service')).openCluster,
|
|
152
|
+
// The framework's envelope, on the record and on the call site's own
|
|
153
|
+
// arguments: `{$meta: {mtid, method}}` is what its readers match on.
|
|
154
|
+
envelope: ({leg, phase}) => callRecord(leg, phase),
|
|
155
|
+
translate: args => toLogCall(args),
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** What the emitter retains through, and how a retained record is read back. */
|
|
160
|
+
public get store(): RecordStore & PayloadStore {
|
|
161
|
+
return this.#base.store;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* The address of the cluster service this process started, once it started one.
|
|
166
|
+
*
|
|
167
|
+
* The framework publishes it to the components that read the service — the realm
|
|
168
|
+
* whose pages ask it what it observed. It is the *bound* address rather than a
|
|
169
|
+
* configured one because the port is asked for, not named: two framework
|
|
170
|
+
* processes on one machine are ordinary (a dev server beside a test run, or CI
|
|
171
|
+
* running packages in parallel) and a fixed port leaves the second one with no
|
|
172
|
+
* service at all while its readers keep looking at the first one's.
|
|
173
|
+
*/
|
|
174
|
+
public get clusterUrl(): string | undefined {
|
|
175
|
+
return this.#base.clusterUrl;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Install the call gate, then open the store and the cluster.
|
|
180
|
+
*
|
|
181
|
+
* The gate's configuration goes first: `enabled`/`off` decide whether a flow
|
|
182
|
+
* records, and both are read where a flow is minted, which is before the store
|
|
183
|
+
* can matter. The ambient scope the decision is carried in is semantic-log's
|
|
184
|
+
* own, reached by the vocabulary the server bootstrap attaches, so there is
|
|
185
|
+
* nothing to attach here.
|
|
186
|
+
*/
|
|
187
|
+
public async init(): Promise<void> {
|
|
188
|
+
configureCallTrace(this.#config.calls);
|
|
189
|
+
await this.#base.init();
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Flush and close everything the base opened. */
|
|
193
|
+
public async stop(): Promise<void> {
|
|
194
|
+
await this.#base.stop();
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* A pino-shaped child logger, with pino's `silent` translated first.
|
|
199
|
+
*
|
|
200
|
+
* Everything else about the child — the bindings, the component's name, the
|
|
201
|
+
* face — is the base's.
|
|
202
|
+
*/
|
|
203
|
+
public child<T extends string>(
|
|
204
|
+
...params: Parameters<PinoLogger<never>['child']>
|
|
205
|
+
): PinoLogger<T> {
|
|
206
|
+
const [bindings, options] = params as unknown as [
|
|
207
|
+
Record<string, unknown> | undefined,
|
|
208
|
+
{level?: Level | 'silent' | number} | undefined,
|
|
209
|
+
];
|
|
210
|
+
const level = thresholdOf(options?.level);
|
|
211
|
+
return this.#base.child(
|
|
212
|
+
bindings,
|
|
213
|
+
level === undefined ? undefined : {level},
|
|
214
|
+
) as unknown as PinoLogger<T>;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
public logger(
|
|
218
|
+
level: LogLevel = this.#config.level as LogLevel,
|
|
219
|
+
bindings: object,
|
|
220
|
+
): ReturnType<ILog['logger']> {
|
|
221
|
+
const {name, ...rest} = bindings as {name?: string};
|
|
222
|
+
const face: LoggerFace = this.#base.componentFace(name, thresholdOf(level), rest);
|
|
223
|
+
const result: ILogger = {
|
|
224
|
+
trace: undefined,
|
|
225
|
+
debug: undefined,
|
|
226
|
+
info: undefined,
|
|
227
|
+
warn: undefined,
|
|
228
|
+
error: undefined,
|
|
229
|
+
fatal: undefined,
|
|
230
|
+
};
|
|
231
|
+
// The fall-through is the contract, not an oversight: see the class
|
|
232
|
+
// comment. Each case binds its own level and every one below it.
|
|
233
|
+
switch (level) {
|
|
234
|
+
case 'trace':
|
|
235
|
+
result.trace = face.trace;
|
|
236
|
+
case 'debug':
|
|
237
|
+
result.debug = face.debug;
|
|
238
|
+
case 'info':
|
|
239
|
+
result.info = face.info;
|
|
240
|
+
case 'warn':
|
|
241
|
+
result.warn = face.warn;
|
|
242
|
+
case 'error':
|
|
243
|
+
result.error = face.error;
|
|
244
|
+
case 'fatal':
|
|
245
|
+
result.fatal = face.fatal;
|
|
246
|
+
}
|
|
247
|
+
return {
|
|
248
|
+
...result,
|
|
249
|
+
progress: (label, promise, options) => withProgress(result, label, promise, options),
|
|
250
|
+
calls: face.calls,
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* The framework's own account of the calls made through this log.
|
|
256
|
+
*
|
|
257
|
+
* Present on the log itself so a component that holds the log rather than a
|
|
258
|
+
* child logger (`Gateway`) reaches the same channel as a port that holds
|
|
259
|
+
* only its logger.
|
|
260
|
+
*/
|
|
261
|
+
public get calls(): ICallLog {
|
|
262
|
+
return this.#base.callsChannel(undefined);
|
|
263
|
+
}
|
|
264
|
+
}
|
package/src/Watch.ts
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
type IConfigRuntime,
|
|
11
11
|
type IErrorFactory,
|
|
12
12
|
type ILog,
|
|
13
|
+
type IMeta,
|
|
13
14
|
type IModuleConfig,
|
|
14
15
|
type IObjectSchema,
|
|
15
16
|
type IPlatformApi,
|
|
@@ -21,6 +22,7 @@ import type {Dirent} from 'node:fs';
|
|
|
21
22
|
import merge from 'ut-function.merge';
|
|
22
23
|
|
|
23
24
|
import layerProxy from './layerProxy.ts';
|
|
25
|
+
import {runInFlow, withStep} from './semanticContext.ts';
|
|
24
26
|
|
|
25
27
|
export interface IWatch {
|
|
26
28
|
start: (realm: IRegistry, remote: IRemote, configOverride: object) => Promise<void>;
|
|
@@ -64,6 +66,16 @@ const isTest = (filename: string): boolean => /\.test\.[mc]?[tj]sx?$/i.test(file
|
|
|
64
66
|
|
|
65
67
|
const prefixRE: RegExp = /(?:\d+-)?(.*)/;
|
|
66
68
|
|
|
69
|
+
/**
|
|
70
|
+
* The name a test group is displayed by: `test.meter.flow` reads as `meter flow`.
|
|
71
|
+
*
|
|
72
|
+
* The group's handler may name itself instead (`group("gateway meter flow")`), which
|
|
73
|
+
* is what the handler's own reader sees; this is the name to fall back to.
|
|
74
|
+
*/
|
|
75
|
+
function groupName(method: string): string {
|
|
76
|
+
return method.replace(/^test\./, '').replace(/\./g, ' ');
|
|
77
|
+
}
|
|
78
|
+
|
|
67
79
|
interface IConfig {
|
|
68
80
|
enabled: boolean;
|
|
69
81
|
test: string;
|
|
@@ -731,31 +743,53 @@ export default class Watch extends Internal implements IWatch {
|
|
|
731
743
|
await import('./chain.ts')
|
|
732
744
|
).default(test as ITestContext, this.log);
|
|
733
745
|
|
|
734
|
-
const
|
|
735
|
-
([] as string[]).concat(this.#config.test).map(async method => {
|
|
736
|
-
const result = await remote.remote(method)({}, {});
|
|
737
|
-
if (Array.isArray(result) && !('name' in result)) {
|
|
738
|
-
Object.defineProperty(result, 'name', {
|
|
739
|
-
value: method.replace(/^test\./, '').replace(/\./g, ' '),
|
|
740
|
-
configurable: true,
|
|
741
|
-
});
|
|
742
|
-
}
|
|
743
|
-
return result;
|
|
744
|
-
}),
|
|
745
|
-
);
|
|
746
|
+
const groups = ([] as string[]).concat(this.#config.test);
|
|
746
747
|
let executed = 0;
|
|
747
748
|
await withProgress(
|
|
748
749
|
this.log,
|
|
749
750
|
'run test groups',
|
|
750
|
-
(
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
751
|
+
Promise.all(
|
|
752
|
+
groups.map(async method => {
|
|
753
|
+
// Nothing above a test group minted a flow, so each group is
|
|
754
|
+
// an entry of its own. The groups run in parallel, so one
|
|
755
|
+
// flow for the whole run would interleave unrelated
|
|
756
|
+
// scenarios into a single diagram; a flow per group gives
|
|
757
|
+
// one scenario per flow, which is what there is to draw.
|
|
758
|
+
//
|
|
759
|
+
// The flow wraps the *execution*, not just the dispatch: a
|
|
760
|
+
// group answers with its steps and they run afterwards, so a
|
|
761
|
+
// flow that ended with the dispatch covered nothing but the
|
|
762
|
+
// call that built the scenario - the calls the scenario made
|
|
763
|
+
// were then made in no flow at all, and the observed flow the
|
|
764
|
+
// run was supposed to leave behind never existed.
|
|
765
|
+
const meta: IMeta = {mtid: 'event', method};
|
|
766
|
+
await runInFlow(meta, method, async () => {
|
|
767
|
+
const steps = await remote.remote(method)({}, meta);
|
|
768
|
+
if (Array.isArray(steps) && !('name' in steps)) {
|
|
769
|
+
Object.defineProperty(steps, 'name', {
|
|
770
|
+
value: groupName(method),
|
|
771
|
+
configurable: true,
|
|
772
|
+
});
|
|
773
|
+
}
|
|
774
|
+
// The group's display name is also the flow's *phase*: a
|
|
775
|
+
// sequence that runs a whole scenario reads as one band, and
|
|
776
|
+
// the band is what tells a reader which scenario it is.
|
|
777
|
+
const named =
|
|
778
|
+
typeof steps === 'object' && steps !== null
|
|
779
|
+
? (steps as {name?: unknown})
|
|
780
|
+
: undefined;
|
|
781
|
+
const phase =
|
|
782
|
+
typeof named?.name === 'string'
|
|
783
|
+
? named.name
|
|
784
|
+
: groupName(method);
|
|
785
|
+
await withStep(phase, () =>
|
|
786
|
+
chain(steps as Parameters<typeof chain>[0]),
|
|
787
|
+
);
|
|
788
|
+
});
|
|
789
|
+
executed += 1;
|
|
790
|
+
}),
|
|
791
|
+
),
|
|
792
|
+
{getProgress: () => ({done: executed, total: groups.length})},
|
|
759
793
|
);
|
|
760
794
|
} catch (error) {
|
|
761
795
|
this.log?.error?.(error);
|
|
@@ -771,6 +805,20 @@ export default class Watch extends Internal implements IWatch {
|
|
|
771
805
|
}
|
|
772
806
|
|
|
773
807
|
public async test(framework: unknown): Promise<void> {
|
|
808
|
+
// `test()` settles only when the `start()`-registered listener calls `done`, and
|
|
809
|
+
// that listener is registered only when test groups are declared. Waiting for it
|
|
810
|
+
// unconditionally hangs the whole run with no output at all — a silent wait, which
|
|
811
|
+
// is exactly what a suite that loads a platform without test groups (a browser
|
|
812
|
+
// platform in a tap run, say) used to get. Report it and return: nothing to run is
|
|
813
|
+
// not an error, it just has to be said out loud.
|
|
814
|
+
const groups = ([] as string[]).concat(this.#config.test ?? []);
|
|
815
|
+
if (groups.length === 0) {
|
|
816
|
+
this.log?.info?.(
|
|
817
|
+
{$meta: {mtid: 'event', method: 'watch.test.none'}},
|
|
818
|
+
'no test groups configured on this platform; nothing to run',
|
|
819
|
+
);
|
|
820
|
+
return;
|
|
821
|
+
}
|
|
774
822
|
return new Promise<void>((resolve, reject) => {
|
|
775
823
|
// this.#emit.emit('test', error => (error ? reject(error) : resolve()), framework);
|
|
776
824
|
this.#emit.dispatchEvent(
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The framework's half of call recording: the configuration it falls back to, the
|
|
3
|
+
* envelope a record carries, and the name a capability is asked for by.
|
|
4
|
+
*
|
|
5
|
+
* The switch itself and the phases are semantic-log's (`src/capability.ts`
|
|
6
|
+
* there), and the two are pinned together here rather than left to agree by
|
|
7
|
+
* convention — a capability name that drifted between packages would silence the
|
|
8
|
+
* channel without failing anything.
|
|
9
|
+
*/
|
|
10
|
+
import {CALLS_CAPABILITY as SEMANTIC_CALLS_CAPABILITY} from '@feasibleone/semantic-log/capability';
|
|
11
|
+
import t from 'tap';
|
|
12
|
+
import {
|
|
13
|
+
CALLS_CAPABILITY,
|
|
14
|
+
callRecord,
|
|
15
|
+
callTraceConfig,
|
|
16
|
+
callsPermitted,
|
|
17
|
+
configureCallTrace,
|
|
18
|
+
} from './callTrace.ts';
|
|
19
|
+
|
|
20
|
+
t.teardown(() => configureCallTrace(undefined));
|
|
21
|
+
|
|
22
|
+
t.test('the capability name is the one semantic-log asks for', t => {
|
|
23
|
+
t.equal(CALLS_CAPABILITY, SEMANTIC_CALLS_CAPABILITY);
|
|
24
|
+
t.end();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
t.test('the default is to record', t => {
|
|
28
|
+
configureCallTrace(undefined);
|
|
29
|
+
t.equal(callsPermitted('access.access.find'), true, 'nothing configured records');
|
|
30
|
+
t.same(callTraceConfig(), {}, 'and the configuration in force says so');
|
|
31
|
+
t.end();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
t.test('an off pattern names a flow, and matches a namespace or an exact method', t => {
|
|
35
|
+
configureCallTrace({off: ['payment', 'ledger.entry.post']});
|
|
36
|
+
t.equal(callsPermitted('payment.transfer.prepare'), false, 'a namespace is opted out');
|
|
37
|
+
t.equal(callsPermitted('ledger.entry.post'), false, 'an exact entry is opted out');
|
|
38
|
+
t.equal(callsPermitted('ledger.entry.other'), true, 'a sibling of the exact entry is not');
|
|
39
|
+
t.equal(callsPermitted('access.access.find'), true, 'an unrelated flow is untouched');
|
|
40
|
+
t.end();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
t.test('a glob pattern opts out a whole prefix', t => {
|
|
44
|
+
configureCallTrace({off: ['payment.*']});
|
|
45
|
+
t.equal(callsPermitted('payment.transfer.prepare'), false);
|
|
46
|
+
t.equal(callsPermitted('payment'), false, 'the namespace itself matches the pattern');
|
|
47
|
+
t.equal(callsPermitted('payments.transfer.prepare'), true, 'a longer name is not a match');
|
|
48
|
+
t.end();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
t.test('enabled false is the blunt switch', t => {
|
|
52
|
+
configureCallTrace({enabled: false, off: ['access']});
|
|
53
|
+
t.equal(callsPermitted('access.access.find'), false);
|
|
54
|
+
t.equal(callsPermitted('anything.else'), false, 'and it is not limited to the patterns');
|
|
55
|
+
t.end();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
t.test('a single off flag is read as a list, because the CLI writes one', t => {
|
|
59
|
+
// `--log.calls.off=payment` arrives as a string and the same flag twice as an
|
|
60
|
+
// array; a configuration that only worked when it was repeated would be a trap.
|
|
61
|
+
configureCallTrace({off: 'payment' as unknown as string[]});
|
|
62
|
+
t.equal(callsPermitted('payment.transfer.prepare'), false);
|
|
63
|
+
t.same(callTraceConfig().off, ['payment'], 'and it is normalised to a list');
|
|
64
|
+
t.end();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
t.test('a phase record keeps the shape the ledger reads a leg from', t => {
|
|
68
|
+
t.same(callRecord('gateway.access.access.find', 'start'), {
|
|
69
|
+
$meta: {mtid: 'event', method: 'gateway.access.access.find.start'},
|
|
70
|
+
});
|
|
71
|
+
t.same(callRecord('a.b', 'received'), {$meta: {mtid: 'event', method: 'a.b.received'}});
|
|
72
|
+
t.end();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
t.test('the stdout opt-in is read by the log implementation, not here', t => {
|
|
76
|
+
configureCallTrace({stdout: true});
|
|
77
|
+
t.equal(callTraceConfig().stdout, true, 'the framework passes it through untouched');
|
|
78
|
+
t.equal(callsPermitted('a.b'), true, 'and it says nothing about whether a flow records');
|
|
79
|
+
t.end();
|
|
80
|
+
});
|
package/src/callTrace.ts
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether a flow's calls are recorded, and the record a phase produces.
|
|
3
|
+
*
|
|
4
|
+
* The *decision* is no longer made here. It is a capability — a per-execution
|
|
5
|
+
* switch — and semantic-log owns those (`@feasibleone/semantic-log/capability`):
|
|
6
|
+
* the switch lives in its ambient scope, travels with the identity in the `cap`
|
|
7
|
+
* field of `x-semantic-trace`, and is read back where a record would be produced.
|
|
8
|
+
* What this module keeps is everything that is the *framework's* rather than the
|
|
9
|
+
* emitter's:
|
|
10
|
+
*
|
|
11
|
+
* - the configuration the decision falls back to (`log.calls.*`), and the entry
|
|
12
|
+
* patterns a high-throughput flow is opted out by;
|
|
13
|
+
* - the envelope a call record carries — `$meta` with `mtid: 'event'` and a method
|
|
14
|
+
* naming the leg and the phase, which is what the ledger reads a call out of;
|
|
15
|
+
* - the header a grant token arrives in.
|
|
16
|
+
*
|
|
17
|
+
* The line between the two is worth stating, because it is the line between the
|
|
18
|
+
* packages: semantic-log knows what a call *is* (a leg, its target, its position,
|
|
19
|
+
* its phases), and blong knows how this runtime names things and what its records
|
|
20
|
+
* look like.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import type {CallPhase} from '@feasibleone/blong/types';
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The capability that switches the call channel.
|
|
27
|
+
*
|
|
28
|
+
* The same name semantic-log's `createCallChannel` asks for — it is defined there
|
|
29
|
+
* too, and `callTrace.test.ts` pins the two together, because a value that had to
|
|
30
|
+
* agree across packages by convention is a value that would stop agreeing
|
|
31
|
+
* silently.
|
|
32
|
+
*/
|
|
33
|
+
export const CALLS_CAPABILITY = 'calls';
|
|
34
|
+
|
|
35
|
+
/** The header a short-lived grant token arrives in. Never forwarded. */
|
|
36
|
+
export const GRANT_HEADER = 'x-blong-grant';
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* What the framework may be told about call recording.
|
|
40
|
+
*
|
|
41
|
+
* Declared here rather than in the log implementation because both of them honour
|
|
42
|
+
* it — the decision belongs to the framework, not to whichever logger a process
|
|
43
|
+
* happens to run.
|
|
44
|
+
*/
|
|
45
|
+
export interface CallTraceConfig {
|
|
46
|
+
/** Produce call records at all. On unless this is `false`. */
|
|
47
|
+
enabled?: boolean;
|
|
48
|
+
/**
|
|
49
|
+
* Entry methods whose flows record nothing, matched against the name the flow
|
|
50
|
+
* was minted with (the gateway's method, e.g. `payment.transfer.prepare`).
|
|
51
|
+
*
|
|
52
|
+
* `payment` and `payment.*` both match the whole namespace; an exact name
|
|
53
|
+
* matches only that entry. The point is the high-throughput flows: recording
|
|
54
|
+
* them costs a record per call, and the picture they would draw is the one a
|
|
55
|
+
* reader already has.
|
|
56
|
+
*/
|
|
57
|
+
off?: string[];
|
|
58
|
+
/**
|
|
59
|
+
* Print call records to stdout. Off unless asked for: the records are retained
|
|
60
|
+
* and sent to the cluster either way, and a person watching a terminal is
|
|
61
|
+
* reading the level log, not the call ledger.
|
|
62
|
+
*/
|
|
63
|
+
stdout?: boolean;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
let config: CallTraceConfig = {};
|
|
67
|
+
|
|
68
|
+
/** Install the configuration. Called by whichever log implementation loaded. */
|
|
69
|
+
export function configureCallTrace(next: CallTraceConfig | undefined): void {
|
|
70
|
+
if (next === undefined) {
|
|
71
|
+
config = {};
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
// `off` normalised to a list, because the CLI is one of the two ways this is
|
|
75
|
+
// configured: `--log.calls.off=payment` arrives as a string and
|
|
76
|
+
// `--log.calls.off=payment --log.calls.off=ledger` as an array, and a
|
|
77
|
+
// configuration that only worked when it was repeated twice would be a trap.
|
|
78
|
+
const off = next.off as string | string[] | undefined;
|
|
79
|
+
config = {
|
|
80
|
+
...next,
|
|
81
|
+
...(off === undefined ? {} : {off: Array.isArray(off) ? off : [off]}),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** The configuration in force, for a caller that has to explain it. */
|
|
86
|
+
export function callTraceConfig(): CallTraceConfig {
|
|
87
|
+
return config;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Does `pattern` name this entry method? */
|
|
91
|
+
function matches(pattern: string, entry: string): boolean {
|
|
92
|
+
if (pattern.length === 0) return false;
|
|
93
|
+
if (pattern === entry) return true;
|
|
94
|
+
// `payment` and `payment.*` mean the same thing — the whole namespace — and a
|
|
95
|
+
// bare name is what a configuration usually writes; the glob form exists so a
|
|
96
|
+
// pattern can end in `*` where that reads better.
|
|
97
|
+
const prefix = pattern.endsWith('.*') ? pattern.slice(0, -2) : pattern;
|
|
98
|
+
return entry === prefix || entry.startsWith(`${prefix}.`);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* What the configuration says about an entry, when nothing else has decided.
|
|
103
|
+
*
|
|
104
|
+
* Only the configuration half of the decision: a capability already published for
|
|
105
|
+
* the flow settles the question before this is reached, and `semanticContext.ts`
|
|
106
|
+
* is where the two are put in order.
|
|
107
|
+
*/
|
|
108
|
+
export function callsPermitted(entry: string): boolean {
|
|
109
|
+
if (config.enabled === false) {
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
return !(config.off ?? []).some(pattern => matches(pattern, entry));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* The envelope a call record carries.
|
|
117
|
+
*
|
|
118
|
+
* What it was when these records were written through the level API — `$meta` with
|
|
119
|
+
* `mtid: 'event'` and a method naming the leg and the phase — so nothing downstream
|
|
120
|
+
* of the channel had to learn a new format, and the ledger still reads a call out
|
|
121
|
+
* of the same two fields. The line beside it belongs to semantic-log, which hands
|
|
122
|
+
* it to the writer on the event; nothing here names a phase in prose.
|
|
123
|
+
*/
|
|
124
|
+
export function callRecord(leg: string, phase: CallPhase): Record<string, unknown> {
|
|
125
|
+
return {$meta: {mtid: 'event', method: `${leg}.${phase}`}};
|
|
126
|
+
}
|
package/src/cliHelp.ts
CHANGED
|
@@ -17,6 +17,8 @@ Usage:
|
|
|
17
17
|
blong <intent>... Run with only the given intents (e.g. integration)
|
|
18
18
|
blong realm <name> [--object] Scaffold a new realm
|
|
19
19
|
blong create realm <name> Scaffold a new realm
|
|
20
|
+
blong grant <capability> Mint a short-lived capability token
|
|
21
|
+
(e.g. blong grant calls --ttl=15m)
|
|
20
22
|
|
|
21
23
|
Well-known intents:
|
|
22
24
|
dev Development — verbose logs, hot reload
|
package/src/devKeys.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The framework's development-time gateway keys.
|
|
3
|
+
*
|
|
4
|
+
* Static, committed, and deliberately not secret: their job is that two
|
|
5
|
+
* processes started from the same checkout agree on the same key material, so a
|
|
6
|
+
* session survives a hot reload and a token minted by one command is the token a
|
|
7
|
+
* running server accepts. A deployment supplies its own through
|
|
8
|
+
* `GATEWAY_SIGN_KEY` / `GATEWAY_ENCRYPT_KEY` (see `Gateway.ts`), and this module
|
|
9
|
+
* is never consulted when they are set.
|
|
10
|
+
*
|
|
11
|
+
* They live here rather than inline in the `dev` intent because more than the
|
|
12
|
+
* loader needs them: `blong grant` mints a short-lived capability token with the
|
|
13
|
+
* same sign key the gateway verifies with, and a token minted with a key nobody
|
|
14
|
+
* else holds is a token that verifies nowhere.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/* cSpell:disable */
|
|
18
|
+
/** The `dev` intent's sign key (`ES384`, private: minting happens here too). */
|
|
19
|
+
export const devSignKey = {
|
|
20
|
+
kty: 'EC',
|
|
21
|
+
crv: 'P-384',
|
|
22
|
+
alg: 'ES384',
|
|
23
|
+
use: 'sig',
|
|
24
|
+
x: 'VlRkjgqRHJSk9WN8CaAqHn34BUMy9pgKQUAAW9MrOqh0yvCmJW7JTr6LUCbm9zfW',
|
|
25
|
+
y: '8eYxbAZrv-HZEc4LSgdEHeSp21zO3D8KrynMcVcNAmZKTf3RMkbkh1B26lePHQNz',
|
|
26
|
+
d: 'aj6BkYmpwkKRbmcO1LO6d__HX5bvkqcRjqadlX7plXlGfj1d42XiSUWa4c9xrxwt',
|
|
27
|
+
};
|
|
28
|
+
/** The `dev` intent's encrypt key (`ECDH-ES+A256KW`). */
|
|
29
|
+
export const devEncryptKey = {
|
|
30
|
+
kty: 'EC',
|
|
31
|
+
crv: 'P-384',
|
|
32
|
+
alg: 'ECDH-ES+A256KW',
|
|
33
|
+
use: 'enc',
|
|
34
|
+
x: '86IBoWsatO3Vky9CRMxmuYcfYoTY1Yr0D1sJGDgLlREMjbL9cIOHcBQnEaW52QJV',
|
|
35
|
+
y: 'fsKOmTuXaIRFXXteh7uU0Z8mncX4VsPhqaz9pMKMm8EktQlF7HBS_fYFdkLwqMMN',
|
|
36
|
+
d: 'rBY50TZzjONw_oYzWPqaR3DdoFwO-F9sWcmkOltrJHYnfbnTojNImX2xN1DhhC5-',
|
|
37
|
+
};
|
|
38
|
+
/* cSpell:enable */
|