Skip to content

Commit 6faa867

Browse files
committed
[otel] rely on context for parenting spans correctly
1 parent 89c2839 commit 6faa867

File tree

9 files changed

+872
-385
lines changed

9 files changed

+872
-385
lines changed
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@graphql-mesh/plugin-opentelemetry': patch
3+
---
4+
5+
dependencies updates:
6+
7+
- Added dependency [`@opentelemetry/context-async-hooks@^1.30.1` ↗︎](https://www.npmjs.com/package/@opentelemetry/context-async-hooks/v/1.30.1) (to `dependencies`)

e2e/opentelemetry/opentelemetry.e2e.ts

Lines changed: 81 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -24,15 +24,18 @@ beforeAll(async () => {
2424
type JaegerTracesApiResponse = {
2525
data: Array<{
2626
traceID: string;
27-
spans: Array<{
28-
traceID: string;
29-
spanID: string;
30-
operationName: string;
31-
tags: Array<{ key: string; value: string; type: string }>;
32-
}>;
27+
spans: JaegerTraceSpan[];
3328
}>;
3429
};
3530

31+
type JaegerTraceSpan = {
32+
traceID: string;
33+
spanID: string;
34+
operationName: string;
35+
tags: Array<{ key: string; value: string; type: string }>;
36+
references: Array<{ refType: string; spanID: string; traceID: string }>;
37+
};
38+
3639
describe('OpenTelemetry', () => {
3740
(['grpc', 'http'] as const).forEach((OTLP_EXPORTER_TYPE) => {
3841
describe(`exporter > ${OTLP_EXPORTER_TYPE}`, () => {
@@ -77,6 +80,9 @@ describe('OpenTelemetry', () => {
7780
await checkFn(res);
7881
return;
7982
} catch (e) {
83+
if (signal.aborted) {
84+
throw err;
85+
}
8086
err = e;
8187
}
8288
}
@@ -559,40 +565,52 @@ describe('OpenTelemetry', () => {
559565
expect(relevantTraces.length).toBe(1);
560566
const relevantTrace = relevantTraces[0];
561567
expect(relevantTrace).toBeDefined();
562-
expect(relevantTrace?.spans.length).toBe(11);
568+
expect(relevantTrace!.spans.length).toBe(18);
563569

564-
expect(relevantTrace?.spans).toContainEqual(
565-
expect.objectContaining({ operationName: 'POST /graphql' }),
566-
);
567-
expect(relevantTrace?.spans).toContainEqual(
568-
expect.objectContaining({ operationName: 'graphql.parse' }),
569-
);
570-
expect(relevantTrace?.spans).toContainEqual(
571-
expect.objectContaining({ operationName: 'graphql.validate' }),
572-
);
573-
expect(relevantTrace?.spans).toContainEqual(
574-
expect.objectContaining({ operationName: 'graphql.execute' }),
570+
const spanTree = buildSpanTree(relevantTrace!.spans, 'POST /graphql');
571+
expect(spanTree).toBeDefined();
572+
573+
const expectedHttpChildren = [
574+
'graphql.parse',
575+
'graphql.validate',
576+
'graphql.execute',
577+
];
578+
expect(spanTree!.children).toHaveLength(3);
579+
for (const operationName of expectedHttpChildren) {
580+
expect(spanTree?.children).toContainEqual(
581+
expect.objectContaining({
582+
span: expect.objectContaining({ operationName }),
583+
}),
584+
);
585+
}
586+
587+
const executeSpan = spanTree?.children.find(
588+
({ span }) => span.operationName === 'graphql.execute',
575589
);
576-
expect(
577-
relevantTrace?.spans.filter(
578-
(r) => r.operationName === 'subgraph.execute (accounts)',
579-
).length,
580-
).toBe(2);
581-
expect(
582-
relevantTrace?.spans.filter(
583-
(r) => r.operationName === 'subgraph.execute (products)',
584-
).length,
585-
).toBe(2);
586-
expect(
587-
relevantTrace?.spans.filter(
588-
(r) => r.operationName === 'subgraph.execute (inventory)',
589-
).length,
590-
).toBe(1);
591-
expect(
592-
relevantTrace?.spans.filter(
593-
(r) => r.operationName === 'subgraph.execute (reviews)',
594-
).length,
595-
).toBe(2);
590+
591+
const expectedExecuteChildren = [
592+
['subgraph.execute (accounts)', 2],
593+
['subgraph.execute (products)', 2],
594+
['subgraph.execute (inventory)', 1],
595+
['subgraph.execute (reviews)', 2],
596+
] as const;
597+
598+
for (const [operationName, count] of expectedExecuteChildren) {
599+
const matchingChildren = executeSpan!.children.filter(
600+
({ span }) => span.operationName === operationName,
601+
);
602+
expect(matchingChildren).toHaveLength(count);
603+
for (const child of matchingChildren) {
604+
expect(child.children).toHaveLength(1);
605+
expect(child.children).toContainEqual(
606+
expect.objectContaining({
607+
span: expect.objectContaining({
608+
operationName: 'http.fetch',
609+
}),
610+
}),
611+
);
612+
}
613+
}
596614
});
597615
});
598616

@@ -1279,3 +1297,28 @@ describe('OpenTelemetry', () => {
12791297
});
12801298
});
12811299
});
1300+
1301+
type TraceTreeNode = {
1302+
span: JaegerTraceSpan;
1303+
children: TraceTreeNode[];
1304+
};
1305+
function buildSpanTree(
1306+
spans: JaegerTraceSpan[],
1307+
rootName: string,
1308+
): TraceTreeNode | undefined {
1309+
function buildNode(root: JaegerTraceSpan): TraceTreeNode {
1310+
return {
1311+
span: root,
1312+
children: spans
1313+
.filter((span) =>
1314+
span.references.find(
1315+
(ref) => ref.refType === 'CHILD_OF' && ref.spanID === root.spanID,
1316+
),
1317+
)
1318+
.map(buildNode),
1319+
};
1320+
}
1321+
1322+
const root = spans.find((span) => span.operationName === rootName);
1323+
return root && buildNode(root);
1324+
}

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@
6060
"vitest": "^3.0.1"
6161
},
6262
"resolutions": {
63+
"@envelop/core": "5.1.0-alpha-20250206123608-cd323a08c43c066eb34cba698d61b4f059ab5ee5",
6364
"@graphql-tools/delegate": "workspace:^",
6465
"@opentelemetry/exporter-trace-otlp-http": "patch:@opentelemetry/exporter-trace-otlp-http@npm%3A0.56.0#~/.yarn/patches/@opentelemetry-exporter-trace-otlp-http-npm-0.56.0-dddd282e41.patch",
6566
"@opentelemetry/otlp-exporter-base": "patch:@opentelemetry/otlp-exporter-base@npm%3A0.56.0#~/.yarn/patches/@opentelemetry-otlp-exporter-base-npm-0.56.0-ba3dc5f5c5.patch",

packages/plugins/opentelemetry/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@
5050
"@graphql-mesh/utils": "^0.103.6",
5151
"@graphql-tools/utils": "^10.7.0",
5252
"@opentelemetry/api": "^1.9.0",
53+
"@opentelemetry/context-async-hooks": "^1.30.1",
5354
"@opentelemetry/exporter-trace-otlp-grpc": "^0.57.0",
5455
"@opentelemetry/exporter-trace-otlp-http": "^0.57.0",
5556
"@opentelemetry/exporter-zipkin": "^1.29.0",
@@ -62,6 +63,7 @@
6263
"tslib": "^2.8.1"
6364
},
6465
"devDependencies": {
66+
"@whatwg-node/server": "^0.9.65",
6567
"graphql": "^16.9.0",
6668
"graphql-yoga": "^5.10.11",
6769
"pkgroll": "2.8.2"
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { trace, type Context } from '@opentelemetry/api';
2+
3+
type Node = {
4+
ctx: Context;
5+
previous?: Node;
6+
};
7+
8+
export class OtelContextStack {
9+
#root: Node;
10+
#current: Node;
11+
12+
constructor(root: Context) {
13+
this.#root = { ctx: root };
14+
this.#current = this.#root;
15+
}
16+
17+
get current(): Context {
18+
return this.#current.ctx;
19+
}
20+
21+
get root(): Context {
22+
return this.#root.ctx;
23+
}
24+
25+
push = (ctx: Context) => {
26+
this.#current = { ctx, previous: this.#current };
27+
};
28+
29+
pop = () => {
30+
this.#current = this.#current.previous ?? this.#root;
31+
};
32+
33+
toString() {
34+
let node: Node | undefined = this.#current;
35+
const names = [];
36+
while (node != undefined) {
37+
names.push((trace.getSpan(node.ctx) as unknown as { name: string }).name);
38+
node = node.previous;
39+
}
40+
return names.join(' -> ');
41+
}
42+
}
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import type { ExecutionRequest } from '@graphql-tools/utils';
2+
3+
export function withState<
4+
P,
5+
GraphqlState = object,
6+
HttpState = object,
7+
SubExecState = object,
8+
>(plugin: WithState<P, HttpState, GraphqlState, SubExecState>): P {
9+
const states: {
10+
forRequest?: WeakMap<Request, Partial<HttpState>>;
11+
forOperation?: WeakMap<any, Partial<GraphqlState>>;
12+
forSubgraphExecution?: WeakMap<ExecutionRequest, Partial<SubExecState>>;
13+
} = {};
14+
15+
function getProp(scope: keyof typeof states, key: any): PropertyDescriptor {
16+
return {
17+
get() {
18+
if (!states[scope]) states[scope] = new WeakMap<any, any>();
19+
let value = states[scope].get(key as any);
20+
if (!value) states[scope].set(key, (value = {}));
21+
return value;
22+
},
23+
enumerable: true,
24+
};
25+
}
26+
27+
const pluginWithState: Record<string, (payload: any) => unknown> = {};
28+
for (const [hookName, hook] of Object.entries(plugin) as any) {
29+
pluginWithState[hookName] = (payload) =>
30+
hook({
31+
...payload,
32+
get state() {
33+
let { executionRequest, context, request } = payload;
34+
35+
const state = {};
36+
const defineState = (scope: keyof typeof states, key: any) =>
37+
Object.defineProperty(state, scope, getProp(scope, key));
38+
39+
if (executionRequest) {
40+
defineState('forSubgraphExecution', executionRequest);
41+
if (executionRequest.context) context = executionRequest.context;
42+
}
43+
if (context) {
44+
defineState('forOperation', context);
45+
if (context.request) request = context.request;
46+
}
47+
if (request) {
48+
defineState('forRequest', request);
49+
}
50+
return state;
51+
},
52+
});
53+
}
54+
55+
return pluginWithState as P;
56+
}
57+
58+
export type HttpState<T> = {
59+
forRequest: Partial<T>;
60+
};
61+
62+
export type GraphQLState<T> = {
63+
forOperation: Partial<T>;
64+
};
65+
66+
export type GatewayState<T> = {
67+
forSubgraphExecution: Partial<T>;
68+
};
69+
70+
export function getMostSpecificState<T>(
71+
state: Partial<HttpState<T> & GraphQLState<T> & GatewayState<T>> = {},
72+
): Partial<T> | undefined {
73+
const { forOperation, forRequest, forSubgraphExecution } = state;
74+
return forSubgraphExecution ?? forOperation ?? forRequest;
75+
}
76+
77+
// Brace yourself! TS Wizardry is coming!
78+
79+
type PayloadWithState<T, Http, GraphQL, Gateway> = T extends {
80+
executionRequest: any;
81+
}
82+
? T & {
83+
state: Partial<HttpState<Http> & GraphQLState<GraphQL>> &
84+
GatewayState<Gateway>;
85+
}
86+
: T extends {
87+
executionRequest?: any;
88+
}
89+
? T & {
90+
state: Partial<
91+
HttpState<Http> & GraphQLState<GraphQL> & GatewayState<Gateway>
92+
>;
93+
}
94+
: T extends { context: any }
95+
? T & { state: HttpState<Http> & GraphQLState<GraphQL> }
96+
: T extends { request: any }
97+
? T & { state: HttpState<Http> }
98+
: T;
99+
100+
type WithState<P, Http = object, GraphQL = object, Gateway = object> = {
101+
[K in keyof P]: P[K] extends ((payload: infer T) => infer R) | undefined
102+
? (payload: PayloadWithState<T, Http, GraphQL, Gateway>) => R | undefined
103+
: P[K];
104+
};

0 commit comments

Comments
 (0)