cabloy 5.1.219 → 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.
Files changed (63) hide show
  1. package/.cabloy-version +1 -1
  2. package/.github/workflows/create-cabloy.yml +1 -1
  3. package/CHANGELOG.md +12 -0
  4. package/README.md +1 -1
  5. package/package.json +1 -1
  6. package/repo-docs/fullstack/quickstart.md +3 -3
  7. package/repo-docs/reference/repo-scripts.md +12 -0
  8. package/scripts/generateOpenapiIsolated.test.ts +25 -0
  9. package/scripts/generateOpenapiIsolated.ts +28 -11
  10. package/scripts/runIsolatedTest.test.ts +157 -45
  11. package/scripts/runIsolatedTest.ts +30 -6
  12. package/scripts/strictGenerationStartup.test.ts +212 -0
  13. package/vona/packages-cli/cli/package.json +1 -1
  14. package/vona/packages-cli/cli-set-api/package.json +1 -1
  15. package/vona/packages-vona/vona/package.json +1 -1
  16. package/vona/packages-vona/vona-core/package.json +1 -1
  17. package/vona/packages-vona/vona-core/src/lib/bean/beanBase.ts +4 -0
  18. package/vona/packages-vona/vona-core/src/lib/core/asyncLocalStorage.ts +84 -12
  19. package/vona/packages-vona/vona-core/src/lib/core/index.ts +14 -0
  20. package/vona/packages-vona/vona-core/src/lib/core/logger/appLogger.ts +11 -3
  21. package/vona/packages-vona/vona-core/src/lib/core/logger/diagnosticLogger.test.ts +146 -0
  22. package/vona/packages-vona/vona-core/src/lib/core/logger/diagnosticLogger.ts +138 -0
  23. package/vona/packages-vona/vona-core/src/lib/core/logger/index.ts +1 -0
  24. package/vona/packages-vona/vona-core/src/lib/core/logger/utils.ts +25 -5
  25. package/vona/packages-vona/vona-core/src/lib/core/requestBoundary.ts +89 -0
  26. package/vona/packages-vona/vona-core/src/lib/core/testFreshness.test.ts +379 -0
  27. package/vona/packages-vona/vona-core/src/lib/core/testFreshness.ts +238 -0
  28. package/vona/packages-vona/vona-core/src/lib/framework/start.test.ts +4 -3
  29. package/vona/packages-vona/vona-core/src/lib/framework/start.ts +2 -0
  30. package/vona/packages-vona/vona-mock/package.json +1 -1
  31. package/vona/pnpm-lock.yaml +154 -125
  32. package/vona/src/suite-vendor/a-vona/modules/a-core/package.json +1 -1
  33. package/vona/src/suite-vendor/a-vona/modules/a-executor/package.json +1 -1
  34. package/vona/src/suite-vendor/a-vona/modules/a-executor/src/bean/bean.executor.ts +111 -60
  35. package/vona/src/suite-vendor/a-vona/modules/a-executor/src/service/executor.ts +6 -6
  36. package/vona/src/suite-vendor/a-vona/modules/a-executor/src/types/executor.ts +13 -1
  37. package/vona/src/suite-vendor/a-vona/modules/a-jwt/package.json +1 -1
  38. package/vona/src/suite-vendor/a-vona/modules/a-jwt/src/lib/jwtClientLogging.test.ts +182 -0
  39. package/vona/src/suite-vendor/a-vona/modules/a-jwt/src/service/jwtClient.ts +2 -8
  40. package/vona/src/suite-vendor/a-vona/modules/a-logger/package.json +1 -1
  41. package/vona/src/suite-vendor/a-vona/modules/a-logger/src/bean/aopMethod.log.ts +4 -1
  42. package/vona/src/suite-vendor/a-vona/modules/a-logger/src/bean/bean.logger.ts +9 -0
  43. package/vona/src/suite-vendor/a-vona/modules/a-logger/src/bean/middlewareSystem.httpLog.ts +27 -14
  44. package/vona/src/suite-vendor/a-vona/modules/a-logger/src/lib/redactHttpLog.test.ts +207 -0
  45. package/vona/src/suite-vendor/a-vona/modules/a-logger/src/lib/redactHttpLog.ts +30 -0
  46. package/vona/src/suite-vendor/a-vona/modules/a-logger/test/middlewareSystem.httpLog.test.ts +33 -1
  47. package/vona/src/suite-vendor/a-vona/modules/a-startup/package.json +1 -1
  48. package/vona/src/suite-vendor/a-vona/modules/a-startup/src/service/startup.ts +6 -0
  49. package/vona/src/suite-vendor/a-vona/modules/a-startup/test/devSeed.test.ts +2 -2
  50. package/vona/src/suite-vendor/a-vona/modules/a-telemetry/package.json +1 -1
  51. package/vona/src/suite-vendor/a-vona/modules/a-telemetry/src/bean/bean.telemetry.ts +8 -1
  52. package/vona/src/suite-vendor/a-vona/modules/a-telemetry/src/bean/middlewareSystem.trace.ts +9 -14
  53. package/vona/src/suite-vendor/a-vona/modules/a-telemetry/src/lib/spanDiagnostics.ts +70 -0
  54. package/vona/src/suite-vendor/a-vona/modules/a-telemetry/src/service/telemetry.ts +37 -23
  55. package/vona/src/suite-vendor/a-vona/modules/a-version/package.json +1 -1
  56. package/vona/src/suite-vendor/a-vona/modules/a-version/src/service/database.ts +31 -4
  57. package/vona/src/suite-vendor/a-vona/package.json +2 -2
  58. package/zova/packages-cli/cli/package.json +2 -2
  59. package/zova/packages-cli/cli-set-front/package.json +1 -1
  60. package/zova/packages-cli/cli-set-front/src/lib/bean/cli.bin.buildRest.ts +11 -101
  61. package/zova/packages-cli/cli-set-front/src/lib/common/restDependencies.ts +78 -0
  62. package/zova/packages-cli/cli-set-front/src/lib/common/restDependencyVersions.ts +231 -0
  63. package/zova/packages-cli/cli-set-front/test/restDependencies.test.ts +431 -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 = {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vona-module-a-startup",
3
- "version": "5.1.20",
3
+ "version": "5.1.21",
4
4
  "gitHead": "a79189b882c17af5911573896a781bbb0046d37d",
5
5
  "description": "",
6
6
  "keywords": [
@@ -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,6 +1,6 @@
1
1
  {
2
2
  "name": "vona-module-a-telemetry",
3
- "version": "5.0.2",
3
+ "version": "5.0.3",
4
4
  "description": "",
5
5
  "keywords": [
6
6
  "Vona Module"
@@ -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 finish = () => this._end(span, ctx.res.statusCode || ctx.status || 500, false);
41
- const close = () =>
42
- this._end(span, ctx.res.statusCode || ctx.status || 499, !ctx.res.writableEnded);
43
- ctx.res.once('finish', finish);
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
- telemetry.recordException(span, err);
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 { context, propagation, SpanKind, SpanStatusCode, trace } from '@opentelemetry/api';
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 = this.activeContext): ITelemetryCarrier {
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 = this.activeContext) {
123
- return trace.getTracer('vona-module-a-telemetry').startSpan(name, options, parent);
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 = this.activeContext) {
132
- return trace.setSpan(parent, span);
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 (error instanceof Error) {
137
- span.recordException(error);
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 (aborted || statusCode >= 500) {
146
- span.setStatus({
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}`);