@toa.io/extensions.introspection 1.0.0-alpha.262
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 +61 -0
- package/components/introspection.edges/manifest.toa.yaml +48 -0
- package/components/introspection.edges/operations/lib/types.d.ts +28 -0
- package/components/introspection.edges/operations/lib/types.js +3 -0
- package/components/introspection.edges/operations/lib/types.js.map +1 -0
- package/components/introspection.edges/operations/merge.d.ts +9 -0
- package/components/introspection.edges/operations/merge.js +25 -0
- package/components/introspection.edges/operations/merge.js.map +1 -0
- package/components/introspection.edges/source/lib/types.ts +33 -0
- package/components/introspection.edges/source/merge.ts +26 -0
- package/components/introspection.edges/tsconfig.json +9 -0
- package/components/introspection.nodes/manifest.toa.yaml +49 -0
- package/components/introspection.nodes/operations/lib/types.d.ts +16 -0
- package/components/introspection.nodes/operations/lib/types.js +3 -0
- package/components/introspection.nodes/operations/lib/types.js.map +1 -0
- package/components/introspection.nodes/operations/merge.d.ts +9 -0
- package/components/introspection.nodes/operations/merge.js +21 -0
- package/components/introspection.nodes/operations/merge.js.map +1 -0
- package/components/introspection.nodes/source/lib/types.ts +18 -0
- package/components/introspection.nodes/source/merge.ts +21 -0
- package/components/introspection.nodes/tsconfig.json +9 -0
- package/cucumber.js +8 -0
- package/features/calls.feature +123 -0
- package/features/nodes.feature +70 -0
- package/features/samples.feature +75 -0
- package/features/shutdown.feature +37 -0
- package/features/site/_app/immutable/asset.js +1 -0
- package/features/site/index.html +6 -0
- package/features/steps/Map.ts +151 -0
- package/features/steps/UI.ts +88 -0
- package/features/steps/components/probe.discreet/manifest.toa.yaml +12 -0
- package/features/steps/components/probe.discreet/operations/ping.js +7 -0
- package/features/steps/components/probe.quiet/manifest.toa.yaml +11 -0
- package/features/steps/components/probe.quiet/operations/ping.js +7 -0
- package/features/steps/components/probe.source/manifest.toa.yaml +27 -0
- package/features/steps/components/probe.source/operations/crash.js +7 -0
- package/features/steps/components/probe.source/operations/fail.js +12 -0
- package/features/steps/components/probe.source/operations/relay.js +7 -0
- package/features/steps/components/probe.target/manifest.toa.yaml +24 -0
- package/features/steps/components/probe.target/operations/compute.js +7 -0
- package/features/steps/components/probe.target/operations/count.js +9 -0
- package/features/steps/config.ts +20 -0
- package/features/steps/tsconfig.json +9 -0
- package/features/ui.feature +85 -0
- package/package.json +31 -0
- package/readme.md +141 -0
- package/schemas/annotation.cos.yaml +16 -0
- package/schemas/declaration.cos.yaml +2 -0
- package/source/Composition.ts +61 -0
- package/source/Explorer.ts +16 -0
- package/source/Factory.ts +119 -0
- package/source/Reporter.ts +210 -0
- package/source/Tenant.ts +47 -0
- package/source/UI.ts +197 -0
- package/source/annotation.ts +83 -0
- package/source/const.ts +32 -0
- package/source/describe.ts +68 -0
- package/source/extension.ts +45 -0
- package/source/index.ts +7 -0
- package/source/keys.ts +0 -0
- package/source/manifest.ts +19 -0
- package/source/model.ts +82 -0
- package/source/sample.ts +49 -0
- package/source/schemas.ts +10 -0
- package/transpiled/Composition.d.ts +21 -0
- package/transpiled/Composition.js +48 -0
- package/transpiled/Composition.js.map +1 -0
- package/transpiled/Explorer.d.ts +9 -0
- package/transpiled/Explorer.js +19 -0
- package/transpiled/Explorer.js.map +1 -0
- package/transpiled/Factory.d.ts +22 -0
- package/transpiled/Factory.js +93 -0
- package/transpiled/Factory.js.map +1 -0
- package/transpiled/Reporter.d.ts +44 -0
- package/transpiled/Reporter.js +187 -0
- package/transpiled/Reporter.js.map +1 -0
- package/transpiled/Tenant.d.ts +18 -0
- package/transpiled/Tenant.js +40 -0
- package/transpiled/Tenant.js.map +1 -0
- package/transpiled/UI.d.ts +25 -0
- package/transpiled/UI.js +186 -0
- package/transpiled/UI.js.map +1 -0
- package/transpiled/annotation.d.ts +40 -0
- package/transpiled/annotation.js +46 -0
- package/transpiled/annotation.js.map +1 -0
- package/transpiled/const.d.ts +23 -0
- package/transpiled/const.js +27 -0
- package/transpiled/const.js.map +1 -0
- package/transpiled/contracts.d.ts +51 -0
- package/transpiled/contracts.js +53 -0
- package/transpiled/contracts.js.map +1 -0
- package/transpiled/describe.d.ts +9 -0
- package/transpiled/describe.js +64 -0
- package/transpiled/describe.js.map +1 -0
- package/transpiled/extension.d.ts +11 -0
- package/transpiled/extension.js +64 -0
- package/transpiled/extension.js.map +1 -0
- package/transpiled/index.d.ts +6 -0
- package/transpiled/index.js +25 -0
- package/transpiled/index.js.map +1 -0
- package/transpiled/keys.d.ts +9 -0
- package/transpiled/keys.js +0 -0
- package/transpiled/keys.js.map +1 -0
- package/transpiled/manifest.d.ts +9 -0
- package/transpiled/manifest.js +43 -0
- package/transpiled/manifest.js.map +1 -0
- package/transpiled/model.d.ts +71 -0
- package/transpiled/model.js +3 -0
- package/transpiled/model.js.map +1 -0
- package/transpiled/sample.d.ts +7 -0
- package/transpiled/sample.js +44 -0
- package/transpiled/sample.js.map +1 -0
- package/transpiled/schemas.d.ts +4 -0
- package/transpiled/schemas.js +13 -0
- package/transpiled/schemas.js.map +1 -0
- package/tsconfig.json +12 -0
- package/ui/dist/_app/env.js +1 -0
- package/ui/dist/_app/immutable/assets/0.fg2cP6bO.css +2 -0
- package/ui/dist/_app/immutable/assets/2.BghF56l_.css +1 -0
- package/ui/dist/_app/immutable/assets/inter-cyrillic-ext-wght-normal.BOeWTOD4.woff2 +0 -0
- package/ui/dist/_app/immutable/assets/inter-cyrillic-wght-normal.DqGufNeO.woff2 +0 -0
- package/ui/dist/_app/immutable/assets/inter-greek-ext-wght-normal.DlzME5K_.woff2 +0 -0
- package/ui/dist/_app/immutable/assets/inter-greek-wght-normal.CkhJZR-_.woff2 +0 -0
- package/ui/dist/_app/immutable/assets/inter-latin-ext-wght-normal.DO1Apj_S.woff2 +0 -0
- package/ui/dist/_app/immutable/assets/inter-latin-wght-normal.Dx4kXJAl.woff2 +0 -0
- package/ui/dist/_app/immutable/assets/inter-vietnamese-wght-normal.CBcvBZtf.woff2 +0 -0
- package/ui/dist/_app/immutable/chunks/Bjy-W4x2.js +81 -0
- package/ui/dist/_app/immutable/chunks/CIvRtvVA.js +1 -0
- package/ui/dist/_app/immutable/chunks/D1jNriZ8.js +1 -0
- package/ui/dist/_app/immutable/chunks/DINmginf.js +1 -0
- package/ui/dist/_app/immutable/chunks/DKZCAkrF.js +1 -0
- package/ui/dist/_app/immutable/chunks/DLG2JvyF.js +6 -0
- package/ui/dist/_app/immutable/chunks/DSi-FjwI.js +2 -0
- package/ui/dist/_app/immutable/chunks/DdoZuyU1.js +1 -0
- package/ui/dist/_app/immutable/chunks/O9IjE_TH.js +1 -0
- package/ui/dist/_app/immutable/chunks/PUjOYRcs.js +3 -0
- package/ui/dist/_app/immutable/chunks/QKD4Mgck.js +5 -0
- package/ui/dist/_app/immutable/chunks/Ux-r8C9U.js +1 -0
- package/ui/dist/_app/immutable/chunks/xihTtKlq.js +1 -0
- package/ui/dist/_app/immutable/entry/app.CTY34L35.js +2 -0
- package/ui/dist/_app/immutable/entry/start.Bq2Gn1Ax.js +1 -0
- package/ui/dist/_app/immutable/nodes/0.lOXtBRjQ.js +1 -0
- package/ui/dist/_app/immutable/nodes/1.r8ESZdgM.js +1 -0
- package/ui/dist/_app/immutable/nodes/2.DuVj0loz.js +8 -0
- package/ui/dist/_app/immutable/nodes/3.DhdkQ2Q1.js +1 -0
- package/ui/dist/_app/immutable/nodes/4.DUAEZdRr.js +1 -0
- package/ui/dist/_app/immutable/nodes/5.CIVn_xgq.js +1 -0
- package/ui/dist/_app/version.json +1 -0
- package/ui/dist/index.html +48 -0
- package/ui/dist/robots.txt +3 -0
package/readme.md
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# Toa Introspection
|
|
2
|
+
|
|
3
|
+
Collects and shows how the parts of an application talk to each other.
|
|
4
|
+
|
|
5
|
+
Two things make that picture. Every component describes itself — what it can be asked to do, what it
|
|
6
|
+
announces, what it reacts to — and that much is true before a single request is served. Every call
|
|
7
|
+
between components is then recorded as it happens, along with what set it off. One says how the
|
|
8
|
+
application is put together, the other what it actually does.
|
|
9
|
+
|
|
10
|
+
It comes out as a page: a map of the components and the calls between them, and a list of what each
|
|
11
|
+
one declares. Nothing has to be declared to get it — the extension takes part in every composition.
|
|
12
|
+
|
|
13
|
+
## Configuration
|
|
14
|
+
|
|
15
|
+
Both levels of the annotation are optional. Everything below shows the defaults.
|
|
16
|
+
|
|
17
|
+
### Context
|
|
18
|
+
|
|
19
|
+
```yaml
|
|
20
|
+
# context.toa.yaml
|
|
21
|
+
|
|
22
|
+
introspection:
|
|
23
|
+
samples: false # capture the actual payloads of calls
|
|
24
|
+
interval: 15 # how often a component reports, seconds
|
|
25
|
+
threshold: 256 # report earlier once this many edges are pending
|
|
26
|
+
ui: true # publish the UI
|
|
27
|
+
resources: # explorer pod limits
|
|
28
|
+
cpu: [100m, 500m]
|
|
29
|
+
memory: [128Mi, 256Mi]
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
To turn introspection off completely — no explorer, no collection:
|
|
33
|
+
|
|
34
|
+
```yaml
|
|
35
|
+
introspection: false
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Introspection exposes the map over HTTP and therefore requires the
|
|
39
|
+
[exposition](../exposition) extension. An application that does not use exposition has to turn
|
|
40
|
+
introspection off.
|
|
41
|
+
|
|
42
|
+
### Component
|
|
43
|
+
|
|
44
|
+
```yaml
|
|
45
|
+
# manifest.toa.yaml
|
|
46
|
+
|
|
47
|
+
introspection:
|
|
48
|
+
samples: false # never capture this component's payloads
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
To keep a component off the map entirely:
|
|
52
|
+
|
|
53
|
+
```yaml
|
|
54
|
+
introspection: false
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
### Samples
|
|
58
|
+
|
|
59
|
+
A sample is the actual input of a call, kept on the edge. It is production data, so it is off by
|
|
60
|
+
default, and both the context and the component have to allow it — either can veto. A component that
|
|
61
|
+
handles personal data should opt out permanently in its own manifest.
|
|
62
|
+
|
|
63
|
+
Payloads of the `identity` namespace are never captured, keys that look like secrets are masked, and
|
|
64
|
+
oversized payloads are dropped.
|
|
65
|
+
|
|
66
|
+
## The UI
|
|
67
|
+
|
|
68
|
+
The UI is published at `/.introspection`, on the hosts the context declares:
|
|
69
|
+
|
|
70
|
+
```yaml
|
|
71
|
+
# context.toa.yaml
|
|
72
|
+
|
|
73
|
+
ingress:
|
|
74
|
+
hosts:
|
|
75
|
+
- api.example.com
|
|
76
|
+
class: alb
|
|
77
|
+
annotations:
|
|
78
|
+
alb.ingress.kubernetes.io/group.name: example
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
This section is what every service uses to reach the outside; without it the UI has nowhere to land
|
|
82
|
+
and `toa export` says so. To collect the map without publishing anything, set `ui: false`.
|
|
83
|
+
|
|
84
|
+
List the same hostnames Exposition serves — the page is static and reads the map from the API on its
|
|
85
|
+
own origin, so a host Exposition does not serve gives a page that cannot load anything.
|
|
86
|
+
|
|
87
|
+
Two services now share one host, which is a question for the ingress controller rather than for
|
|
88
|
+
Toa: ingress-nginx merges them, while AWS ALB needs `alb.ingress.kubernetes.io/group.name` on both.
|
|
89
|
+
The `annotations` above are applied to every service, which is the place to put it.
|
|
90
|
+
|
|
91
|
+
Reading the map still needs the `system:introspection` role. The page itself is served without
|
|
92
|
+
authentication — it is a page, and it displays nothing the API has not already granted.
|
|
93
|
+
|
|
94
|
+
### Developing the UI
|
|
95
|
+
|
|
96
|
+
The page is a Svelte application under `ui`, built into `ui/dist` — a directory of files the
|
|
97
|
+
explorer serves, with no server rendering and nothing to run beside it. The mount path is baked
|
|
98
|
+
into the bundle, because the ingress forwards it rather than rewriting it, and the client router
|
|
99
|
+
matches against it.
|
|
100
|
+
|
|
101
|
+
The build is git-ignored and produced on `npm publish`, so from a checkout it is a one-time step,
|
|
102
|
+
like `npm run transpile`:
|
|
103
|
+
|
|
104
|
+
```shell
|
|
105
|
+
$ npm run build:ui # in this package
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
The explorer then answers on port `8002`, and `/` redirects to the mount path, so a local run
|
|
109
|
+
opens at <http://localhost:8002>. To work on the page itself:
|
|
110
|
+
|
|
111
|
+
```shell
|
|
112
|
+
$ npm --prefix ui run dev # http://localhost:5173/.introspection
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Either way the API is the page's own origin — except on a local host, where the explorer and the
|
|
116
|
+
gateway are separate ports and the page asks `:8000` instead. Nothing configures this; see
|
|
117
|
+
`ui/src/config/index.ts`.
|
|
118
|
+
|
|
119
|
+
## Reading the map
|
|
120
|
+
|
|
121
|
+
The map lives in two ordinary components, `introspection.nodes` and `introspection.edges`. Both
|
|
122
|
+
answer `enumerate` and `observe`, and both are exposed under the `system:introspection` role:
|
|
123
|
+
|
|
124
|
+
```
|
|
125
|
+
GET /introspection/nodes/
|
|
126
|
+
GET /introspection/nodes/:id
|
|
127
|
+
GET /introspection/edges/
|
|
128
|
+
GET /introspection/edges/:id
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
Records carry `_updated`, which is when the node or edge was last observed — that is how a component
|
|
132
|
+
that no longer exists is recognized.
|
|
133
|
+
|
|
134
|
+
## Resources
|
|
135
|
+
|
|
136
|
+
The explorer runs as the `introspection-explorer` service on port `8002`. It needs a database: the
|
|
137
|
+
components use the context's `mongodb` annotation like any other.
|
|
138
|
+
|
|
139
|
+
A port belongs to one service only — `toa export` refuses two claims on the same one, because
|
|
140
|
+
`toa mono` and a local run put every service in one process. Taken so far: `8000` by the exposition
|
|
141
|
+
gateway, `8001` by the telemetry readiness probe, `8002` here.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# context.toa.yaml introspection annotation
|
|
2
|
+
samples: boolean
|
|
3
|
+
interval:
|
|
4
|
+
type: integer
|
|
5
|
+
minimum: 1
|
|
6
|
+
threshold:
|
|
7
|
+
type: integer
|
|
8
|
+
minimum: 1
|
|
9
|
+
ui: boolean
|
|
10
|
+
resources:
|
|
11
|
+
cpu: &constraint
|
|
12
|
+
type: array
|
|
13
|
+
items: { type: string }
|
|
14
|
+
minItems: 2
|
|
15
|
+
maxItems: 2
|
|
16
|
+
memory: *constraint
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { readdirSync, type Dirent } from 'node:fs'
|
|
2
|
+
import { resolve } from 'node:path'
|
|
3
|
+
import { Connector } from '@toa.io/core'
|
|
4
|
+
import { type Bootloader } from './Factory'
|
|
5
|
+
import type { Annotation } from './annotation'
|
|
6
|
+
|
|
7
|
+
/** Hosts the introspection components in the explorer process. */
|
|
8
|
+
export class Composition extends Connector {
|
|
9
|
+
private readonly boot: Bootloader
|
|
10
|
+
|
|
11
|
+
public constructor (boot: Bootloader) {
|
|
12
|
+
super()
|
|
13
|
+
this.boot = boot
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
protected override async open (): Promise<void> {
|
|
17
|
+
const paths = find()
|
|
18
|
+
const composition = await this.boot.composition(paths)
|
|
19
|
+
|
|
20
|
+
await composition.connect()
|
|
21
|
+
|
|
22
|
+
this.depends(composition)
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function find (): string[] {
|
|
27
|
+
return entries().map((entry) => resolve(ROOT, entry.name))
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function entries (): Dirent[] {
|
|
31
|
+
const entries = readdirSync(ROOT, { withFileTypes: true })
|
|
32
|
+
|
|
33
|
+
return entries.filter((entry) => entry.isDirectory())
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The extension is predefined, so an application that turns introspection off
|
|
38
|
+
* must not end up with the explorer components — nor with the exposition
|
|
39
|
+
* dependency they bring in.
|
|
40
|
+
*/
|
|
41
|
+
export function components (annotation?: Annotation): Components {
|
|
42
|
+
if (annotation === false)
|
|
43
|
+
return { labels: [], paths: [] }
|
|
44
|
+
|
|
45
|
+
const labels: string[] = []
|
|
46
|
+
const paths: string[] = []
|
|
47
|
+
|
|
48
|
+
for (const entry of entries()) {
|
|
49
|
+
labels.push(entry.name.replace('.', '-'))
|
|
50
|
+
paths.push(resolve(ROOT, entry.name))
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return { labels, paths }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
interface Components {
|
|
57
|
+
labels: string[]
|
|
58
|
+
paths: string[]
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const ROOT = resolve(__dirname, '../components/')
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { Connector } from '@toa.io/core'
|
|
2
|
+
import { console } from 'openspan'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The explorer process. It hosts the introspection components — the map is read
|
|
6
|
+
* through their own operations — and serves the UI.
|
|
7
|
+
*/
|
|
8
|
+
export class Explorer extends Connector {
|
|
9
|
+
protected override async open (): Promise<void> {
|
|
10
|
+
console.info('Introspection explorer started')
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
protected override dispose (): void {
|
|
14
|
+
console.info('Introspection explorer is closed')
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { Connector } from '@toa.io/core'
|
|
2
|
+
import { DISABLED, environment, component as declaration, settings } from './annotation'
|
|
3
|
+
import { NAMESPACE, UI_PORT } from './const'
|
|
4
|
+
import { describe } from './describe'
|
|
5
|
+
import { Reporter } from './Reporter'
|
|
6
|
+
import { Tenant } from './Tenant'
|
|
7
|
+
import { Composition } from './Composition'
|
|
8
|
+
import { Explorer } from './Explorer'
|
|
9
|
+
import { UI } from './UI'
|
|
10
|
+
import { capture, samplable } from './sample'
|
|
11
|
+
import type { Declaration, Options, Settings } from './annotation'
|
|
12
|
+
import type { Origin, Outcome, Target } from './model'
|
|
13
|
+
import type { Manifest } from '@toa.io/norm'
|
|
14
|
+
import type { Component, Locator, Reply, Request, extensions } from '@toa.io/core'
|
|
15
|
+
|
|
16
|
+
export class Factory implements extensions.Factory {
|
|
17
|
+
private readonly boot: Bootloader
|
|
18
|
+
private readonly options: Options | null
|
|
19
|
+
private readonly settings: Record<string, Settings> = {}
|
|
20
|
+
private reporter: Reporter | null = null
|
|
21
|
+
|
|
22
|
+
public constructor (boot: Bootloader) {
|
|
23
|
+
this.boot = boot
|
|
24
|
+
this.options = environment()
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
public tenant (locator: Locator, decl: Declaration | null, manifest: Manifest): Connector {
|
|
28
|
+
const resolved = settings(locator.namespace, declaration(decl), this.options)
|
|
29
|
+
|
|
30
|
+
this.settings[locator.id] = resolved
|
|
31
|
+
|
|
32
|
+
if (!resolved.enabled || locator.namespace === NAMESPACE)
|
|
33
|
+
return new Connector()
|
|
34
|
+
|
|
35
|
+
return new Tenant(this.collector(), describe(manifest))
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
public component (component: Component): Component {
|
|
39
|
+
const locator = component.locator
|
|
40
|
+
const resolved = this.resolve(locator)
|
|
41
|
+
|
|
42
|
+
if (!resolved.enabled)
|
|
43
|
+
return component
|
|
44
|
+
|
|
45
|
+
const reporter = this.collector()
|
|
46
|
+
const invoke = component.invoke.bind(component)
|
|
47
|
+
|
|
48
|
+
component.invoke = async (endpoint: string, request: Request): Promise<any> => {
|
|
49
|
+
let outcome: Outcome = 'ok'
|
|
50
|
+
let reply: Reply | undefined
|
|
51
|
+
|
|
52
|
+
try {
|
|
53
|
+
reply = await invoke(endpoint, request)
|
|
54
|
+
|
|
55
|
+
if (reply?.exception !== undefined) outcome = 'exception'
|
|
56
|
+
else if (reply?.error !== undefined) outcome = 'error'
|
|
57
|
+
|
|
58
|
+
return reply
|
|
59
|
+
} catch (error) {
|
|
60
|
+
outcome = 'exception'
|
|
61
|
+
|
|
62
|
+
throw error
|
|
63
|
+
} finally {
|
|
64
|
+
// a call that failed is still a connection between two components
|
|
65
|
+
const src: Origin = request?.source ?? UNKNOWN
|
|
66
|
+
const dst: Target = { namespace: locator.namespace, component: locator.name, operation: endpoint }
|
|
67
|
+
|
|
68
|
+
const sample = resolved.samples && samplable(request?.input)
|
|
69
|
+
? capture(request?.input, outcome)
|
|
70
|
+
: undefined
|
|
71
|
+
|
|
72
|
+
reporter.observe({ src, dst, sample })
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
component.depends(reporter)
|
|
77
|
+
|
|
78
|
+
return component
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
public service (): Connector | null {
|
|
82
|
+
if (this.options === null)
|
|
83
|
+
return null
|
|
84
|
+
|
|
85
|
+
const composition = new Composition(this.boot)
|
|
86
|
+
const explorer = new Explorer()
|
|
87
|
+
|
|
88
|
+
explorer.depends(composition)
|
|
89
|
+
|
|
90
|
+
if (this.options.ui)
|
|
91
|
+
explorer.depends(new UI(UI_PORT))
|
|
92
|
+
|
|
93
|
+
return explorer
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* `tenant()` runs before any component is created, so settings are warm.
|
|
98
|
+
* A component booted on its own (without a composition) falls back to
|
|
99
|
+
* the environment, with sampling off.
|
|
100
|
+
*/
|
|
101
|
+
private resolve (locator: Locator): Settings {
|
|
102
|
+
if (locator.namespace === NAMESPACE)
|
|
103
|
+
return DISABLED
|
|
104
|
+
|
|
105
|
+
return this.settings[locator.id] ??
|
|
106
|
+
settings(locator.namespace, {}, this.options === null ? null : { ...this.options, samples: false })
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
private collector (): Reporter {
|
|
110
|
+
this.reporter ??= new Reporter(this.boot, this.options!)
|
|
111
|
+
|
|
112
|
+
return this.reporter
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const UNKNOWN = { service: 'unknown' } as const
|
|
117
|
+
|
|
118
|
+
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
|
|
119
|
+
export type Bootloader = typeof import('@toa.io/boot')
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import { Connector, Locator } from '@toa.io/core'
|
|
2
|
+
import { console } from 'openspan'
|
|
3
|
+
import { EDGES, MAX_EDGES, NAMESPACE, NODES } from './const'
|
|
4
|
+
import * as keys from './keys'
|
|
5
|
+
import type { Bootloader } from './Factory'
|
|
6
|
+
import type { Options } from './annotation'
|
|
7
|
+
import type { Edge, Node } from './model'
|
|
8
|
+
import type { Remote } from '@toa.io/core'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Buffers what a process observes and flushes it into the introspection
|
|
12
|
+
* components.
|
|
13
|
+
*
|
|
14
|
+
* Nothing here is on the critical path. Reaching the explorer is a discovery,
|
|
15
|
+
* which waits as long as it takes, so the collector never holds up a component
|
|
16
|
+
* starting, running or stopping: it buffers until the connection is there, and
|
|
17
|
+
* when it has to choose it gives up on the data rather than on the application.
|
|
18
|
+
*/
|
|
19
|
+
export class Reporter extends Connector {
|
|
20
|
+
private readonly boot: Bootloader
|
|
21
|
+
private readonly options: Options
|
|
22
|
+
private readonly nodes = new Map<string, Node>()
|
|
23
|
+
private readonly edges = new Map<string, Edge>()
|
|
24
|
+
|
|
25
|
+
/** Holds a remote only once it is connected and usable. */
|
|
26
|
+
private readonly remotes: Record<string, Remote> = {}
|
|
27
|
+
|
|
28
|
+
private timer: NodeJS.Timeout | null = null
|
|
29
|
+
private flushing: Promise<void> | null = null
|
|
30
|
+
private acquiring = false
|
|
31
|
+
private dropped = 0
|
|
32
|
+
|
|
33
|
+
public constructor (boot: Bootloader, options: Options) {
|
|
34
|
+
super()
|
|
35
|
+
|
|
36
|
+
this.boot = boot
|
|
37
|
+
this.options = options
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The static description of a component. */
|
|
41
|
+
public expose (node: Node): void {
|
|
42
|
+
this.nodes.set(keys.node(node.namespace, node.component), node)
|
|
43
|
+
|
|
44
|
+
void this.flush()
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** A call between two components. */
|
|
48
|
+
public observe (observed: Edge): void {
|
|
49
|
+
const id = keys.edge(observed.src, observed.dst)
|
|
50
|
+
const edge = this.edges.get(id)
|
|
51
|
+
|
|
52
|
+
if (edge === undefined) {
|
|
53
|
+
/*
|
|
54
|
+
* `source` arrives over the wire, so the number of distinct edges a process
|
|
55
|
+
* can hold has to be bounded regardless of what peers send — and of whether
|
|
56
|
+
* anyone is there to take them.
|
|
57
|
+
*/
|
|
58
|
+
if (this.edges.size >= MAX_EDGES) {
|
|
59
|
+
this.dropped++
|
|
60
|
+
|
|
61
|
+
return
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
this.edges.set(id, observed)
|
|
65
|
+
} else if (observed.sample !== undefined)
|
|
66
|
+
edge.sample = observed.sample
|
|
67
|
+
|
|
68
|
+
if (this.edges.size >= this.options.threshold)
|
|
69
|
+
void this.flush()
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
protected override async open (): Promise<void> {
|
|
73
|
+
// deliberately not awaited: the explorer may not be there yet, or at all
|
|
74
|
+
this.acquire()
|
|
75
|
+
|
|
76
|
+
this.timer = setInterval(() => void this.flush(), this.options.interval * 1000)
|
|
77
|
+
this.timer.unref()
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
protected override async close (): Promise<void> {
|
|
81
|
+
if (this.timer !== null) {
|
|
82
|
+
clearInterval(this.timer)
|
|
83
|
+
this.timer = null
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
await this.flushing
|
|
87
|
+
|
|
88
|
+
if (!this.ready()) {
|
|
89
|
+
this.discard('the explorer was never reached')
|
|
90
|
+
|
|
91
|
+
return
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// the remotes are still up: dependencies are disconnected after this returns
|
|
95
|
+
await this.dispatch().catch((error: Error) => {
|
|
96
|
+
console.debug('Introspection final flush failed', { message: error.message })
|
|
97
|
+
})
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
private ready (): boolean {
|
|
101
|
+
return NODES in this.remotes && EDGES in this.remotes
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
private async flush (): Promise<void> {
|
|
105
|
+
// a dispatch in flight keeps observations buffered, they join the next batch
|
|
106
|
+
if (this.flushing !== null)
|
|
107
|
+
return
|
|
108
|
+
|
|
109
|
+
if (this.nodes.size === 0 && this.edges.size === 0)
|
|
110
|
+
return
|
|
111
|
+
|
|
112
|
+
if (!this.ready()) {
|
|
113
|
+
this.acquire()
|
|
114
|
+
|
|
115
|
+
if (this.edges.size >= MAX_EDGES)
|
|
116
|
+
this.discard('the explorer is not reachable')
|
|
117
|
+
|
|
118
|
+
return
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
this.flushing = this.dispatch()
|
|
122
|
+
.catch((error: Error) => {
|
|
123
|
+
console.debug('Introspection flush failed', { message: error.message })
|
|
124
|
+
})
|
|
125
|
+
.finally(() => {
|
|
126
|
+
this.flushing = null
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
await this.flushing
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
private discard (reason: string): void {
|
|
133
|
+
const nodes = this.nodes.size
|
|
134
|
+
const edges = this.edges.size + this.dropped
|
|
135
|
+
|
|
136
|
+
this.nodes.clear()
|
|
137
|
+
this.edges.clear()
|
|
138
|
+
this.dropped = 0
|
|
139
|
+
|
|
140
|
+
if (nodes === 0 && edges === 0)
|
|
141
|
+
return
|
|
142
|
+
|
|
143
|
+
console.warn(`Introspection data discarded, ${reason}`, { nodes, edges })
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
private async dispatch (): Promise<void> {
|
|
147
|
+
const nodes = [...this.nodes.entries()]
|
|
148
|
+
const edges = [...this.edges.entries()]
|
|
149
|
+
|
|
150
|
+
this.nodes.clear()
|
|
151
|
+
this.edges.clear()
|
|
152
|
+
|
|
153
|
+
if (this.dropped > 0) {
|
|
154
|
+
console.warn('Introspection edges dropped', { dropped: this.dropped, limit: MAX_EDGES })
|
|
155
|
+
this.dropped = 0
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
await Promise.all([
|
|
159
|
+
this.merge(NODES, 'nodes', nodes),
|
|
160
|
+
this.merge(EDGES, 'edges', edges)
|
|
161
|
+
])
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* A mass transition: every affected object is acquired and committed at once,
|
|
166
|
+
* so a flush is one call per component whatever it carries.
|
|
167
|
+
*/
|
|
168
|
+
private async merge (name: string, property: string,
|
|
169
|
+
observed: Array<[string, Node | Edge]>): Promise<void> {
|
|
170
|
+
if (observed.length === 0)
|
|
171
|
+
return
|
|
172
|
+
|
|
173
|
+
const objects: Record<string, Node | Edge> = {}
|
|
174
|
+
|
|
175
|
+
for (const [id, object] of observed)
|
|
176
|
+
objects[id] = object
|
|
177
|
+
|
|
178
|
+
await this.remotes[name].invoke('merge', {
|
|
179
|
+
query: { ids: observed.map(([id]) => id) },
|
|
180
|
+
input: { [property]: objects },
|
|
181
|
+
task: true
|
|
182
|
+
})
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Runs in the background: discovery waits for the explorer as long as it takes. */
|
|
186
|
+
private acquire (): void {
|
|
187
|
+
if (this.acquiring)
|
|
188
|
+
return
|
|
189
|
+
|
|
190
|
+
this.acquiring = true
|
|
191
|
+
|
|
192
|
+
void this.reach().catch((error: Error) => {
|
|
193
|
+
this.acquiring = false
|
|
194
|
+
|
|
195
|
+
console.error('Introspection cannot reach its explorer', { message: error.message })
|
|
196
|
+
})
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
private async reach (): Promise<void> {
|
|
200
|
+
await Promise.all([NODES, EDGES].map(async (name) => {
|
|
201
|
+
const remote = await this.boot.remote(new Locator(name, NAMESPACE))
|
|
202
|
+
|
|
203
|
+
this.depends(remote)
|
|
204
|
+
|
|
205
|
+
await remote.connect()
|
|
206
|
+
|
|
207
|
+
this.remotes[name] = remote
|
|
208
|
+
}))
|
|
209
|
+
}
|
|
210
|
+
}
|
package/source/Tenant.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { setTimeout } from 'node:timers/promises'
|
|
2
|
+
import { Connector } from '@toa.io/core'
|
|
3
|
+
import { ANNOUNCE_INTERVAL } from './const'
|
|
4
|
+
import type { Reporter } from './Reporter'
|
|
5
|
+
import type { Node } from './model'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Announces the static description of a component.
|
|
9
|
+
*
|
|
10
|
+
* Delivery is guaranteed, so the repeat is not about reliability — it keeps
|
|
11
|
+
* `_updated` fresh, which is how a removed component fades off the map.
|
|
12
|
+
*/
|
|
13
|
+
export class Tenant extends Connector {
|
|
14
|
+
private readonly reporter: Reporter
|
|
15
|
+
private readonly node: Node
|
|
16
|
+
private stopped = false
|
|
17
|
+
|
|
18
|
+
public constructor (reporter: Reporter, node: Node) {
|
|
19
|
+
super()
|
|
20
|
+
|
|
21
|
+
this.reporter = reporter
|
|
22
|
+
this.node = node
|
|
23
|
+
|
|
24
|
+
this.depends(reporter)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
protected override async open (): Promise<void> {
|
|
28
|
+
this.reporter.expose(this.node)
|
|
29
|
+
|
|
30
|
+
void this.announce()
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
protected override dispose (): void {
|
|
34
|
+
this.stopped = true
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
private async announce (): Promise<void> {
|
|
38
|
+
while (!this.stopped) {
|
|
39
|
+
await setTimeout(ANNOUNCE_INTERVAL, undefined, { ref: false })
|
|
40
|
+
|
|
41
|
+
if (this.stopped)
|
|
42
|
+
break
|
|
43
|
+
|
|
44
|
+
this.reporter.expose(this.node)
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|