Skip to content

Commit 865bf31

Browse files
committed
feat(otel): Trace decorator accepts scope
- symbol can be symbol demo: - test/decorator/112.docorator-scope.test.ts - test/fixtures/base-app/src/trace.decorator/112a.decorator-scope.controller.ts
1 parent 1bed479 commit 865bf31

23 files changed

+1340
-185
lines changed

packages/otel/README.md

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,45 @@ export class FooController {
100100
}
101101
```
102102

103+
Pass `scope` to avoid the confusion of call chain relationship when async methods are called concurrently
104+
105+
```ts
106+
import { Trace } from '@mwcp/otel'
107+
108+
@Controller('/')
109+
export class FooController {
110+
111+
@Trace()
112+
async hello(): Promise<string> {
113+
await Promise.all([
114+
this._simple1(),
115+
this._simple2(),
116+
])
117+
return 'OK'
118+
}
119+
120+
@Trace({ scope: 'hello1' })
121+
async _hello1(): Promise<string> {
122+
return 'world'
123+
}
124+
125+
@Trace({ scope: 'hello2' })
126+
async _hello2(): Promise<string> {
127+
return 'world'
128+
}
129+
130+
@Trace({ scope: 'hello1' })
131+
async _hello1a(): Promise<string> {
132+
return 'world'
133+
}
134+
135+
@Trace({ scope: 'hello2' })
136+
async _hello2a(): Promise<string> {
137+
return 'world'
138+
}
139+
}
140+
```
141+
103142
## `TraceInit` Decorator
104143

105144
```ts

packages/otel/README.zh-CN.md

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,45 @@ export class FooController {
100100
}
101101
```
102102

103+
使用 `scope` 避免异步方法并发请求时调用链混乱
104+
105+
```ts
106+
import { Trace } from '@mwcp/otel'
107+
108+
@Controller('/')
109+
export class FooController {
110+
111+
@Trace()
112+
async hello(): Promise<string> {
113+
await Promise.all([
114+
this._simple1(),
115+
this._simple2(),
116+
])
117+
return 'OK'
118+
}
119+
120+
@Trace({ scope: 'hello1' })
121+
async _hello1(): Promise<string> {
122+
return 'world'
123+
}
124+
125+
@Trace({ scope: 'hello2' })
126+
async _hello2(): Promise<string> {
127+
return 'world'
128+
}
129+
130+
@Trace({ scope: 'hello1' })
131+
async _hello1a(): Promise<string> {
132+
return 'world'
133+
}
134+
135+
@Trace({ scope: 'hello2' })
136+
async _hello2a(): Promise<string> {
137+
return 'world'
138+
}
139+
}
140+
```
141+
103142
## `TraceInit` 装饰器
104143

105144
```ts

packages/otel/src/lib/abstract.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,7 @@ export abstract class AbstractTraceService {
140140
name: string,
141141
options?: SpanOptions,
142142
traceContext?: Context,
143+
scope?: object | symbol,
143144
): Span
144145

145146
/**
@@ -161,6 +162,7 @@ export abstract class AbstractTraceService {
161162
callback: F,
162163
options?: SpanOptions,
163164
traceContext?: Context,
165+
scope?: object | symbol,
164166
): ReturnType<F>
165167

166168
/**
@@ -231,7 +233,7 @@ export interface StartScopeActiveSpanOptions {
231233
/**
232234
* @default scope is `this.ctx`
233235
*/
234-
scope?: object
236+
scope?: object | symbol | undefined
235237
spanOptions?: SpanOptions | undefined
236238
traceContext?: Context | undefined
237239
}

packages/otel/src/lib/component.ts

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,9 @@ export class OtelComponent extends AbstractOtelComponent {
8484
otelLibraryVersion: string
8585
/* request|response -> Map<lower,norm> */
8686
readonly captureHeadersMap = new Map<string, Map<string, string>>()
87-
readonly traceContextMap = new WeakMap<object, Context[]>()
87+
// ref: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/WeakMap
88+
// @ts-expect-error non-registered symbols can be used as keys
89+
readonly traceContextMap = new WeakMap<object | symbol, Context[]>()
8890

8991
protected traceProvider: node.NodeTracerProvider | undefined
9092
protected spanProcessors: node.SpanProcessor[] = []
@@ -410,17 +412,19 @@ export class OtelComponent extends AbstractOtelComponent {
410412
}
411413
}
412414

