Skip to content

Commit 094ca85

Browse files
ardatangithub-actions[bot]enisdenjo
authored
Platform Agnostic OTEL plugin (graphql-hive#138)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> Co-authored-by: enisdenjo <[email protected]>
1 parent d2dccda commit 094ca85

36 files changed

+1270
-1877
lines changed
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@graphql-mesh/plugin-opentelemetry': patch
3+
---
4+
5+
dependencies updates:
6+
7+
- Added dependency [`@graphql-hive/gateway-runtime@workspace:^` ↗︎](https://www.npmjs.com/package/@graphql-hive/gateway-runtime/v/workspace:^) (to `dependencies`)
8+
- Added dependency [`@opentelemetry/sdk-trace-web@^1.27.0` ↗︎](https://www.npmjs.com/package/@opentelemetry/sdk-trace-web/v/1.27.0) (to `dependencies`)
9+
- Added dependency [`@whatwg-node/disposablestack@^0.0.5` ↗︎](https://www.npmjs.com/package/@whatwg-node/disposablestack/v/0.0.5) (to `dependencies`)
10+
- Removed dependency [`@graphql-hive/gateway@^1.5.1` ↗︎](https://www.npmjs.com/package/@graphql-hive/gateway/v/1.5.1) (from `dependencies`)
11+
- Removed dependency [`@opentelemetry/auto-instrumentations-node@^0.53.0` ↗︎](https://www.npmjs.com/package/@opentelemetry/auto-instrumentations-node/v/0.53.0) (from `dependencies`)
12+
- Removed dependency [`@opentelemetry/context-async-hooks@^1.25.1` ↗︎](https://www.npmjs.com/package/@opentelemetry/context-async-hooks/v/1.25.1) (from `dependencies`)
13+
- Removed dependency [`@opentelemetry/sdk-node@^0.52.1` ↗︎](https://www.npmjs.com/package/@opentelemetry/sdk-node/v/0.52.1) (from `dependencies`)
14+
- Removed dependency [`@opentelemetry/sdk-trace-node@^1.25.1` ↗︎](https://www.npmjs.com/package/@opentelemetry/sdk-trace-node/v/1.25.1) (from `dependencies`)
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
'@graphql-mesh/plugin-prometheus': patch
3+
---
4+
5+
dependencies updates:
6+
7+
- Added dependency [`@graphql-hive/gateway-runtime@workspace:^` ↗︎](https://www.npmjs.com/package/@graphql-hive/gateway-runtime/v/workspace:^) (to `dependencies`)
8+
- Added dependency [`@whatwg-node/disposablestack@^0.0.5` ↗︎](https://www.npmjs.com/package/@whatwg-node/disposablestack/v/0.0.5) (to `dependencies`)
9+
- Removed dependency [`@graphql-hive/gateway@workspace:^` ↗︎](https://www.npmjs.com/package/@graphql-hive/gateway/v/workspace:^) (from `dependencies`)

.github/workflows/test.yml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ jobs:
3434
matrix:
3535
node-version:
3636
- 18
37+
- 20
38+
- 22
3739
name: Leaks / Node v${{matrix.node-version}}
3840
runs-on: ubuntu-latest
3941
steps:
@@ -134,4 +136,4 @@ jobs:
134136
- name: Test
135137
env:
136138
E2E_GATEWAY_RUNNER: ${{matrix.setup.gateway-runner}}
137-
run: yarn e2e:test
139+
run: yarn test:e2e

.prettierignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,4 @@ __generated__
55
.changeset/*
66
!.changeset/README.md
77
!.changeset/config.json
8+
.wrangler/
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
diff --git a/build/index.js b/build/index.js
2+
index a8ccb1e..70699fd 100644
3+
--- a/build/index.js
4+
+++ b/build/index.js
5+
@@ -74,26 +74,14 @@ class LeakDetector {
6+
value = null;
7+
}
8+
async isLeaking() {
9+
- this._runGarbageCollector();
10+
+ (0, _v().setFlagsFromString)('--allow-natives-syntax');
11+
12+
// wait some ticks to allow GC to run properly, see https://github.com/nodejs/node/issues/34636#issuecomment-669366235
13+
for (let i = 0; i < 10; i++) {
14+
+ eval('%CollectGarbage(true)');
15+
await tick();
16+
}
17+
return this._isReferenceBeingHeld;
18+
}
19+
- _runGarbageCollector() {
20+
- // @ts-expect-error: not a function on `globalThis`
21+
- const isGarbageCollectorHidden = globalThis.gc == null;
22+
-
23+
- // GC is usually hidden, so we have to expose it before running.
24+
- (0, _v().setFlagsFromString)('--expose-gc');
25+
- (0, _vm().runInNewContext)('gc')();
26+
-
27+
- // The GC was not initially exposed, so let's hide it again.
28+
- if (isGarbageCollectorHidden) {
29+
- (0, _v().setFlagsFromString)('--no-expose-gc');
30+
- }
31+
- }
32+
}
33+
exports.default = LeakDetector;

bench/federation/apollo.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,12 @@ export default new ApolloGateway({
1616
name,
1717
typeDefs,
1818
})),
19+
logger: {
20+
debug: () => {},
21+
info: () => {},
22+
warn: () => {},
23+
error: () => {},
24+
},
1925
buildService: ({ name }) => {
2026
const serviceName = name as keyof typeof serviceMap;
2127
return new LocalGraphQLDataSource(serviceMap[serviceName].schema);

e2e/auto-type-merging/auto-type-merging.bench.ts

Lines changed: 0 additions & 46 deletions
This file was deleted.

e2e/cloudflare-workers/.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
.wrangler
Lines changed: 256 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,256 @@
1+
import os from 'os';
2+
import { setTimeout } from 'timers/promises';
3+
import { createTenv, getAvailablePort, type Container } from '@internal/e2e';
4+
import { getLocalhost, isDebug } from '@internal/testing';
5+
import { fetch } from '@whatwg-node/fetch';
6+
import { ExecutionResult } from 'graphql';
7+
import { beforeAll, describe, expect, it } from 'vitest';
8+
9+
const { spawn, container, gatewayRunner } = createTenv(__dirname);
10+
11+
describe.skipIf(gatewayRunner !== 'node')('Cloudflare Workers', () => {
12+
let jaeger: Container;
13+
let jaegerHostname: string;
14+
15+
const TEST_QUERY = /* GraphQL */ `
16+
query TestQuery {
17+
language(code: "en") {
18+
name
19+
}
20+
}
21+
`;
22+
23+
beforeAll(async () => {
24+
jaeger = await container({
25+
name: 'jaeger',
26+
image:
27+
os.platform().toLowerCase() === 'win32'
28+
? 'johnnyhuy/jaeger-windows:1809'
29+
: 'jaegertracing/all-in-one:1.56',
30+
env: {
31+
COLLECTOR_OTLP_ENABLED: 'true',
32+
},
33+
containerPort: 4318,
34+
additionalContainerPorts: [16686],
35+
healthcheck: ['CMD-SHELL', 'wget --spider http://0.0.0.0:14269'],
36+
});
37+
jaegerHostname = await getLocalhost(jaeger.port);
38+
});
39+
40+
type JaegerTracesApiResponse = {
41+
data: Array<{
42+
traceID: string;
43+
spans: Array<{
44+
traceID: string;
45+
spanID: string;
46+
operationName: string;
47+
tags: Array<{ key: string; value: string; type: string }>;
48+
}>;
49+
}>;
50+
};
51+
52+
async function getJaegerTraces(
53+
service: string,
54+
expectedDataLength: number,
55+
): Promise<JaegerTracesApiResponse> {
56+
const port = jaeger.additionalPorts[16686]!;
57+
const hostname = await getLocalhost(port);
58+
const url = `${hostname}:${jaeger.additionalPorts[16686]}/api/traces?service=${service}`;
59+
60+
let res!: JaegerTracesApiResponse;
61+
for (let i = 0; i < 25; i++) {
62+
res = await fetch(url).then((r) => r.json());
63+
if (res.data.length >= expectedDataLength) {
64+
break;
65+
}
66+
await setTimeout(300);
67+
}
68+
69+
return res;
70+
}
71+
72+
async function wrangler(env: {
73+
OTLP_EXPORTER_URL: string;
74+
OTLP_SERVICE_NAME: string;
75+
}) {
76+
const port = await getAvailablePort();
77+
await spawn('yarn wrangler', {
78+
args: [
79+
'dev',
80+
'--port',
81+
port.toString(),
82+
'--var',
83+
'OTLP_EXPORTER_URL:' + env.OTLP_EXPORTER_URL,
84+
'--var',
85+
'OTLP_SERVICE_NAME:' + env.OTLP_SERVICE_NAME,
86+
...(isDebug() ? ['--var', 'DEBUG:1'] : []),
87+
],
88+
});
89+
const hostname = await getLocalhost(port);
90+
return {
91+
url: `${hostname}:${port}`,
92+
async execute({
93+
query,
94+
headers,
95+
}: {
96+
query: string;
97+
headers?: HeadersInit;
98+
}): Promise<ExecutionResult> {
99+
const r = await fetch(`${hostname}:${port}/graphql`, {
100+
method: 'POST',
101+
headers: {
102+
'content-type': 'application/json',
103+
...headers,
104+
},
105+
body: JSON.stringify({ query }),
106+
});
107+
return r.json();
108+
},
109+
};
110+
}
111+
112+
it('should report telemetry metrics correctly to jaeger', async () => {
113+
const serviceName = 'mesh-e2e-test-1';
114+
const { execute } = await wrangler({
115+
OTLP_EXPORTER_URL: `${jaegerHostname}:${jaeger.port}/v1/traces`,
116+
OTLP_SERVICE_NAME: serviceName,
117+
});
118+
119+
await expect(execute({ query: TEST_QUERY })).resolves
120+
.toMatchInlineSnapshot(`
121+
{
122+
"data": {
123+
"language": {
124+
"name": "English",
125+
},
126+
},
127+
}
128+
`);
129+
130+
const traces = await getJaegerTraces(serviceName, 2);
131+
expect(traces.data.length).toBe(2);
132+
const relevantTraces = traces.data.filter((trace) =>
133+
trace.spans.some((span) => span.operationName === 'POST /graphql'),
134+
);
135+
expect(relevantTraces.length).toBe(1);
136+
const relevantTrace = relevantTraces[0];
137+
expect(relevantTrace).toBeDefined();
138+
expect(relevantTrace?.spans.length).toBe(5);
139+
140+
expect(relevantTrace?.spans).toContainEqual(
141+
expect.objectContaining({ operationName: 'POST /graphql' }),
142+
);
143+
expect(relevantTrace?.spans).toContainEqual(
144+
expect.objectContaining({ operationName: 'graphql.parse' }),
145+
);
146+
expect(relevantTrace?.spans).toContainEqual(
147+
expect.objectContaining({ operationName: 'graphql.validate' }),
148+
);
149+
expect(relevantTrace?.spans).toContainEqual(
150+
expect.objectContaining({ operationName: 'graphql.execute' }),
151+
);
152+
expect(
153+
relevantTrace?.spans.filter((r) =>
154+
r.operationName.includes('subgraph.execute'),
155+
).length,
156+
).toBe(1);
157+
});
158+
159+
it('should report http failures', async () => {
160+
const serviceName = 'mesh-e2e-test-4';
161+
const { url } = await wrangler({
162+
OTLP_EXPORTER_URL: `${jaegerHostname}:${jaeger.port}/v1/traces`,
163+
OTLP_SERVICE_NAME: serviceName,
164+
});
165+
166+
await fetch(`${url}/non-existing`).catch(() => {});
167+
const traces = await getJaegerTraces(serviceName, 2);
168+
expect(traces.data.length).toBe(2);
169+
const relevantTrace = traces.data.find((trace) =>
170+
trace.spans.some((span) => span.operationName === 'GET /non-existing'),
171+
);
172+
expect(relevantTrace).toBeDefined();
173+
expect(relevantTrace?.spans.length).toBe(1);
174+
175+
expect(relevantTrace?.spans).toContainEqual(
176+
expect.objectContaining({
177+
operationName: 'GET /non-existing',
178+
tags: expect.arrayContaining([
179+
expect.objectContaining({
180+
key: 'otel.status_code',
181+
value: 'ERROR',
182+
}),
183+
expect.objectContaining({
184+
key: 'error',
185+
value: true,
186+
}),
187+
expect.objectContaining({
188+
key: 'http.status_code',
189+
value: 404,
190+
}),
191+
]),
192+
}),
193+
);
194+
});
195+
196+
it('context propagation should work correctly', async () => {
197+
const traceId = '0af7651916cd43dd8448eb211c80319c';
198+
const serviceName = 'mesh-e2e-test-5';
199+
const { url, execute } = await wrangler({
200+
OTLP_EXPORTER_URL: `${jaegerHostname}:${jaeger.port}/v1/traces`,
201+
OTLP_SERVICE_NAME: serviceName,
202+
});
203+
204+
await expect(
205+
execute({
206+
query: TEST_QUERY,
207+
headers: {
208+
traceparent: `00-${traceId}-b7ad6b7169203331-01`,
209+
},
210+
}),
211+
).resolves.toMatchInlineSnapshot(`
212+
{
213+
"data": {
214+
"language": {
215+
"name": "English",
216+
},
217+
},
218+
}
219+
`);
220+
221+
const upstreamHttpCalls = await fetch(`${url}/upstream-fetch`).then(
222+
(r) =>
223+
r.json() as unknown as Array<{
224+
url: string;
225+
headers?: Record<string, string>;
226+
}>,
227+
);
228+
229+
const traces = await getJaegerTraces(serviceName, 3);
230+
expect(traces.data.length).toBe(3);
231+
232+
const relevantTraces = traces.data.filter((trace) =>
233+
trace.spans.some((span) => span.operationName === 'POST /graphql'),
234+
);
235+
expect(relevantTraces.length).toBe(1);
236+
const relevantTrace = relevantTraces[0]!;
237+
expect(relevantTrace).toBeDefined();
238+
239+
// Check for extraction of the otel context
240+
expect(relevantTrace.traceID).toBe(traceId);
241+
for (const span of relevantTrace.spans) {
242+
expect(span.traceID).toBe(traceId);
243+
}
244+
245+
expect(upstreamHttpCalls.length).toBe(2);
246+
247+
for (const call of upstreamHttpCalls) {
248+
if (call.headers?.['x-request-id']) {
249+
const transparentHeader = (call.headers || {})['traceparent'];
250+
expect(transparentHeader).toBeDefined();
251+
expect(transparentHeader?.length).toBeGreaterThan(1);
252+
expect(transparentHeader).toContain(traceId);
253+
}
254+
}
255+
});
256+
});

0 commit comments

Comments
 (0)