Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .oxlintrc.base.json
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@
"**/integrations/fs/vendored/**/*.ts",
"**/integrations/tracing/knex/vendored/**/*.ts",
"**/integrations/tracing/mongo/vendored/**/*.ts",
"**/integrations/tracing/graphql/vendored/**/*.ts",
"**/integrations/tracing/koa/vendored/**/*.ts",
"**/integrations/tracing/mysql2/vendored/**/*.ts",
"**/integration/aws/vendored/**/*.ts",
Expand All @@ -154,7 +155,6 @@
"**/integrations/tracing/mongoose/vendored/**/*.ts",
"**/integrations/tracing/amqplib/vendored/**/*.ts",
"**/integrations/tracing/prisma/vendored/**/*.ts",
"**/integrations/tracing/graphql/vendored/**/*.ts",
"**/integrations/tracing/postgres/vendored/**/*.ts",
"**/integrations/tracing/fastify/vendored/**/*.ts"
],
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import * as Sentry from '@sentry/node';
import { loggingTransport } from '@sentry-internal/node-integration-tests';

Sentry.init({
dsn: 'https://public@dsn.ingest.sentry.io/1337',
release: '1.0',
tracesSampleRate: 1.0,
integrations: [Sentry.graphqlIntegration({ ignoreResolveSpans: false })],
transport: loggingTransport,
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import * as Sentry from '@sentry/node';

async function run() {
const { createApolloServer } = await import('../../apollo-server.mjs');
const server = createApolloServer();

await Sentry.startSpan(
{
name: 'Test Transaction',
op: 'transaction',
},
async span => {
// Ref: https://www.apollographql.com/docs/apollo-server/testing/testing/#testing-using-executeoperation
await server.executeOperation({
query: '{hello}',
});

setTimeout(() => {
span.end();
server.stop();
}, 500);
},
);
}

run();
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { afterAll, describe, expect } from 'vitest';
import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../../utils/runner';

// Server start transaction (Apollo Server v5 no longer runs introspection query on start)
const EXPECTED_START_SERVER_TRANSACTION = {
transaction: 'Test Server Start',
};

describe('GraphQL/Apollo Tests > resolve spans', () => {
afterAll(() => {
cleanupChildProcesses();
});

// With `ignoreResolveSpans: false`, the instrumentation emits a span for the execute step as well as
// for `parse`, `validate` and each (non-trivial) field resolver.
const EXPECTED_TRANSACTION = {
// `useOperationNameForRootSpan` defaults to true, so the root span name gets the operation appended.
transaction: 'Test Transaction (query)',
spans: expect.arrayContaining([
expect.objectContaining({
description: 'query',
origin: 'auto.graphql.otel.graphql',
data: expect.objectContaining({
'graphql.operation.type': 'query',
'graphql.source': '{hello}',
'sentry.origin': 'auto.graphql.otel.graphql',
}),
}),
expect.objectContaining({ description: 'graphql.parse' }),
expect.objectContaining({ description: 'graphql.validate' }),
expect.objectContaining({
description: 'graphql.resolve hello',
data: expect.objectContaining({
'graphql.field.name': 'hello',
'graphql.field.path': 'hello',
'graphql.field.type': 'String',
'graphql.parent.name': 'Query',
}),
}),
]),
};

createEsmAndCjsTests(__dirname, 'scenario-query.mjs', 'instrument.mjs', (createTestRunner, test) => {
test('emits parse, validate and resolve spans when ignoreResolveSpans is false', async () => {
await createTestRunner()
.expect({ transaction: EXPECTED_START_SERVER_TRANSACTION })
.expect({ transaction: EXPECTED_TRANSACTION })
.start()
.completed();
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import * as Sentry from '@sentry/node';
import gql from 'graphql-tag';

async function run() {
const { createApolloServer } = await import('./apollo-server.mjs');
const server = createApolloServer();

await Sentry.startSpan(
{
name: 'Test Transaction',
op: 'transaction',
},
async span => {
// Inline string literal (not a variable) so we can assert it gets redacted out of `graphql.source`.
await server.executeOperation({
query: gql`
mutation {
login(email: "secret@example.com")
}
`,
});

setTimeout(() => {
span.end();
server.stop();
}, 500);
},
);
}

run();
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,41 @@ describe('GraphQL/Apollo Tests', () => {
);
});

describe('redaction', () => {
const EXPECTED_TRANSACTION = {
transaction: 'Test Transaction (mutation)',
spans: expect.arrayContaining([
expect.objectContaining({
description: 'mutation',
status: 'ok',
origin: 'auto.graphql.otel.graphql',
data: expect.objectContaining({
'graphql.operation.type': 'mutation',
// The inline email literal must be redacted to `"*"`, so the raw value can never reach `graphql.source`.
'graphql.source': expect.stringContaining('login(email: "*")'),
'sentry.origin': 'auto.graphql.otel.graphql',
}),
}),
]),
};

createEsmAndCjsTests(
__dirname,
'scenario-redaction.mjs',
'instrument.mjs',
(createTestRunner, test) => {
test('redacts inline literal values from graphql.source.', async () => {
await createTestRunner()
.expect({ transaction: EXPECTED_START_SERVER_TRANSACTION })
.expect({ transaction: EXPECTED_TRANSACTION })
.start()
.completed();
});
},
{ copyPaths: ['apollo-server.mjs'] },
);
});

describe('error', () => {
const EXPECTED_TRANSACTION = {
transaction: 'Test Transaction (mutation Mutation)',
Expand Down
79 changes: 3 additions & 76 deletions packages/node/src/integrations/tracing/graphql/index.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,7 @@
import type { AttributeValue } from '@opentelemetry/api';
import { SpanStatusCode } from '@opentelemetry/api';
import { GraphQLInstrumentation } from './vendored/instrumentation';
import type { IntegrationFn } from '@sentry/core';
import { defineIntegration, getRootSpan, spanToJSON } from '@sentry/core';
import { addOriginToSpan, generateInstrumentOnce } from '@sentry/node-core';
import { SEMANTIC_ATTRIBUTE_SENTRY_GRAPHQL_OPERATION } from '@sentry/opentelemetry';
import { defineIntegration } from '@sentry/core';
import { generateInstrumentOnce } from '@sentry/node-core';

interface GraphqlOptions {
/**
Expand Down Expand Up @@ -40,59 +37,7 @@ const INTEGRATION_NAME = 'Graphql';
export const instrumentGraphql = generateInstrumentOnce(
INTEGRATION_NAME,
GraphQLInstrumentation,
(_options: GraphqlOptions) => {
const options = getOptionsWithDefaults(_options);

return {
...options,
responseHook(span, result) {
addOriginToSpan(span, 'auto.graphql.otel.graphql');

// We want to ensure spans are marked as errored if there are errors in the result
// We only do that if the span is not already marked with a status
const resultWithMaybeError = result as { errors?: { message: string }[] };
if (resultWithMaybeError.errors?.length && !spanToJSON(span).status) {
span.setStatus({ code: SpanStatusCode.ERROR });
}

const attributes = spanToJSON(span).data;

// If operation.name is not set, we fall back to use operation.type only
const operationType = attributes['graphql.operation.type'];
const operationName = attributes['graphql.operation.name'];

if (options.useOperationNameForRootSpan && operationType) {
const rootSpan = getRootSpan(span);
const rootSpanAttributes = spanToJSON(rootSpan).data;

const existingOperations = rootSpanAttributes[SEMANTIC_ATTRIBUTE_SENTRY_GRAPHQL_OPERATION] || [];

const newOperation = operationName ? `${operationType} ${operationName}` : `${operationType}`;

// We keep track of each operation on the root span
// This can either be a string, or an array of strings (if there are multiple operations)
if (Array.isArray(existingOperations)) {
(existingOperations as string[]).push(newOperation);
rootSpan.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_GRAPHQL_OPERATION, existingOperations);
} else if (typeof existingOperations === 'string') {
rootSpan.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_GRAPHQL_OPERATION, [existingOperations, newOperation]);
} else {
rootSpan.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_GRAPHQL_OPERATION, newOperation);
}

if (!spanToJSON(rootSpan).data['original-description']) {
rootSpan.setAttribute('original-description', spanToJSON(rootSpan).description);
}
// Important for e.g. @sentry/aws-serverless because this would otherwise overwrite the name again
rootSpan.updateName(
`${spanToJSON(rootSpan).data['original-description']} (${getGraphqlOperationNamesFromAttribute(
existingOperations,
)})`,
);
}
},
};
},
(_options: GraphqlOptions) => getOptionsWithDefaults(_options),
);

const _graphqlIntegration = ((options: GraphqlOptions = {}) => {
Expand Down Expand Up @@ -132,21 +77,3 @@ function getOptionsWithDefaults(options?: GraphqlOptions): GraphqlOptions {
...options,
};
}

// copy from packages/opentelemetry/utils
function getGraphqlOperationNamesFromAttribute(attr: AttributeValue): string {
if (Array.isArray(attr)) {
// oxlint-disable-next-line typescript/require-array-sort-compare
const sorted = attr.slice().sort();

// Up to 5 items, we just add all of them
if (sorted.length <= 5) {
return sorted.join(', ');
} else {
// Else, we add the first 5 and the diff of other operations
return `${sorted.slice(0, 5).join(', ')}, +${sorted.length - 5}`;
}
}

return `${attr}`;
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@
* - Vendored from: https://github.com/open-telemetry/opentelemetry-js-contrib/tree/15ef7506553f631ea4181391e0c5725a56f0d082/packages/instrumentation-graphql
* - Upstream version: @opentelemetry/instrumentation-graphql@0.66.0
*/
/* eslint-disable */

export enum AllowedOperationTypes {
QUERY = 'query',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@
* - Vendored from: https://github.com/open-telemetry/opentelemetry-js-contrib/tree/15ef7506553f631ea4181391e0c5725a56f0d082/packages/instrumentation-graphql
* - Upstream version: @opentelemetry/instrumentation-graphql@0.66.0
*/
/* eslint-disable */

export enum AttributeNames {
SOURCE = 'graphql.source',

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

l/m: This is neither in OTel's nor Sentry's semantic conventions. Maybe we should define it or update it to https://getsentry.github.io/sentry-conventions/attributes/graphql/#graphql-document?

Feel free to disregard for this PR, just maybe good to note as a follow-up?

@logaretm logaretm Jun 15, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it's an old attribute that predated OTel so they have it for backward compat stuff

https://github.com/open-telemetry/opentelemetry-js-contrib/blob/main/packages/instrumentation-graphql/src/enums/AttributeNames.ts#L6

I think we can track it as part of the attributes to fix before v11 release, since we have a lot of those semantic attrs missing or need renaming all over. What do you think?

Expand All @@ -27,6 +26,4 @@ export enum AttributeNames {
PARENT_NAME = 'graphql.parent.name',
OPERATION_TYPE = 'graphql.operation.type',
OPERATION_NAME = 'graphql.operation.name',
VARIABLES = 'graphql.variables.',
ERROR_VALIDATION_NAME = 'graphql.validation.error',
}
Loading
Loading