Skip to content

Commit 838e985

Browse files
EmrysMyrddinenisdenjo
authored andcommitted
feat(opentelemetry): add cache events and attributes (#993)
1 parent 62f1ff6 commit 838e985

File tree

5 files changed

+181
-17
lines changed

5 files changed

+181
-17
lines changed

packages/plugins/opentelemetry/src/plugin-utils.ts

Lines changed: 16 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -56,19 +56,23 @@ export function withState<
5656
function addStateGetters(src: any) {
5757
const result: any = {};
5858
for (const [hookName, hook] of Object.entries(src) as any) {
59-
result[hookName] =
60-
typeof hook !== 'function'
61-
? hook
62-
: (payload: any, ...args: any[]) =>
63-
hook(
64-
{
65-
...payload,
66-
get state() {
67-
return getState(payload);
68-
},
59+
if (typeof hook !== 'function') {
60+
result[hookName] = hook;
61+
} else {
62+
result[hookName] = {
63+
[hook.name](payload: any, ...args: any[]) {
64+
return hook(
65+
{
66+
...payload,
67+
get state() {
68+
return getState(payload);
6969
},
70-
...args,
71-
);
70+
},
71+
...args,
72+
);
73+
},
74+
}[hook.name];
75+
}
7276
}
7377
return result;
7478
}

packages/plugins/opentelemetry/src/plugin.ts

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,8 +51,11 @@ import {
5151
createHttpSpan,
5252
startSubgraphExecuteFetchSpan as createSubgraphExecuteFetchSpan,
5353
createUpstreamHttpFetchSpan,
54+
recordCacheError,
55+
recordCacheEvent,
5456
registerException,
5557
setExecutionAttributesOnOperationSpan,
58+
setExecutionResultAttributes,
5659
setGraphQLExecutionAttributes,
5760
setGraphQLExecutionResultAttributes,
5861
setGraphQLParseAttributes,
@@ -205,6 +208,10 @@ export type OpenTelemetryGatewayPluginOptions =
205208
* Enable/disable upstream HTTP fetch calls spans (default: true).
206209
*/
207210
upstreamFetch?: BooleanOrPredicate<ExecutionRequest | undefined>;
211+
/**
212+
* Enable/Disable cache related span events (default: true).
213+
*/
214+
cache?: BooleanOrPredicate<{ key: string; action: 'read' | 'write' }>;
208215
};
209216
};
210217

@@ -677,6 +684,25 @@ export function useOpenTelemetry(
677684
});
678685
},
679686

