cabloy 5.1.218 → 5.1.220
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/.cabloy-version +1 -1
- package/.github/workflows/create-cabloy.yml +1 -1
- package/CHANGELOG.md +19 -0
- package/README.md +1 -1
- package/package.json +1 -1
- package/repo-docs/fullstack/quickstart.md +3 -3
- package/repo-docs/reference/repo-scripts.md +12 -0
- package/scripts/generateOpenapiIsolated.test.ts +25 -0
- package/scripts/generateOpenapiIsolated.ts +28 -11
- package/scripts/runIsolatedTest.test.ts +157 -45
- package/scripts/runIsolatedTest.ts +30 -6
- package/scripts/strictGenerationStartup.test.ts +212 -0
- package/vona/packages-cli/cli/package.json +1 -1
- package/vona/packages-cli/cli-set-api/package.json +1 -1
- package/vona/packages-vona/vona/package.json +1 -1
- package/vona/packages-vona/vona-core/package.json +1 -1
- package/vona/packages-vona/vona-core/src/lib/bean/beanBase.ts +4 -0
- package/vona/packages-vona/vona-core/src/lib/core/asyncLocalStorage.ts +84 -12
- package/vona/packages-vona/vona-core/src/lib/core/index.ts +14 -0
- package/vona/packages-vona/vona-core/src/lib/core/logger/appLogger.ts +11 -3
- package/vona/packages-vona/vona-core/src/lib/core/logger/diagnosticLogger.test.ts +146 -0
- package/vona/packages-vona/vona-core/src/lib/core/logger/diagnosticLogger.ts +138 -0
- package/vona/packages-vona/vona-core/src/lib/core/logger/index.ts +1 -0
- package/vona/packages-vona/vona-core/src/lib/core/logger/utils.ts +25 -5
- package/vona/packages-vona/vona-core/src/lib/core/requestBoundary.ts +89 -0
- package/vona/packages-vona/vona-core/src/lib/core/testFreshness.test.ts +379 -0
- package/vona/packages-vona/vona-core/src/lib/core/testFreshness.ts +238 -0
- package/vona/packages-vona/vona-core/src/lib/framework/start.test.ts +4 -3
- package/vona/packages-vona/vona-core/src/lib/framework/start.ts +2 -0
- package/vona/packages-vona/vona-mock/package.json +1 -1
- package/vona/pnpm-lock.yaml +218 -43
- package/vona/pnpm-workspace.yaml +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-core/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-executor/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-executor/src/bean/bean.executor.ts +111 -60
- package/vona/src/suite-vendor/a-vona/modules/a-executor/src/service/executor.ts +6 -6
- package/vona/src/suite-vendor/a-vona/modules/a-executor/src/types/executor.ts +13 -1
- package/vona/src/suite-vendor/a-vona/modules/a-jwt/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-jwt/src/lib/jwtClientLogging.test.ts +182 -0
- package/vona/src/suite-vendor/a-vona/modules/a-jwt/src/service/jwtClient.ts +2 -8
- package/vona/src/suite-vendor/a-vona/modules/a-logger/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-logger/src/bean/aopMethod.log.ts +4 -1
- package/vona/src/suite-vendor/a-vona/modules/a-logger/src/bean/bean.logger.ts +9 -0
- package/vona/src/suite-vendor/a-vona/modules/a-logger/src/bean/middlewareSystem.httpLog.ts +27 -14
- package/vona/src/suite-vendor/a-vona/modules/a-logger/src/lib/redactHttpLog.test.ts +207 -0
- package/vona/src/suite-vendor/a-vona/modules/a-logger/src/lib/redactHttpLog.ts +30 -0
- package/vona/src/suite-vendor/a-vona/modules/a-logger/test/middlewareSystem.httpLog.test.ts +33 -1
- package/vona/src/suite-vendor/a-vona/modules/a-startup/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-startup/src/service/startup.ts +6 -0
- package/vona/src/suite-vendor/a-vona/modules/a-startup/test/devSeed.test.ts +2 -2
- package/vona/src/suite-vendor/a-vona/modules/a-telemetry/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-telemetry/src/bean/bean.telemetry.ts +8 -1
- package/vona/src/suite-vendor/a-vona/modules/a-telemetry/src/bean/middlewareSystem.trace.ts +9 -14
- package/vona/src/suite-vendor/a-vona/modules/a-telemetry/src/lib/spanDiagnostics.ts +70 -0
- package/vona/src/suite-vendor/a-vona/modules/a-telemetry/src/service/telemetry.ts +37 -23
- package/vona/src/suite-vendor/a-vona/modules/a-version/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-version/src/service/database.ts +31 -4
- package/vona/src/suite-vendor/a-vona/package.json +2 -2
- package/zova/packages-cli/cli/package.json +2 -2
- package/zova/packages-cli/cli-set-front/package.json +1 -1
- package/zova/packages-cli/cli-set-front/src/lib/bean/cli.bin.buildRest.ts +11 -101
- package/zova/packages-cli/cli-set-front/src/lib/common/restDependencies.ts +78 -0
- package/zova/packages-cli/cli-set-front/src/lib/common/restDependencyVersions.ts +231 -0
- package/zova/packages-cli/cli-set-front/test/restDependencies.test.ts +431 -0
- package/zova/pnpm-lock.yaml +8 -8
- /package/vona/patches/{zova-core@5.1.92.patch → zova-core@5.1.93.patch} +0 -0
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { registerHooks } from 'node:module';
|
|
4
|
+
import { test } from 'node:test';
|
|
5
|
+
import ts from 'typescript';
|
|
6
|
+
|
|
7
|
+
import { redactHttpLogHeaders, redactHttpLogUrl } from './redactHttpLog.ts';
|
|
8
|
+
|
|
9
|
+
const previousApp = Object.getOwnPropertyDescriptor(globalThis, '__app__');
|
|
10
|
+
const vonaUrl = new URL('../../../../../../../', import.meta.url);
|
|
11
|
+
const middlewareUrl = new URL(
|
|
12
|
+
'../bean/middlewareSystem.httpLog.ts?fixture=httpLogging',
|
|
13
|
+
import.meta.url,
|
|
14
|
+
).href;
|
|
15
|
+
const formatterUrl = new URL('packages-vona/vona-core/src/lib/core/logger/utils.ts', vonaUrl).href;
|
|
16
|
+
const checkUrl = new URL('packages-utils/utils/src/check.ts', vonaUrl).href;
|
|
17
|
+
const fixture = (source: string) => `data:text/javascript,${encodeURIComponent(source)}`;
|
|
18
|
+
const hooks = registerHooks({
|
|
19
|
+
resolve(specifier, context, nextResolve) {
|
|
20
|
+
if (context.parentURL === middlewareUrl) {
|
|
21
|
+
if (specifier === 'vona') {
|
|
22
|
+
return {
|
|
23
|
+
url: fixture('export class BeanBase {} export function getRequestBoundary() {}'),
|
|
24
|
+
shortCircuit: true,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
if (specifier === 'vona-module-a-aspect') {
|
|
28
|
+
return {
|
|
29
|
+
url: fixture('export function MiddlewareSystem() { return () => {}; }'),
|
|
30
|
+
shortCircuit: true,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
if (context.parentURL === formatterUrl) {
|
|
35
|
+
if (specifier === '@cabloy/utils') return { url: checkUrl, shortCircuit: true };
|
|
36
|
+
if (specifier === '../../framework/useApp.ts') {
|
|
37
|
+
return {
|
|
38
|
+
url: fixture(
|
|
39
|
+
'export function useApp() { throw new Error("unexpected application access"); }',
|
|
40
|
+
),
|
|
41
|
+
shortCircuit: true,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return nextResolve(specifier, context);
|
|
46
|
+
},
|
|
47
|
+
load(url, context, nextLoad) {
|
|
48
|
+
if (url === middlewareUrl) {
|
|
49
|
+
return {
|
|
50
|
+
format: 'module',
|
|
51
|
+
source: ts.transpileModule(readFileSync(new URL(url), 'utf8'), {
|
|
52
|
+
compilerOptions: {
|
|
53
|
+
experimentalDecorators: true,
|
|
54
|
+
target: ts.ScriptTarget.ESNext,
|
|
55
|
+
module: ts.ModuleKind.ESNext,
|
|
56
|
+
},
|
|
57
|
+
}).outputText,
|
|
58
|
+
shortCircuit: true,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
return nextLoad(url, context);
|
|
62
|
+
},
|
|
63
|
+
});
|
|
64
|
+
const { MiddlewareSystemHttpLog, formatLoggerFilter } = await (async () => {
|
|
65
|
+
try {
|
|
66
|
+
return { ...(await import(middlewareUrl)), ...(await import(formatterUrl)) };
|
|
67
|
+
} finally {
|
|
68
|
+
hooks.deregister();
|
|
69
|
+
}
|
|
70
|
+
})();
|
|
71
|
+
|
|
72
|
+
const secret = 'synthetic-http-secret-sentinel';
|
|
73
|
+
|
|
74
|
+
test('header copies hide standard, mixed-case and custom credentials', () => {
|
|
75
|
+
const headers = Object.freeze({
|
|
76
|
+
'Authorization': secret,
|
|
77
|
+
'Proxy-Authorization': secret,
|
|
78
|
+
'Cookie': secret,
|
|
79
|
+
'Set-Cookie': Object.freeze([secret, secret]),
|
|
80
|
+
'X-Custom-Credential': secret,
|
|
81
|
+
'Location': `/callback?code=${secret}`,
|
|
82
|
+
'Referer': `/callback?state=${secret}`,
|
|
83
|
+
'Content-Type': 'application/json',
|
|
84
|
+
'content-length': '42',
|
|
85
|
+
'accept': 'application/json',
|
|
86
|
+
});
|
|
87
|
+
const logged = redactHttpLogHeaders(headers);
|
|
88
|
+
assert.equal(JSON.stringify(logged).includes(secret), false);
|
|
89
|
+
assert.equal(logged['Content-Type'], 'application/json');
|
|
90
|
+
assert.equal(logged['content-length'], '42');
|
|
91
|
+
assert.equal(logged.accept, 'application/json');
|
|
92
|
+
assert.equal(headers.Authorization, secret);
|
|
93
|
+
assert.deepEqual(headers['Set-Cookie'], [secret, secret]);
|
|
94
|
+
assert.notEqual(logged, headers);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test('URL projections omit all query/fragment data and absolute-URL userinfo', () => {
|
|
98
|
+
for (const key of [
|
|
99
|
+
'access_token',
|
|
100
|
+
'refresh_token',
|
|
101
|
+
'code',
|
|
102
|
+
'state',
|
|
103
|
+
'x-vona-passport-code',
|
|
104
|
+
'custom',
|
|
105
|
+
]) {
|
|
106
|
+
const url = `/callback?${key}=${secret}&${key}=${secret}#${secret}`;
|
|
107
|
+
assert.equal(redactHttpLogUrl(url), '/callback?[REDACTED]');
|
|
108
|
+
}
|
|
109
|
+
assert.equal(redactHttpLogUrl(`/callback#${secret}`), '/callback?[REDACTED]');
|
|
110
|
+
assert.equal(
|
|
111
|
+
redactHttpLogUrl(`https://${secret}:${secret}@example.invalid/callback?x=${secret}`),
|
|
112
|
+
'https://example.invalid/callback?[REDACTED]',
|
|
113
|
+
);
|
|
114
|
+
assert.equal(
|
|
115
|
+
redactHttpLogUrl(`//${secret}@example.invalid/callback`),
|
|
116
|
+
'//example.invalid/callback',
|
|
117
|
+
);
|
|
118
|
+
assert.equal(redactHttpLogUrl('https://['), '[REDACTED]');
|
|
119
|
+
assert.equal(redactHttpLogUrl(`data:${secret}`), '[REDACTED]');
|
|
120
|
+
assert.equal(redactHttpLogUrl({ path: secret }), undefined);
|
|
121
|
+
assert.equal(redactHttpLogUrl('/ordinary/path'), '/ordinary/path');
|
|
122
|
+
assert.equal(redactHttpLogUrl('*'), '*');
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
for (const failure of [false, true]) {
|
|
126
|
+
test(`actual middleware strict-http projection, downstream ${failure ? 'error' : 'success'}`, async () => {
|
|
127
|
+
const output: string[] = [];
|
|
128
|
+
const filter = formatLoggerFilter({ level: 'http', child: undefined, strict: true });
|
|
129
|
+
const capture = (message: any, extra = {}) => {
|
|
130
|
+
const info = filter.transform({ level: 'http', message, ...extra }, filter.options);
|
|
131
|
+
assert.ok(info);
|
|
132
|
+
output.push(JSON.stringify(info));
|
|
133
|
+
};
|
|
134
|
+
const headers = Object.freeze({
|
|
135
|
+
authorization: secret,
|
|
136
|
+
cookie: secret,
|
|
137
|
+
accept: 'application/json',
|
|
138
|
+
});
|
|
139
|
+
const responseHeaders = Object.freeze({
|
|
140
|
+
'set-cookie': [secret],
|
|
141
|
+
'content-type': 'application/json',
|
|
142
|
+
});
|
|
143
|
+
const req = Object.freeze({
|
|
144
|
+
method: 'POST',
|
|
145
|
+
url: `/callback?custom=${secret}`,
|
|
146
|
+
originalUrl: `/callback?x-vona-passport-code=${secret}`,
|
|
147
|
+
query: { custom: secret },
|
|
148
|
+
params: { token: secret },
|
|
149
|
+
body: { token: secret },
|
|
150
|
+
headers,
|
|
151
|
+
socket: { remoteAddress: '127.0.0.1', remotePort: 12345 },
|
|
152
|
+
});
|
|
153
|
+
const ctx = {
|
|
154
|
+
req,
|
|
155
|
+
path: '/callback',
|
|
156
|
+
res: { statusCode: 204, getHeaders: () => responseHeaders },
|
|
157
|
+
};
|
|
158
|
+
const fake = {
|
|
159
|
+
ctx,
|
|
160
|
+
app: { meta: { env: {} } },
|
|
161
|
+
$loggerChild: (name: string) => {
|
|
162
|
+
assert.ok(name === 'req' || name === 'res');
|
|
163
|
+
return {
|
|
164
|
+
http: (message: any) => capture(message),
|
|
165
|
+
startTimer: () => ({
|
|
166
|
+
done: (info: any) => {
|
|
167
|
+
assert.equal(info.level, 'http');
|
|
168
|
+
capture(info.message, { durationMs: 7 });
|
|
169
|
+
},
|
|
170
|
+
}),
|
|
171
|
+
};
|
|
172
|
+
},
|
|
173
|
+
};
|
|
174
|
+
let calls = 0;
|
|
175
|
+
const error = new Error(secret);
|
|
176
|
+
const execution = MiddlewareSystemHttpLog.prototype.execute.call(fake as any, {}, async () => {
|
|
177
|
+
calls++;
|
|
178
|
+
assert.equal(ctx.req, req);
|
|
179
|
+
assert.equal(ctx.req.headers.authorization, secret);
|
|
180
|
+
assert.equal(ctx.req.body.token, secret);
|
|
181
|
+
if (failure) throw error;
|
|
182
|
+
});
|
|
183
|
+
if (failure) {
|
|
184
|
+
await assert.rejects(execution, value => value === error);
|
|
185
|
+
} else {
|
|
186
|
+
await execution;
|
|
187
|
+
}
|
|
188
|
+
assert.equal(calls, 1);
|
|
189
|
+
assert.equal(output.length, failure ? 1 : 2);
|
|
190
|
+
assert.equal(output.join('\n').includes(secret), false);
|
|
191
|
+
const request = JSON.parse(JSON.parse(output[0]!).message);
|
|
192
|
+
assert.equal(request.method, 'POST');
|
|
193
|
+
assert.equal(request.url, '/callback?[REDACTED]');
|
|
194
|
+
assert.equal(request.query, undefined);
|
|
195
|
+
assert.equal(request.params, undefined);
|
|
196
|
+
assert.equal(request.body, undefined);
|
|
197
|
+
assert.equal(request.raw, undefined);
|
|
198
|
+
if (!failure) {
|
|
199
|
+
const response = JSON.parse(output[1]!);
|
|
200
|
+
assert.equal(response.durationMs, 7);
|
|
201
|
+
assert.equal(JSON.parse(response.message).statusCode, 204);
|
|
202
|
+
}
|
|
203
|
+
assert.equal(req.url, `/callback?custom=${secret}`);
|
|
204
|
+
assert.deepEqual(responseHeaders['set-cookie'], [secret]);
|
|
205
|
+
assert.deepEqual(Object.getOwnPropertyDescriptor(globalThis, '__app__'), previousApp);
|
|
206
|
+
});
|
|
207
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
const safeHeaders = new Set(['accept', 'content-type', 'content-length']);
|
|
2
|
+
|
|
3
|
+
export function redactHttpLogHeaders(headers: Record<string, unknown>): Record<string, unknown> {
|
|
4
|
+
// Allowlist diagnostic metadata so custom credential headers are covered too.
|
|
5
|
+
return Object.fromEntries(
|
|
6
|
+
Object.entries(headers).map(([key, value]) => [
|
|
7
|
+
key,
|
|
8
|
+
safeHeaders.has(key.toLowerCase()) ? value : '[REDACTED]',
|
|
9
|
+
]),
|
|
10
|
+
);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function redactHttpLogUrl(value: unknown): string | undefined {
|
|
14
|
+
if (typeof value !== 'string') return undefined;
|
|
15
|
+
const boundary = value.search(/[?#]/);
|
|
16
|
+
let path = boundary < 0 ? value : value.slice(0, boundary);
|
|
17
|
+
if (/^[a-z][a-z\d+.-]*:\/\//i.test(path) || path.startsWith('//')) {
|
|
18
|
+
try {
|
|
19
|
+
const url = new URL(path, 'http://redacted.invalid');
|
|
20
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') return '[REDACTED]';
|
|
21
|
+
path = `${path.startsWith('//') ? '//' : `${url.protocol}//`}${url.host}${url.pathname}`;
|
|
22
|
+
} catch {
|
|
23
|
+
return '[REDACTED]';
|
|
24
|
+
}
|
|
25
|
+
} else if (!path.startsWith('/') && path !== '*') {
|
|
26
|
+
return '[REDACTED]';
|
|
27
|
+
}
|
|
28
|
+
// Query names can be configured by authentication clients; omit the whole query/fragment.
|
|
29
|
+
return `${path}${boundary < 0 ? '' : '?[REDACTED]'}`;
|
|
30
|
+
}
|
|
@@ -8,11 +8,18 @@ const middlewareUrl = new URL(
|
|
|
8
8
|
'../src/bean/middlewareSystem.httpLog.ts?fixture=httpLog',
|
|
9
9
|
import.meta.url,
|
|
10
10
|
).href;
|
|
11
|
+
const boundaryFixture = `data:text/javascript,${encodeURIComponent(`
|
|
12
|
+
export class BeanBase {}
|
|
13
|
+
let boundary;
|
|
14
|
+
export function getRequestBoundary() { return boundary; }
|
|
15
|
+
export function setBoundary(value) { boundary = value; }
|
|
16
|
+
`)}`;
|
|
17
|
+
const { setBoundary } = await import(boundaryFixture);
|
|
11
18
|
const hooks = registerHooks({
|
|
12
19
|
resolve(specifier, context, nextResolve) {
|
|
13
20
|
if (context.parentURL === middlewareUrl) {
|
|
21
|
+
if (specifier === 'vona') return { url: boundaryFixture, shortCircuit: true };
|
|
14
22
|
const source = {
|
|
15
|
-
'vona': 'export class BeanBase {}',
|
|
16
23
|
'vona-module-a-aspect': 'export function MiddlewareSystem() { return () => {}; }',
|
|
17
24
|
'pino-std-serializers':
|
|
18
25
|
'export default { req: req => ({ headers: req.headers, url: req.url }) };',
|
|
@@ -42,6 +49,31 @@ const hooks = registerHooks({
|
|
|
42
49
|
const { MiddlewareSystemHttpLog } = await import(middlewareUrl);
|
|
43
50
|
hooks.deregister();
|
|
44
51
|
|
|
52
|
+
test('HTTP logger skips context and diagnostic access inside a request boundary', async () => {
|
|
53
|
+
const middleware = Object.create(MiddlewareSystemHttpLog.prototype);
|
|
54
|
+
for (const key of ['app', 'ctx', '$loggerChild']) {
|
|
55
|
+
Object.defineProperty(middleware, key, {
|
|
56
|
+
get() {
|
|
57
|
+
assert.fail('protected diagnostic access');
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
let calls = 0;
|
|
62
|
+
setBoundary({ diagnostics: 'suppress' });
|
|
63
|
+
try {
|
|
64
|
+
assert.equal(
|
|
65
|
+
await middleware.execute({}, async () => {
|
|
66
|
+
calls++;
|
|
67
|
+
return 23;
|
|
68
|
+
}),
|
|
69
|
+
23,
|
|
70
|
+
);
|
|
71
|
+
assert.equal(calls, 1);
|
|
72
|
+
} finally {
|
|
73
|
+
setBoundary(undefined);
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
|
|
45
77
|
for (const path of ['/health/ready', '/health/test-stop']) {
|
|
46
78
|
test(`HTTP logger redacts request authorization and response proof at ${path} without mutating headers`, async () => {
|
|
47
79
|
const headers = {
|
|
@@ -5,6 +5,8 @@ import { isNil } from '@cabloy/utils';
|
|
|
5
5
|
import fse from 'fs-extra';
|
|
6
6
|
import {
|
|
7
7
|
assertIsolatedTestConfig,
|
|
8
|
+
assertFreshTestResourcesReady,
|
|
9
|
+
requiresFreshTestResources,
|
|
8
10
|
BeanBase,
|
|
9
11
|
cast,
|
|
10
12
|
getRuntimePathPhysicalRoot,
|
|
@@ -242,6 +244,10 @@ export class ServiceStartup extends BeanBase {
|
|
|
242
244
|
private async _clearResources() {
|
|
243
245
|
if (!this.app.meta.isTest) return;
|
|
244
246
|
assertIsolatedTestConfig(this.app);
|
|
247
|
+
if (requiresFreshTestResources()) {
|
|
248
|
+
assertFreshTestResourcesReady(this.app);
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
245
251
|
// redis
|
|
246
252
|
await this.$scope.redis.service.redis.clearAllData();
|
|
247
253
|
// .app/public
|
|
@@ -217,7 +217,7 @@ describe('creation-driven dev seed', { concurrency: false }, () => {
|
|
|
217
217
|
const { service, app, calls, pending } = fixture(['', 'isolateTest']);
|
|
218
218
|
const runStartup = service.runStartup.bind(service);
|
|
219
219
|
service.runStartup = async (name, instance, options) => {
|
|
220
|
-
if (name === 'a-version:instanceInit' && instance === 'isolateTest') {
|
|
220
|
+
if (name === 'a-version:instanceInit' && String(instance) === 'isolateTest') {
|
|
221
221
|
throw new Error('isolate init failed');
|
|
222
222
|
}
|
|
223
223
|
await runStartup(name, instance, options);
|
|
@@ -239,7 +239,7 @@ describe('creation-driven dev seed', { concurrency: false }, () => {
|
|
|
239
239
|
} else {
|
|
240
240
|
const runStartup = service.runStartup.bind(service);
|
|
241
241
|
service.runStartup = async (name, instance, options) => {
|
|
242
|
-
if (name === 'a-version:instanceInit' && instance === 'isolateTest') {
|
|
242
|
+
if (name === 'a-version:instanceInit' && String(instance) === 'isolateTest') {
|
|
243
243
|
throw new Error('isolate init failed');
|
|
244
244
|
}
|
|
245
245
|
await runStartup(name, instance, options);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { Span, SpanOptions } from '@opentelemetry/api';
|
|
2
2
|
|
|
3
3
|
import { INVALID_SPAN_CONTEXT, trace } from '@opentelemetry/api';
|
|
4
|
-
import { BeanBase } from 'vona';
|
|
4
|
+
import { BeanBase, getRequestBoundary } from 'vona';
|
|
5
5
|
import { Bean } from 'vona-module-a-bean';
|
|
6
6
|
|
|
7
7
|
const nonRecordingSpan = trace.wrapSpanContext(INVALID_SPAN_CONTEXT);
|
|
@@ -9,25 +9,30 @@ const nonRecordingSpan = trace.wrapSpanContext(INVALID_SPAN_CONTEXT);
|
|
|
9
9
|
@Bean()
|
|
10
10
|
export class BeanTelemetry extends BeanBase {
|
|
11
11
|
get enabled() {
|
|
12
|
+
if (getRequestBoundary()) return false;
|
|
12
13
|
return this.scope.service.telemetry.enabled;
|
|
13
14
|
}
|
|
14
15
|
|
|
15
16
|
startSpan(name: string, options?: SpanOptions): Span {
|
|
17
|
+
if (getRequestBoundary()) return nonRecordingSpan;
|
|
16
18
|
const telemetry = this.scope.service.telemetry;
|
|
17
19
|
return telemetry.enabled ? telemetry.startSpan(name, options) : nonRecordingSpan;
|
|
18
20
|
}
|
|
19
21
|
|
|
20
22
|
withSpan<RESULT>(span: Span, fn: () => RESULT): RESULT {
|
|
23
|
+
if (getRequestBoundary()) return fn();
|
|
21
24
|
const telemetry = this.scope.service.telemetry;
|
|
22
25
|
return telemetry.enabled ? telemetry.withSpan(span, fn) : fn();
|
|
23
26
|
}
|
|
24
27
|
|
|
25
28
|
recordException(span: Span, error: unknown) {
|
|
29
|
+
if (getRequestBoundary()) return;
|
|
26
30
|
const telemetry = this.scope.service.telemetry;
|
|
27
31
|
if (telemetry.enabled) telemetry.recordException(span, error);
|
|
28
32
|
}
|
|
29
33
|
|
|
30
34
|
withNamedSpan<RESULT>(name: string, fn: () => RESULT, options?: SpanOptions): RESULT {
|
|
35
|
+
if (getRequestBoundary()) return fn();
|
|
31
36
|
const span = this.startSpan(name, options);
|
|
32
37
|
try {
|
|
33
38
|
const result = this.withSpan(span, fn);
|
|
@@ -52,6 +57,7 @@ export class BeanTelemetry extends BeanBase {
|
|
|
52
57
|
}
|
|
53
58
|
|
|
54
59
|
private _recordAndEndSpan(span: Span, error: unknown) {
|
|
60
|
+
if (getRequestBoundary()) return;
|
|
55
61
|
try {
|
|
56
62
|
this.recordException(span, error);
|
|
57
63
|
} catch {}
|
|
@@ -59,6 +65,7 @@ export class BeanTelemetry extends BeanBase {
|
|
|
59
65
|
}
|
|
60
66
|
|
|
61
67
|
private _endSpan(span: Span) {
|
|
68
|
+
if (getRequestBoundary()) return;
|
|
62
69
|
try {
|
|
63
70
|
span.end();
|
|
64
71
|
} catch {}
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import type { Span } from '@opentelemetry/api';
|
|
2
1
|
import type { Next } from 'vona';
|
|
3
2
|
import type {
|
|
4
3
|
IDecoratorMiddlewareSystemOptions,
|
|
@@ -6,9 +5,11 @@ import type {
|
|
|
6
5
|
} from 'vona-module-a-aspect';
|
|
7
6
|
|
|
8
7
|
import { context } from '@opentelemetry/api';
|
|
9
|
-
import { BeanBase, uuidv4 } from 'vona';
|
|
8
|
+
import { BeanBase, captureRequestBoundary, getRequestBoundary, uuidv4 } from 'vona';
|
|
10
9
|
import { MiddlewareSystem } from 'vona-module-a-aspect';
|
|
11
10
|
|
|
11
|
+
import { createHttpSpanEnd, recordSpanException } from '../lib/spanDiagnostics.ts';
|
|
12
|
+
|
|
12
13
|
export interface IMiddlewareSystemOptionsTrace extends IDecoratorMiddlewareSystemOptions {}
|
|
13
14
|
|
|
14
15
|
@MiddlewareSystem<IMiddlewareSystemOptionsTrace>({
|
|
@@ -16,6 +17,7 @@ export interface IMiddlewareSystemOptionsTrace extends IDecoratorMiddlewareSyste
|
|
|
16
17
|
})
|
|
17
18
|
export class MiddlewareSystemTrace extends BeanBase implements IMiddlewareSystemExecute {
|
|
18
19
|
async execute(_options: IMiddlewareSystemOptionsTrace, next: Next) {
|
|
20
|
+
if (getRequestBoundary()) return next();
|
|
19
21
|
const telemetry = this.$scope.telemetry.service.telemetry;
|
|
20
22
|
if (!telemetry.enabled || this.ctx.state.telemetry?.internalAction) return next();
|
|
21
23
|
|
|
@@ -37,11 +39,10 @@ export class MiddlewareSystemTrace extends BeanBase implements IMiddlewareSystem
|
|
|
37
39
|
ctx.state.telemetry = { requestId, context: active, serverSpan: span };
|
|
38
40
|
ctx.set(requestIdHeader, requestId);
|
|
39
41
|
|
|
40
|
-
const
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
ctx.res.once('
|
|
44
|
-
ctx.res.once('close', close);
|
|
42
|
+
const capture = captureRequestBoundary();
|
|
43
|
+
const terminal = createHttpSpanEnd(span, ctx.res, capture);
|
|
44
|
+
ctx.res.once('finish', terminal.finish);
|
|
45
|
+
ctx.res.once('close', terminal.close);
|
|
45
46
|
|
|
46
47
|
try {
|
|
47
48
|
const result = await context.with(active, next);
|
|
@@ -55,16 +56,10 @@ export class MiddlewareSystemTrace extends BeanBase implements IMiddlewareSystem
|
|
|
55
56
|
}
|
|
56
57
|
return result;
|
|
57
58
|
} catch (err) {
|
|
58
|
-
|
|
59
|
+
recordSpanException(span, err, capture);
|
|
59
60
|
throw err;
|
|
60
61
|
}
|
|
61
62
|
}
|
|
62
|
-
|
|
63
|
-
private _end(span: Span, statusCode: number, aborted: boolean) {
|
|
64
|
-
if (!span || this.ctx.state.telemetry?.serverSpanEnded) return;
|
|
65
|
-
this.ctx.state.telemetry!.serverSpanEnded = true;
|
|
66
|
-
this.$scope.telemetry.service.telemetry.endHttpSpan(span, statusCode, aborted);
|
|
67
|
-
}
|
|
68
63
|
}
|
|
69
64
|
|
|
70
65
|
function normalizeRequestId(value?: string) {
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { Span } from '@opentelemetry/api';
|
|
2
|
+
import type { RequestBoundaryCapture } from 'vona';
|
|
3
|
+
|
|
4
|
+
import { SpanStatusCode } from '@opentelemetry/api';
|
|
5
|
+
import { captureRequestBoundary, getRequestBoundary } from 'vona';
|
|
6
|
+
|
|
7
|
+
export interface HttpSpanResponse {
|
|
8
|
+
readonly statusCode: number;
|
|
9
|
+
readonly writableEnded: boolean;
|
|
10
|
+
removeListener: (event: 'finish' | 'close', listener: () => void) => unknown;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function recordSpanException(
|
|
14
|
+
span: Span,
|
|
15
|
+
error: unknown,
|
|
16
|
+
capture?: RequestBoundaryCapture,
|
|
17
|
+
): void {
|
|
18
|
+
if (capture || getRequestBoundary()) return;
|
|
19
|
+
if (error instanceof Error) {
|
|
20
|
+
span.recordException(error);
|
|
21
|
+
span.setStatus({ code: SpanStatusCode.ERROR, message: error.message });
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
span.setStatus({ code: SpanStatusCode.ERROR, message: String(error) });
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function endHttpSpan(span: Span, statusCode: number, aborted = false): void {
|
|
28
|
+
if (getRequestBoundary()) return;
|
|
29
|
+
if (aborted || statusCode >= 500) {
|
|
30
|
+
span.setStatus({
|
|
31
|
+
code: SpanStatusCode.ERROR,
|
|
32
|
+
message: aborted ? 'client aborted' : undefined,
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
span.setAttribute('http.response.status_code', statusCode);
|
|
36
|
+
if (aborted) span.setAttribute('vona.http.aborted', true);
|
|
37
|
+
span.end();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function createHttpSpanEnd(
|
|
41
|
+
span: Span,
|
|
42
|
+
response: HttpSpanResponse,
|
|
43
|
+
capture?: RequestBoundaryCapture,
|
|
44
|
+
): Readonly<{ finish(): void; close(): void }> {
|
|
45
|
+
capture ??= captureRequestBoundary();
|
|
46
|
+
const reportFailure = () => capture?.reportFailure('late_callback_failed');
|
|
47
|
+
let ended = false;
|
|
48
|
+
const finish = () => end(false);
|
|
49
|
+
const close = () => end(true);
|
|
50
|
+
function end(closed: boolean) {
|
|
51
|
+
if (ended) return;
|
|
52
|
+
ended = true;
|
|
53
|
+
try {
|
|
54
|
+
response.removeListener(closed ? 'finish' : 'close', closed ? finish : close);
|
|
55
|
+
} catch {
|
|
56
|
+
reportFailure();
|
|
57
|
+
}
|
|
58
|
+
if (capture || getRequestBoundary()) return;
|
|
59
|
+
try {
|
|
60
|
+
endHttpSpan(
|
|
61
|
+
span,
|
|
62
|
+
response.statusCode || (closed ? 499 : 500),
|
|
63
|
+
closed && !response.writableEnded,
|
|
64
|
+
);
|
|
65
|
+
} catch {
|
|
66
|
+
reportFailure();
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return Object.freeze({ finish, close });
|
|
70
|
+
}
|
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
import type { Context, Span, SpanOptions } from '@opentelemetry/api';
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
context,
|
|
5
|
+
INVALID_SPAN_CONTEXT,
|
|
6
|
+
propagation,
|
|
7
|
+
ROOT_CONTEXT,
|
|
8
|
+
SpanKind,
|
|
9
|
+
trace,
|
|
10
|
+
} from '@opentelemetry/api';
|
|
4
11
|
import { AsyncLocalStorageContextManager } from '@opentelemetry/context-async-hooks';
|
|
5
12
|
import { W3CTraceContextPropagator } from '@opentelemetry/core';
|
|
6
13
|
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-proto';
|
|
@@ -11,12 +18,15 @@ import {
|
|
|
11
18
|
TraceIdRatioBasedSampler,
|
|
12
19
|
} from '@opentelemetry/sdk-trace-base';
|
|
13
20
|
import { NodeTracerProvider } from '@opentelemetry/sdk-trace-node';
|
|
14
|
-
import { BeanBase } from 'vona';
|
|
21
|
+
import { BeanBase, captureRequestBoundary, getRequestBoundary } from 'vona';
|
|
15
22
|
import { Service } from 'vona-module-a-bean';
|
|
16
23
|
|
|
17
24
|
import type { ITelemetryCarrier } from '../types/telemetry.ts';
|
|
18
25
|
|
|
19
26
|
import { createIngressTrustChecker } from '../lib/ingress.ts';
|
|
27
|
+
import { endHttpSpan, recordSpanException } from '../lib/spanDiagnostics.ts';
|
|
28
|
+
|
|
29
|
+
const nonRecordingSpan = trace.wrapSpanContext(INVALID_SPAN_CONTEXT);
|
|
20
30
|
|
|
21
31
|
const carrierGetter = {
|
|
22
32
|
get(carrier: ITelemetryCarrier, key: string) {
|
|
@@ -44,10 +54,12 @@ export class ServiceTelemetry extends BeanBase {
|
|
|
44
54
|
private _enabled = false;
|
|
45
55
|
|
|
46
56
|
get enabled() {
|
|
57
|
+
if (getRequestBoundary()) return false;
|
|
47
58
|
return this._enabled;
|
|
48
59
|
}
|
|
49
60
|
|
|
50
61
|
init() {
|
|
62
|
+
if (getRequestBoundary()) return;
|
|
51
63
|
if (this._provider || this._enabled) return;
|
|
52
64
|
const config = this.scope.config;
|
|
53
65
|
this._isTrustedIngress = createIngressTrustChecker(config.ingress);
|
|
@@ -96,64 +108,64 @@ export class ServiceTelemetry extends BeanBase {
|
|
|
96
108
|
}
|
|
97
109
|
|
|
98
110
|
get activeContext() {
|
|
111
|
+
if (getRequestBoundary()) return ROOT_CONTEXT;
|
|
99
112
|
return this.ctx?.state.telemetry?.context ?? context.active();
|
|
100
113
|
}
|
|
101
114
|
|
|
102
115
|
extractCarrier(carrier?: ITelemetryCarrier) {
|
|
116
|
+
if (getRequestBoundary()) return ROOT_CONTEXT;
|
|
103
117
|
if (!carrier) return context.active();
|
|
104
118
|
return propagation.extract(context.active(), carrier, carrierGetter);
|
|
105
119
|
}
|
|
106
120
|
|
|
107
121
|
isTrustedIngress(peerAddress?: string, internalHeaderValue?: string) {
|
|
122
|
+
if (getRequestBoundary()) return false;
|
|
108
123
|
this._isTrustedIngress ??= createIngressTrustChecker(this.scope.config.ingress);
|
|
109
124
|
return this._isTrustedIngress(peerAddress, internalHeaderValue);
|
|
110
125
|
}
|
|
111
126
|
|
|
112
127
|
extractHttpIngressCarrier(trusted: boolean, carrier: ITelemetryCarrier) {
|
|
128
|
+
if (getRequestBoundary()) return ROOT_CONTEXT;
|
|
113
129
|
return trusted ? this.extractCarrier(carrier) : this.extractCarrier();
|
|
114
130
|
}
|
|
115
131
|
|
|
116
|
-
injectCarrier(source
|
|
132
|
+
injectCarrier(source?: Context): ITelemetryCarrier {
|
|
133
|
+
if (getRequestBoundary()) return { version: 1 };
|
|
117
134
|
const carrier: ITelemetryCarrier = { version: 1 };
|
|
118
|
-
propagation.inject(source, carrier, carrierSetter);
|
|
135
|
+
propagation.inject(source === undefined ? this.activeContext : source, carrier, carrierSetter);
|
|
119
136
|
return carrier;
|
|
120
137
|
}
|
|
121
138
|
|
|
122
|
-
startSpan(name: string, options?: SpanOptions, parent
|
|
123
|
-
|
|
139
|
+
startSpan(name: string, options?: SpanOptions, parent?: Context) {
|
|
140
|
+
if (getRequestBoundary()) return nonRecordingSpan;
|
|
141
|
+
return trace
|
|
142
|
+
.getTracer('vona-module-a-telemetry')
|
|
143
|
+
.startSpan(name, options, parent === undefined ? this.activeContext : parent);
|
|
124
144
|
}
|
|
125
145
|
|
|
126
146
|
withSpan<RESULT>(span: Span, fn: () => RESULT): RESULT {
|
|
147
|
+
if (getRequestBoundary()) return fn();
|
|
127
148
|
const active = trace.setSpan(this.activeContext, span);
|
|
128
149
|
return context.with(active, fn);
|
|
129
150
|
}
|
|
130
151
|
|
|
131
|
-
createContext(span: Span, parent
|
|
132
|
-
|
|
152
|
+
createContext(span: Span, parent?: Context) {
|
|
153
|
+
if (getRequestBoundary()) return ROOT_CONTEXT;
|
|
154
|
+
return trace.setSpan(parent === undefined ? this.activeContext : parent, span);
|
|
133
155
|
}
|
|
134
156
|
|
|
135
157
|
recordException(span: Span, error: unknown) {
|
|
136
|
-
if (
|
|
137
|
-
|
|
138
|
-
span.setStatus({ code: SpanStatusCode.ERROR, message: error.message });
|
|
139
|
-
return;
|
|
140
|
-
}
|
|
141
|
-
span.setStatus({ code: SpanStatusCode.ERROR, message: String(error) });
|
|
158
|
+
if (getRequestBoundary()) return;
|
|
159
|
+
recordSpanException(span, error, captureRequestBoundary());
|
|
142
160
|
}
|
|
143
161
|
|
|
144
162
|
endHttpSpan(span: Span, statusCode: number, aborted = false) {
|
|
145
|
-
if (
|
|
146
|
-
|
|
147
|
-
code: SpanStatusCode.ERROR,
|
|
148
|
-
message: aborted ? 'client aborted' : undefined,
|
|
149
|
-
});
|
|
150
|
-
}
|
|
151
|
-
span.setAttribute('http.response.status_code', statusCode);
|
|
152
|
-
if (aborted) span.setAttribute('vona.http.aborted', true);
|
|
153
|
-
span.end();
|
|
163
|
+
if (getRequestBoundary()) return;
|
|
164
|
+
endHttpSpan(span, statusCode, aborted);
|
|
154
165
|
}
|
|
155
166
|
|
|
156
167
|
getLogContext() {
|
|
168
|
+
if (getRequestBoundary()) return;
|
|
157
169
|
if (!this._enabled) return;
|
|
158
170
|
const spanContext = trace.getSpanContext(this.activeContext);
|
|
159
171
|
if (!spanContext || !trace.isSpanContextValid(spanContext)) return;
|
|
@@ -166,6 +178,7 @@ export class ServiceTelemetry extends BeanBase {
|
|
|
166
178
|
}
|
|
167
179
|
|
|
168
180
|
createServerSpan(method: string, parent: Context) {
|
|
181
|
+
if (getRequestBoundary()) return nonRecordingSpan;
|
|
169
182
|
return this.startSpan(`HTTP ${method}`, { kind: SpanKind.SERVER }, parent);
|
|
170
183
|
}
|
|
171
184
|
|
|
@@ -175,6 +188,7 @@ export class ServiceTelemetry extends BeanBase {
|
|
|
175
188
|
controllerBeanFullName: string,
|
|
176
189
|
action: string,
|
|
177
190
|
) {
|
|
191
|
+
if (getRequestBoundary()) return;
|
|
178
192
|
const span = this.ctx.state.telemetry?.serverSpan;
|
|
179
193
|
if (!span) return;
|
|
180
194
|
span.updateName(`HTTP ${method.toUpperCase()} ${routePath}`);
|