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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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?

@logaretmlogaretmJun 15, 2026

Copy link
Copy Markdown
MemberAuthor

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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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?

@logaretmlogaretmJun 15, 2026

Copy link
Copy Markdown
MemberAuthor

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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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?

@logaretmlogaretmJun 15, 2026

Copy link
Copy Markdown
MemberAuthor

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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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?

@logaretmlogaretmJun 15, 2026

Copy link
Copy Markdown
MemberAuthor

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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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?

@logaretmlogaretmJun 15, 2026

Copy link
Copy Markdown
MemberAuthor

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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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?

@logaretmlogaretmJun 15, 2026

Copy link
Copy Markdown
MemberAuthor

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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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?

@logaretmlogaretmJun 15, 2026

Copy link
Copy Markdown
MemberAuthor

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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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?

@logaretmlogaretmJun 15, 2026

Copy link
Copy Markdown
MemberAuthor

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