687+
onCacheGet: (payload) =>
688+
shouldTrace(options.spans?.cache, { key: payload.key, action: 'read' })
689+
? {
690+
onCacheMiss: () => recordCacheEvent('miss', payload),
691+
onCacheHit: () => recordCacheEvent('hit', payload),
692+
onCacheGetError: ({ error }) =>
693+
recordCacheError('read', error, payload),
694+
}
695+
: undefined,
696+
697+
onCacheSet: (payload) =>
698+
shouldTrace(options.spans?.cache, { key: payload.key, action: 'write' })
699+
? {
700+
onCacheSetDone: () => recordCacheEvent('write', payload),
701+
onCacheSetError: ({ error }) =>
702+
recordCacheError('write', error, payload),
703+
}
704+
: undefined,
705+
680706
onResponse({ response, state }) {
681707
try {
682708
state.forRequest.otel &&
@@ -686,7 +712,7 @@ export function useOpenTelemetry(
686712
}
687713
},
688714

689-
onParams({ state, context: gqlCtx, params }) {
715+
onParams: function onParamsOTEL({ state, context: gqlCtx, params }) {
690716
if (
691717
!isParentEnabled(state) ||
692718
!shouldTrace(options.spans?.graphql, gqlCtx)
@@ -698,6 +724,21 @@ export function useOpenTelemetry(
698724
setParamsAttributes({ ctx, params });
699725
},
700726

727+
onExecutionResult: function onExeResOTEL({
728+
result,
729+
context: gqlCtx,
730+
state,
731+
}) {
732+
if (
733+
!isParentEnabled(state) ||
734+
!shouldTrace(options.spans?.graphql, gqlCtx)
735+
) {
736+
return;
737+
}
738+
739+
setExecutionResultAttributes({ ctx: getContext(state), result });
740+
},
741+
701742
onParse({ state, context: gqlCtx }) {
702743
if (
703744
!isParentEnabled(state) ||

packages/plugins/opentelemetry/src/spans.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { OnCacheGetHookEventPayload } from '@graphql-hive/gateway-runtime';
12
import { defaultPrintFn } from '@graphql-mesh/transport-common';
23
import {
34
getOperationASTFromDocument,
@@ -12,6 +13,11 @@ import {
1213
type Context,
1314
type Tracer,
1415
} from '@opentelemetry/api';
16+
import {
17+
SEMATTRS_EXCEPTION_MESSAGE,
18+
SEMATTRS_EXCEPTION_STACKTRACE,
19+
SEMATTRS_EXCEPTION_TYPE,
20+
} from '@opentelemetry/semantic-conventions';
1521
import type { ExecutionArgs } from 'graphql';
1622
import type { GraphQLParams } from 'graphql-yoga';
1723
import {
@@ -78,6 +84,10 @@ export function setResponseAttributes(ctx: Context, response: Response) {
7884
const span = trace.getSpan(ctx);
7985
if (span) {
8086
span.setAttribute(SEMATTRS_HTTP_STATUS_CODE, response.status);
87+
span.setAttribute(
88+
'gateway.cache.response_cache',
89+
response.status === 304 && response.headers.get('ETag') ? 'hit' : 'miss',
90+
);
8191
span.setStatus({
8292
code: response.ok ? SpanStatusCode.OK : SpanStatusCode.ERROR,
8393
message: response.ok ? undefined : response.statusText,
@@ -385,6 +395,46 @@ export function setUpstreamFetchResponseAttributes(input: {
385395
});
386396
}
387397

398+
export function recordCacheEvent(
399+
event: string,
400+
payload: OnCacheGetHookEventPayload,
401+
) {
402+
trace.getActiveSpan()?.addEvent('gateway.cache.' + event, {
403+
'gateway.cache.key': payload.key,
404+
'gateway.cache.ttl': payload.ttl,
405+
});
406+
}
407+
408+
export function recordCacheError(
409+
action: 'read' | 'write',
410+
error: Error,
411+
payload: OnCacheGetHookEventPayload,
412+
) {
413+
trace.getActiveSpan()?.addEvent('gateway.cache.error', {
414+
'gateway.cache.key': payload.key,
415+
'gateway.cache.ttl': payload.ttl,
416+
'gateway.cache.action': action,
417+
[SEMATTRS_EXCEPTION_TYPE]:
418+
'code' in error ? (error.code as string) : error.message,
419+
[SEMATTRS_EXCEPTION_MESSAGE]: error.message,
420+
[SEMATTRS_EXCEPTION_STACKTRACE]: error.stack,
421+
});
422+
}
423+
424+
const responseCacheSymbol = Symbol.for('servedFromResponseCache');
425+
export function setExecutionResultAttributes(input: {
426+
ctx: Context;
427+
result?: any; // We don't need a proper type here because we rely on Symbol mark from response cache plugin
428+
}) {
429+
const span = trace.getSpan(input.ctx);
430+
if (input.result && span) {
431+
span.setAttribute(
432+
'gateway.cache.response_cache',
433+
input.result[responseCacheSymbol] ? 'hit' : 'miss',
434+
);
435+
}
436+
}
437+
388438
export function registerException(ctx: Context | undefined, error: any) {
389439
const span = ctx && trace.getSpan(ctx);
390440
if (!span) {

packages/plugins/opentelemetry/tests/useOpenTelemetry.spec.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -532,5 +532,59 @@ describe('useOpenTelemetry', () => {
532532
children.forEach(spanTree.expectChild);
533533
}
534534
});
535+
536+
it('should have a response cache attribute', async () => {
537+
function checkCacheAttributes(attrs: {
538+
http: 'hit' | 'miss';
539+
operation?: 'hit' | 'miss';
540+
}) {
541+
const { span: httpSpan } = spanExporter.assertRoot('POST /graphql');
542+
const operationSpan = spanExporter.spans.find(({ name }) =>
543+
name.startsWith('graphql.operation'),
544+
);
545+
546+
expect(httpSpan.attributes['gateway.cache.response_cache']).toBe(
547+
attrs.http,
548+
);
549+
if (attrs.operation) {
550+
expect(operationSpan).toBeDefined();
551+
expect(
552+
operationSpan!.attributes['gateway.cache.response_cache'],
553+
).toBe(attrs.operation);
554+
}
555+
}
556+
await using gateway = await buildTestGateway({
557+
gatewayOptions: {
558+
cache: await import('@graphql-mesh/cache-localforage').then(
559+
({ default: Cache }) => new Cache(),
560+
),
561+
responseCaching: {
562+
session: () => '1',
563+
},
564+
},
565+
});
566+
await gateway.query();
567+
568+
checkCacheAttributes({ http: 'miss', operation: 'miss' });
569+
570+
spanExporter.reset();
571+
await gateway.query();
572+
573+
checkCacheAttributes({ http: 'miss', operation: 'hit' });
574+
575+
spanExporter.reset();
576+
const response = await gateway.fetch('http://gateway/graphql', {
577+
method: 'POST',
578+
headers: {
579+
'content-type': 'application/json',
580+
'If-None-Match':
581+
'c2f6fb105ef60ccc99dd6725b55939742e69437d4f85d52bf4664af3799c49fa',
582+
'If-Modified-Since': new Date(),
583+
},
584+
});
585+
expect(response.status).toBe(304);
586+
587+
checkCacheAttributes({ http: 'hit' }); // There is no graphql operation span when cached by HTTP
588+
});
535589
});
536590
});

packages/plugins/opentelemetry/tests/utils.ts

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -81,24 +81,39 @@ export async function buildTestGateway(
8181

8282
return {
8383
otelPlugin: otelPlugin!,
84-
query: async (
85-
body: GraphQLParams = {
84+
query: async ({
85+
shouldReturnErrors,
86+
body = {
8687
query: /* GraphQL */ `
8788
query {
8889
hello
8990
}
9091
`,
9192
},
92-
) => {
93+
}: {
94+
body?: GraphQLParams;
95+
shouldReturnErrors?: boolean;
96+
} = {}) => {
9397
const response = await gateway.fetch('http://localhost:4000/graphql', {
9498
method: 'POST',
9599
headers: {
96100
'content-type': 'application/json',
97101
},
98102
body: JSON.stringify(body),
99103
});
100-
return response.json();
104+
105+
const result = await response.json();
106+
if (shouldReturnErrors) {
107+
expect(result.errors).toBeDefined();
108+
} else {
109+
if (result.errors) {
110+
console.error(result.errors);
111+
}
112+
expect(result.errors).not.toBeDefined();
113+
}
114+
return result;
101115
},
116+
fetch: gateway.fetch,
102117
[Symbol.asyncDispose]: () => {
103118
diag.disable();
104119
return stack.disposeAsync();

0 commit comments

Comments
 (0)