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
19 changes: 12 additions & 7 deletions packages/server-utils/src/integrations/graphql/constants.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,23 @@
/*
* These mirror the constants in `@sentry/server-utils`'s native graphql subscriber
* (`src/graphql/graphql-dc-subscriber.ts`) so the orchestrion path (graphql v14–16) and the native
* `diagnostics_channel` path (graphql >= 17) emit identical spans — same origin, span names and
* field-attribute keys. `graphql.document`/`graphql.operation.*` and the span `op` come from
* `@sentry/conventions` directly and are imported where used.
*/
// Shared by both graphql paths (orchestrion for v14–16, diagnostics channels for >= 17) so they emit
// identical spans.

export const ORIGIN = 'auto.graphql.diagnostic_channel';

export const SPAN_NAME_PARSE = 'graphql.parse';
export const SPAN_NAME_VALIDATE = 'graphql.validate';
export const SPAN_NAME_EXECUTE = 'graphql.execute';
export const SPAN_NAME_SUBSCRIBE = 'graphql.subscribe';
export const SPAN_NAME_RESOLVE = 'graphql.resolve';

// 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';
// `subscribe()` runs an operation too; `graphql.operation.type` is what marks it as a subscription.
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,12 +2,32 @@ 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,
startInactiveSpan,
} from '@sentry/core';
import { bindTracingChannelToSpan } from '../../tracing-channel';
import {
GRAPHQL_FIELD_NAME,
GRAPHQL_FIELD_PATH,
GRAPHQL_FIELD_TYPE,
GRAPHQL_PARENT_NAME,
GRAPHQL_PROCESSING_TYPE,
ORIGIN,
PROCESSING_TYPE_EXECUTE,
PROCESSING_TYPE_PARSE,
PROCESSING_TYPE_RESOLVE,
PROCESSING_TYPE_VALIDATE,
SPAN_NAME_EXECUTE,
SPAN_NAME_PARSE,
SPAN_NAME_RESOLVE,
SPAN_NAME_SUBSCRIBE,
SPAN_NAME_VALIDATE,
} from './constants';
import type { GraphqlDocumentNode } from './types';
import { collectGraphqlDocument, getOperationSpanName, hasResultErrors, renameRootSpanWithOperation } from './utils';

Expand All @@ -20,21 +40,6 @@ export const GRAPHQL_DC_CHANNEL_EXECUTE = 'graphql:execute';
export const GRAPHQL_DC_CHANNEL_SUBSCRIBE = 'graphql:subscribe';
export const GRAPHQL_DC_CHANNEL_RESOLVE = 'graphql:resolve';

const ORIGIN = 'auto.graphql.diagnostic_channel';

const SPAN_NAME_PARSE = 'graphql.parse';
const SPAN_NAME_VALIDATE = 'graphql.validate';
const SPAN_NAME_EXECUTE = 'graphql.execute';
const SPAN_NAME_SUBSCRIBE = 'graphql.subscribe';
const SPAN_NAME_RESOLVE = 'graphql.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';
const GRAPHQL_FIELD_PATH = 'graphql.field.path';
const GRAPHQL_FIELD_TYPE = 'graphql.field.type';
const GRAPHQL_PARENT_NAME = 'graphql.parent.name';

/** Context published on the sync-only `graphql:parse` channel. */
export interface GraphqlParseData {
source: string | { body?: string };
Expand Down Expand Up @@ -101,6 +106,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 +153,33 @@ 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 here, so streaming falls back; the phase lives on the attribute instead.
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 +204,19 @@ function setupOperationChannel(
bindTracingChannelToSpan(
tracingChannel<GraphqlOperationData>(channelName),
data => {
const client = getClient();
// The operation name comes from the client, so only the operation type may reach the 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 +248,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
Loading
Loading