413-
getScopeActiveContext(scope: object): Context | undefined {
415+
getScopeActiveContext(scope: object | symbol): Context | undefined {
414416
if (! this.config.enable) { return }
415417

416-
assert(typeof scope === 'object', 'scope must be an object')
418+
const tp = typeof scope
419+
assert(tp === 'object' || tp === 'symbol', 'scope must be an object or symbol')
420+
417421
const arr = this.traceContextMap.get(scope)
418422
if (arr?.length) {
419423
return this.getActiveContextFromArray(arr)
420424
}
421425
}
422426

423-
setScopeActiveContext(scope: object, ctx: Context): void {
427+
setScopeActiveContext(scope: object | symbol, ctx: Context): void {
424428
if (! this.config.enable) { return }
425429

426430
const currCtx = this.getScopeActiveContext(scope)
@@ -434,10 +438,12 @@ export class OtelComponent extends AbstractOtelComponent {
434438
this.traceContextMap.set(scope, [ctx])
435439
}
436440

437-
delScopeActiveContext(scope: object): void {
441+
delScopeActiveContext(scope: object | symbol): void {
438442
if (! this.config.enable) { return }
439443

440-
assert(typeof scope === 'object', 'scope must be an object')
444+
const tp = typeof scope
445+
assert(tp === 'object' || tp === 'symbol', 'scope must be an object or symbol')
446+
441447
const arr = this.traceContextMap.get(scope)
442448
if (arr) {
443449
arr.length = 0

packages/otel/src/lib/decorator.helper.ts

Lines changed: 84 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,16 @@ import { isPromise } from 'node:util/types'
44
import type { Span } from '@opentelemetry/api'
55

66
import type { AbstractOtelComponent, AbstractTraceService } from './abstract.js'
7-
import type { DecoratorContext, DecoratorTraceDataResp, TraceDecoratorOptions } from './decorator.types.js'
7+
import type {
8+
DecoratorContext,
9+
DecoratorContextBase,
10+
DecoratorTraceDataResp,
11+
ScopeGenerator,
12+
TraceDecoratorOptions,
13+
} from './decorator.types.js'
814
import { DecoratorExecutorParam } from './trace.helper.js'
915
import { AttrNames } from './types.js'
16+
import { isSpanEnded } from './util.js'
1017

1118

1219
export async function processDecoratorBeforeAfterAsync(
@@ -20,19 +27,15 @@ export async function processDecoratorBeforeAfterAsync(
2027

2128
const func = mergedDecoratorParam?.[type]
2229
if (typeof func === 'function') {
23-
// @ts-expect-error
24-
if (typeof span.ended === 'function') {
25-
// @ts-expect-error
26-
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
27-
assert(! span.ended(), 'span is ended after method call')
28-
}
30+
assert(! isSpanEnded(span), 'span is ended after method call')
2931
const decoratorContext: DecoratorContext = {
3032
webApp: options.webApp,
3133
webContext: options.webContext,
3234
otelComponent: options.otelComponent,
3335
traceService: options.traceService,
3436
traceContext: options.traceContext,
3537
traceSpan: span,
38+
// instance: options.instance,
3639
}
3740

3841
let data
@@ -67,12 +70,7 @@ export function processDecoratorBeforeAfterSync(
6770

6871
const func = mergedDecoratorParam?.[type]
6972
if (typeof func === 'function') {
70-
// @ts-expect-error
71-
if (typeof span.ended === 'function') {
72-
// @ts-expect-error
73-
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
74-
assert(! span.ended(), 'span is ended after method call')
75-
}
73+
assert(! isSpanEnded(span), 'span is ended after method call')
7674
const decoratorContext: DecoratorContext = {
7775
webApp: options.webApp,
7876
webContext: options.webContext,
@@ -150,3 +148,76 @@ function processDecoratorSpanData(
150148
}
151149
}
152150
}
151+
152+
export function genTraceScopeFrom(options: DecoratorExecutorParam): object | symbol | undefined {
153+
const { mergedDecoratorParam } = options
154+
155+
let scope
156+
if (mergedDecoratorParam?.scope) {
157+
const decoratorContextBase: DecoratorContextBase = {
158+
webApp: options.webApp,
159+
webContext: options.webContext,
160+
otelComponent: options.otelComponent,
161+
traceService: options.traceService,
162+
// instance: options.instance,
163+
}
164+
scope = genTraceScope({
165+
scope: mergedDecoratorParam.scope,
166+
methodArgs: options.methodArgs,
167+
decoratorContext: decoratorContextBase,
168+
})
169+
}
170+
return scope
171+
}
172+
173+
const traceScopeStringCache = new Map<string, symbol>()
174+
function getScopeStringCache(key: string): symbol {
175+
let sym = traceScopeStringCache.get(key)
176+
if (! sym) {
177+
sym = Symbol(key)
178+
setScopeStringCache(key, sym)
179+
}
180+
return sym
181+
}
182+
function setScopeStringCache(key: string, sym: symbol): void {
183+
/* c8 ignore next 3 */
184+
if (traceScopeStringCache.size > 10000) {
185+
console.warn('traceScopeStringCache.size > 10000, should clear it')
186+
}
187+
traceScopeStringCache.set(key, sym)
188+
}
189+
190+
export interface GenTraceScopeOptions {
191+
scope: TraceDecoratorOptions['scope']
192+
methodArgs: unknown[]
193+
decoratorContext: DecoratorContextBase
194+
}
195+
export function genTraceScope(options: GenTraceScopeOptions): object | symbol | undefined {
196+
const { scope } = options
197+
switch (typeof scope) {
198+
case 'string':
199+
// Symbol.for() invalid as key of WeakMap
200+
return getScopeStringCache(scope)
201+
202+
case 'object':
203+
return scope
204+
205+
case 'undefined':
206+
return
207+
208+
case 'function': {
209+
const res = (scope as ScopeGenerator)(options.methodArgs, options.decoratorContext)
210+
assert(typeof res === 'object' || typeof res === 'symbol', 'scope function must return an object or a symbol')
211+
return res
212+
}
213+
214+
case 'symbol':
215+
return scope
216+
217+
/* c8 ignore next 2 */
218+
default:
219+
throw new Error('scope must be a string, an object, a symbol, or a function')
220+
}
221+
222+
}
223+

packages/otel/src/lib/decorator.trace/trace.helper.async.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import assert from 'node:assert'
22

3-
import { processDecoratorBeforeAfterAsync } from '../decorator.helper.js'
3+
import { processDecoratorBeforeAfterAsync, genTraceScopeFrom } from '../decorator.helper.js'
44
import type { DecoratorExecutorParam } from '../trace.helper.js'
55
import { ConfigKey } from '../types.js'
66

@@ -18,6 +18,7 @@ export async function beforeAsync(options: DecoratorExecutorParam): Promise<void
1818
if (! traceService) { return }
1919

2020
const type = 'before'
21+
const scope = genTraceScopeFrom(options)
2122

2223
if (startActiveSpan) {
2324
// await traceService.startActiveSpan(
@@ -30,15 +31,15 @@ export async function beforeAsync(options: DecoratorExecutorParam): Promise<void
3031
// spanOptions,
3132
// traceContext,
3233
// )
33-
options.span = traceService.startScopeActiveSpan({ name: spanName, spanOptions, traceContext })
34+
options.span = traceService.startScopeActiveSpan({ name: spanName, spanOptions, traceContext, scope })
3435
options.span.setAttributes(callerAttr)
3536
return processDecoratorBeforeAfterAsync(type, options)
3637
}
3738
else {
3839
// it's necessary to cost a little time to prevent next span.startTime is same as previous span.endTime
3940
const rndStr = Math.random().toString(36).substring(7)
4041
void rndStr
41-
options.span = traceService.startSpan(spanName, spanOptions, traceContext)
42+
options.span = traceService.startSpan(spanName, spanOptions, traceContext, scope)
4243
options.span.setAttributes(callerAttr)
4344
return processDecoratorBeforeAfterAsync(type, options)
4445
}

packages/otel/src/lib/decorator.trace/trace.helper.sync.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { isAsyncFunction } from 'util/types'
33

44
import { ConfigKey } from '@mwcp/share'
55

6-
import { processDecoratorBeforeAfterSync } from '../decorator.helper.js'
6+
import { genTraceScopeFrom, processDecoratorBeforeAfterSync } from '../decorator.helper.js'
77
import type { DecoratorExecutorParam } from '../trace.helper.js'
88
import { AttrNames } from '../types.js'
99

@@ -28,6 +28,7 @@ export function beforeSync(options: DecoratorExecutorParam): void {
2828
`[@mwcp/${ConfigKey.namespace}] Trace() ${type}() is a AsyncFunction, but decorated method is sync function, class: ${callerAttr[AttrNames.CallerClass]}, method: ${callerAttr[AttrNames.CallerMethod]}`,
2929
)
3030
assert(spanName, 'spanName is empty')
31+
const scope = genTraceScopeFrom(options)
3132

3233
if (startActiveSpan) {
3334
// traceService.startActiveSpan(
@@ -40,15 +41,15 @@ export function beforeSync(options: DecoratorExecutorParam): void {
4041
// spanOptions,
4142
// traceContext,
4243
// )
43-
options.span = traceService.startScopeActiveSpan({ name: spanName, spanOptions, traceContext })
44+
options.span = traceService.startScopeActiveSpan({ name: spanName, spanOptions, traceContext, scope })
4445
options.span.setAttributes(callerAttr)
4546
processDecoratorBeforeAfterSync(type, options)
4647
}
4748
else {
4849
// it's necessary to cost a little time to prevent next span.startTime is same as previous span.endTime
4950
const rndStr = Math.random().toString(36).substring(7)
5051
void rndStr
51-
options.span = traceService.startSpan(spanName, spanOptions, traceContext)
52+
options.span = traceService.startSpan(spanName, spanOptions, traceContext, scope)
5253
options.span.setAttributes(callerAttr)
5354
processDecoratorBeforeAfterSync(type, options)
5455
}

0 commit comments

Comments
 (0)