Skip to content
Draft
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
17 changes: 12 additions & 5 deletions MIGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -613,21 +613,28 @@ These changes are not caught by TypeScript. If you filter, group, or alert on sp

### Span name changes

Affected SDKs: All SDKs running in the browser.
Affected SDKs: All SDKs.

With [span streaming](#span-streaming-is-now-the-default) enabled(the default), span names are now **low cardinality**, following the [Sentry span name conventions](https://getsentry.github.io/sentry-conventions/names/).

In v11, this only affects `pageload` spans. Further ops will follow in future releases.
In v11, this affects `pageload` and `graphql` spans. Further ops will follow in future releases.
If you [opt out of span streaming](#opting-out-of-span-streaming), span names remain unchanged.

The following span names were adjusted:

| Span op | Before | After |
| ---------- | ------------------------------------------------------------------------------------------- | ---------------------------------------------------------- |
| `pageload` | The parameterized route, or the raw URL path if the SDK couldn't resolve one (`/users/123`) | The parameterized route, or `Pageload` if the SDK has none |
| Span op | Before | After |
| ---------- | --------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- |
| `pageload` | The parameterized route, or the raw URL path if the SDK couldn't resolve one (`/users/123`) | The parameterized route, or `Pageload` if the SDK has none |
| `graphql` | The graphql phase and, for operations, the operation name (`query GetUser`, `graphql.parse`, `graphql.resolve user.0.name`) | `GraphQL <operation type>` (`GraphQL query`), or `GraphQL Operation` for parse, validate and resolve spans |

Some consequences to be aware of:

The graphql operation name and the resolver field path are supplied by the client, so they are no longer part of a span name. They remain available on the `graphql.operation.name` and `graphql.field.path` attributes.

Because a low-cardinality name cannot say which part of request processing a span covers, every graphql span now carries a `graphql.processing.type` attribute (`parse`, `validate`, `execute` or `resolve`). Use it to tell parse, validate and resolve spans apart. The attribute is set in both trace lifecycles.

For the same reason, `useOperationNameForRootSpan` no longer renames the enclosing root span (`GET /graphql` stays `GET /graphql`, instead of becoming `GET /graphql (query GetUser)`). The operations are still recorded on that span's `sentry.graphql.operation` attribute.

Child spans of a pageload span carry its name in their `sentry.segment.name` attribute, so that changes with it. If you group or filter spans by segment name in dashboards or alerts, update those references.

`ignoreSpans` is evaluated when a span **starts**, at which point a pageload span without a resolved route is already named `'Pageload'`, so filters matching a URL path no longer apply to it. Match on attributes instead:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import * as Sentry from '@sentry/node';
import { loggingTransport } from '@sentry-internal/node-integration-tests';

Sentry.init({
traceLifecycle: 'stream',
dsn: 'https://public@dsn.ingest.sentry.io/1337',
release: '1.0',
tracesSampleRate: 1.0,
integrations: [Sentry.graphqlIntegration({ ignoreResolveSpans: false })],
transport: loggingTransport,
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
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: 'query GetHello {hello}' });
await server.executeOperation({
query: 'mutation TestMutation($email: String) { login(email: $email) }',
variables: { email: 'test@email.com' },
});

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

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

type StreamedSpan = SerializedStreamedSpanContainer['items'][number];

// Scoped to the `Test Transaction` segment: creating the server parses the schema's typeDefs, which
// emits a parse span under `Test Server Start`.
function graphqlSpans(container: SerializedStreamedSpanContainer): StreamedSpan[] {
return container.items.filter(
item =>
item.attributes['sentry.op']?.value === 'graphql' &&
item.attributes['sentry.segment.name']?.value === 'Test Transaction',
);
}

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

createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createTestRunner, test) => {
test('names graphql spans after the operation type, never the operation name or field path', async () => {
await createTestRunner()
.expect({
span: container => {
const spans = graphqlSpans(container);

const executeSpans = spans.filter(span => span.attributes['graphql.operation.type']);
expect(executeSpans.map(span => span.name)).toEqual(['GraphQL query', 'GraphQL mutation']);

// Resolver spans keep the field path as an attribute, but it is unbounded, so it must not
// reach the span name.
const resolveSpans = spans.filter(span => span.attributes['graphql.field.path']);
expect(resolveSpans.map(span => span.attributes['graphql.field.path']?.value)).toEqual(['hello', 'login']);

// Parse, validate and resolve spans have no operation type to name them after.
const fallbackSpans = spans.filter(span => !executeSpans.includes(span));
expect(fallbackSpans.every(span => span.name === 'GraphQL Operation')).toBe(true);

expect(spans.some(span => span.name.includes('GetHello') || span.name.includes('TestMutation'))).toBe(
false,
);
},
})
.start()
.completed();
});

test('marks every graphql span with its processing type', async () => {
await createTestRunner()
.expect({
span: container => {
const processingTypes = graphqlSpans(container).map(
span => span.attributes['graphql.processing.type']?.value,
);

expect(processingTypes.sort()).toEqual([
'execute',
'execute',
'parse',
'parse',
'resolve',
'resolve',
'validate',
'validate',
]);
},
})
.start()
.completed();
});

test('records the operations on the segment span without renaming it', async () => {
await createTestRunner()
.expect({
span: container => {
// `Test Server Start` is a segment too, so pick the one the operations ran under.
const segmentSpan = container.items.find(item => item.is_segment && item.name === 'Test Transaction');

expect(segmentSpan).toBeDefined();
// Both operations are recorded here rather than in the name.
expect(segmentSpan?.attributes['sentry.graphql.operation']?.value).toEqual([
'query GetHello',
'mutation TestMutation',
]);
},
})
.start()
.completed();
Comment thread
cursor[bot] marked this conversation as resolved.
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ describe('GraphQL/Apollo Tests > useOperationNameForRootSpan', () => {
'graphql.document': 'query GetHello {hello}',
'sentry.origin': 'auto.graphql.diagnostic_channel',
'sentry.op': 'graphql',
'graphql.processing.type': 'execute',
},
description: 'query GetHello',
status: 'ok',
Expand Down Expand Up @@ -54,6 +55,7 @@ describe('GraphQL/Apollo Tests > useOperationNameForRootSpan', () => {
}`,
'sentry.origin': 'auto.graphql.diagnostic_channel',
'sentry.op': 'graphql',
'graphql.processing.type': 'execute',
},
description: 'mutation TestMutation',
status: 'ok',
Expand Down Expand Up @@ -83,6 +85,7 @@ describe('GraphQL/Apollo Tests > useOperationNameForRootSpan', () => {
'graphql.document': 'query {hello}',
'sentry.origin': 'auto.graphql.diagnostic_channel',
'sentry.op': 'graphql',
'graphql.processing.type': 'execute',
},
description: 'query',
status: 'ok',
Expand Down Expand Up @@ -113,6 +116,7 @@ describe('GraphQL/Apollo Tests > useOperationNameForRootSpan', () => {
'graphql.document': 'query GetHello {hello}',
'sentry.origin': 'auto.graphql.diagnostic_channel',
'sentry.op': 'graphql',
'graphql.processing.type': 'execute',
},
description: 'query GetHello',
status: 'ok',
Expand All @@ -125,6 +129,7 @@ describe('GraphQL/Apollo Tests > useOperationNameForRootSpan', () => {
'graphql.document': 'query GetWorld {world}',
'sentry.origin': 'auto.graphql.diagnostic_channel',
'sentry.op': 'graphql',
'graphql.processing.type': 'execute',
},
description: 'query GetWorld',
status: 'ok',
Expand Down
12 changes: 12 additions & 0 deletions packages/server-utils/src/integrations/graphql/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,18 @@ export const SPAN_NAME_VALIDATE = 'graphql.validate';
export const SPAN_NAME_EXECUTE = 'graphql.execute';
export const SPAN_NAME_RESOLVE = 'graphql.resolve';

// Which part of request processing a span covers. Low-cardinality span names cannot carry the phase,
// so consumers read it here instead. Inlined until `@sentry/conventions` ships it
// (https://github.com/getsentry/sentry-conventions/pull/572).
export const GRAPHQL_PROCESSING_TYPE = 'graphql.processing.type';

export const PROCESSING_TYPE_PARSE = 'parse';
export const PROCESSING_TYPE_VALIDATE = 'validate';
// graphql-js `subscribe()` runs a subscription operation, so it is an execute too; the operation
// itself is told apart by `graphql.operation.type`.
export const PROCESSING_TYPE_EXECUTE = 'execute';
export const PROCESSING_TYPE_RESOLVE = 'resolve';

// Field-level resolver-span attributes; not in `@sentry/conventions`.
export const GRAPHQL_FIELD_NAME = 'graphql.field.name';
export const GRAPHQL_FIELD_PATH = 'graphql.field.path';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ import type { TracingChannel } from 'node:diagnostics_channel';
import { GRAPHQL_DOCUMENT, GRAPHQL_OPERATION_NAME, GRAPHQL_OPERATION_TYPE } from '@sentry/conventions/attributes';
import { GRAPHQL } from '@sentry/conventions/op';
import {
getClient,
GRAPHQL_SPAN_NAME_FALLBACK,
hasSpanStreamingEnabled,
SEMANTIC_ATTRIBUTE_SENTRY_OP,
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
SPAN_STATUS_ERROR,
Expand All @@ -28,6 +31,18 @@ const SPAN_NAME_EXECUTE = 'graphql.execute';
const SPAN_NAME_SUBSCRIBE = 'graphql.subscribe';
const SPAN_NAME_RESOLVE = 'graphql.resolve';

// Which part of request processing a span covers. Low-cardinality span names cannot carry the phase,
// so consumers read it here instead. Inlined until `@sentry/conventions` ships it
// (https://github.com/getsentry/sentry-conventions/pull/572).
const GRAPHQL_PROCESSING_TYPE = 'graphql.processing.type';

const PROCESSING_TYPE_PARSE = 'parse';
const PROCESSING_TYPE_VALIDATE = 'validate';
// graphql-js `subscribe()` runs a subscription operation, so it is an execute too; the operation
// itself is told apart by `graphql.operation.type`.
const PROCESSING_TYPE_EXECUTE = 'execute';
const PROCESSING_TYPE_RESOLVE = 'resolve';

// Field-level attributes for resolver spans. Not in `@sentry/conventions`; these match the keys the
// vendored OTel instrumentation emits so there is no drift between the two paths.
const GRAPHQL_FIELD_NAME = 'graphql.field.name';
Expand Down Expand Up @@ -101,6 +116,9 @@ export interface GraphQLOptions {
/**
* Rename the enclosing root span to include the operation name(s), e.g.
* `GET /graphql` -> `GET /graphql (query GetUser)`. Defaults to `true`.
*
* With span streaming the root span is not renamed, because the operation name is supplied by the
* client. The operations are recorded on its `sentry.graphql.operation` attribute either way.
*/
useOperationNameForRootSpan?: boolean;
}
Expand Down Expand Up @@ -145,26 +163,34 @@ export function subscribeGraphqlDiagnosticChannels(
}

function setupParseChannel(tracingChannel: GraphqlTracingChannelFactory): void {
bindTracingChannelToSpan(tracingChannel<GraphqlParseData>(GRAPHQL_DC_CHANNEL_PARSE), () =>
startInactiveSpan({
name: SPAN_NAME_PARSE,
bindTracingChannelToSpan(tracingChannel<GraphqlParseData>(GRAPHQL_DC_CHANNEL_PARSE), () => {
const client = getClient();

return startInactiveSpan({
// No operation type is available here, so with span streaming the span takes the static
// fallback and `graphql.processing.type` is what tells it apart from the other phases.
name: client && hasSpanStreamingEnabled(client) ? GRAPHQL_SPAN_NAME_FALLBACK : SPAN_NAME_PARSE,
attributes: {
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: ORIGIN,
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: GRAPHQL,
[GRAPHQL_PROCESSING_TYPE]: PROCESSING_TYPE_PARSE,
},
}),
);
});
});
Comment thread
cursor[bot] marked this conversation as resolved.
}

function setupValidateChannel(tracingChannel: GraphqlTracingChannelFactory): void {
bindTracingChannelToSpan(
tracingChannel<GraphqlValidateData>(GRAPHQL_DC_CHANNEL_VALIDATE),
data => {
const client = getClient();

return startInactiveSpan({
name: SPAN_NAME_VALIDATE,
name: client && hasSpanStreamingEnabled(client) ? GRAPHQL_SPAN_NAME_FALLBACK : SPAN_NAME_VALIDATE,
attributes: {
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: ORIGIN,
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: GRAPHQL,
[GRAPHQL_PROCESSING_TYPE]: PROCESSING_TYPE_VALIDATE,
[GRAPHQL_DOCUMENT]: collectGraphqlDocument(data.document),
},
});
Expand All @@ -189,11 +215,20 @@ function setupOperationChannel(
bindTracingChannelToSpan(
tracingChannel<GraphqlOperationData>(channelName),
data => {
const client = getClient();
// The operation name is supplied by the client, so with span streaming only the operation type
// may reach the span name.
const streamedName = data.operationType ? `GraphQL ${data.operationType}` : GRAPHQL_SPAN_NAME_FALLBACK;

const span = startInactiveSpan({
name: getOperationSpanName(data.operationType, data.operationName, fallbackName),
name:
client && hasSpanStreamingEnabled(client)
? streamedName
: getOperationSpanName(data.operationType, data.operationName, fallbackName),
attributes: {
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: ORIGIN,
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: GRAPHQL,
[GRAPHQL_PROCESSING_TYPE]: PROCESSING_TYPE_EXECUTE,
[GRAPHQL_OPERATION_TYPE]: data.operationType,
[GRAPHQL_OPERATION_NAME]: data.operationName || undefined,
[GRAPHQL_DOCUMENT]: collectGraphqlDocument(data.document),
Expand Down Expand Up @@ -225,11 +260,18 @@ function setupResolveChannel(tracingChannel: GraphqlTracingChannelFactory, ignor
return undefined;
}

const client = getClient();

return startInactiveSpan({
name: `${SPAN_NAME_RESOLVE} ${data.fieldPath}`,
// The field path is unbounded, so with span streaming it stays on `graphql.field.path` only.
name:
client && hasSpanStreamingEnabled(client)
? GRAPHQL_SPAN_NAME_FALLBACK
: `${SPAN_NAME_RESOLVE} ${data.fieldPath}`,
attributes: {
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: ORIGIN,
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: GRAPHQL,
[GRAPHQL_PROCESSING_TYPE]: PROCESSING_TYPE_RESOLVE,
[GRAPHQL_FIELD_NAME]: data.fieldName,
[GRAPHQL_FIELD_PATH]: data.fieldPath,
[GRAPHQL_FIELD_TYPE]: data.fieldType,
Expand Down
16 changes: 15 additions & 1 deletion packages/server-utils/src/integrations/graphql/resolvers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@
import { GRAPHQL } from '@sentry/conventions/op';
import type { Span, SpanAttributes } from '@sentry/core';
import {
getClient,
GRAPHQL_SPAN_NAME_FALLBACK,
hasSpanStreamingEnabled,
isObjectLike,
SEMANTIC_ATTRIBUTE_SENTRY_OP,
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
Expand All @@ -23,7 +26,9 @@ import {
GRAPHQL_FIELD_TYPE,
GRAPHQL_PARENT_NAME,
GRAPHQL_PATCHED_SYMBOL,
GRAPHQL_PROCESSING_TYPE,
ORIGIN,
PROCESSING_TYPE_RESOLVE,
SPAN_NAME_RESOLVE,
} from './constants';
import type {
Expand Down Expand Up @@ -180,13 +185,22 @@ function createResolverSpan(info: GraphQLResolveInfo, path: string[], parentSpan
const attributes: SpanAttributes = {
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: ORIGIN,
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: GRAPHQL,
[GRAPHQL_PROCESSING_TYPE]: PROCESSING_TYPE_RESOLVE,
[GRAPHQL_FIELD_NAME]: info.fieldName,
[GRAPHQL_FIELD_PATH]: path.join('.'),
[GRAPHQL_FIELD_TYPE]: info.returnType.toString(),
[GRAPHQL_PARENT_NAME]: info.parentType.name,
};

return startInactiveSpan({ name: `${SPAN_NAME_RESOLVE} ${path.join('.')}`, attributes, parentSpan });
const client = getClient();

return startInactiveSpan({
// The field path is unbounded, so with span streaming it stays on `graphql.field.path` only.
name:
client && hasSpanStreamingEnabled(client) ? GRAPHQL_SPAN_NAME_FALLBACK : `${SPAN_NAME_RESOLVE} ${path.join('.')}`,
attributes,
parentSpan,
});
}

function addField(contextValue: ObjectWithGraphQLData, path: string[], field: { span: Span }): void {
Expand Down
Loading
Loading