Skip to content
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import * as Sentry from '@sentry/node';
import { loggingTransport } from '@sentry-internal/node-integration-tests';

// Configure graphql on the diagnostics-channel injection path: opt in, then pass the matching
// `diagnosticsChannelInjectionIntegrations()` entry explicitly so its options (here
// `ignoreResolveSpans: false`) apply. This explicit instance wins over the default one the opt-in swaps in.
const { graphqlIntegration } = Sentry.diagnosticsChannelInjectionIntegrations();
Sentry.experimentalUseDiagnosticsChannelInjection();

Sentry.init({
dsn: 'https://public@dsn.ingest.sentry.io/1337',
release: '1.0',
tracesSampleRate: 1.0,
integrations: [graphqlIntegration({ ignoreResolveSpans: false })],
transport: loggingTransport,
});
Original file line number Diff line number Diff line change
Expand Up @@ -49,4 +49,45 @@ describe('GraphQL/Apollo Tests > resolve spans', () => {
.completed();
});
});

// Same behavior configured on the diagnostics-channel path: opting in and passing
// `diagnosticsChannelIntegrations.graphqlIntegration({ ignoreResolveSpans: false })` explicitly must
// carry the option through (the explicit instance wins over the swapped-in default) and emit resolve
// spans, with the distinct orchestrion origin. The instrument opts in itself, so it exercises the
// injection path regardless of the auto-orchestrion runner.
const EXPECTED_ORCHESTRION_TRANSACTION = {
transaction: 'Test Transaction (query)',
spans: expect.arrayContaining([
expect.objectContaining({
description: 'query',
origin: 'auto.graphql.orchestrion.graphql',
data: expect.objectContaining({
'graphql.operation.type': 'query',
'graphql.source': '{hello}',
'sentry.origin': 'auto.graphql.orchestrion.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-dc.mjs', (createTestRunner, test) => {
test('emits resolve spans via diagnostics-channel injection when configured explicitly', async () => {
await createTestRunner()
.expect({ transaction: EXPECTED_START_SERVER_TRANSACTION })
.expect({ transaction: EXPECTED_ORCHESTRION_TRANSACTION })
.start()
.completed();
});
});
});
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
import { afterAll, describe, expect } from 'vitest';
import { isOrchestrionEnabled } from '../../../utils';
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',
};

// These suites run twice on CI — once on OTel, once with orchestrion auto-injected. graphql is a
// drop-in replacement: only the span origin differs between the two paths, so branch just that.
const ORIGIN = isOrchestrionEnabled() ? 'auto.graphql.orchestrion.graphql' : 'auto.graphql.otel.graphql';

describe('GraphQL/Apollo Tests', () => {
afterAll(() => {
cleanupChildProcesses();
Expand All @@ -19,11 +24,11 @@ describe('GraphQL/Apollo Tests', () => {
data: {
'graphql.operation.type': 'query',
'graphql.source': '{hello}',
'sentry.origin': 'auto.graphql.otel.graphql',
'sentry.origin': ORIGIN,
},
description: 'query',
status: 'ok',
origin: 'auto.graphql.otel.graphql',
origin: ORIGIN,
}),
]),
};
Expand Down Expand Up @@ -55,11 +60,11 @@ describe('GraphQL/Apollo Tests', () => {
'graphql.operation.name': 'Mutation',
'graphql.operation.type': 'mutation',
'graphql.source': 'mutation Mutation($email: String) {\n login(email: $email)\n}',
'sentry.origin': 'auto.graphql.otel.graphql',
'sentry.origin': ORIGIN,
},
description: 'mutation Mutation',
status: 'ok',
origin: 'auto.graphql.otel.graphql',
origin: ORIGIN,
}),
]),
};
Expand Down Expand Up @@ -89,12 +94,12 @@ describe('GraphQL/Apollo Tests', () => {
expect.objectContaining({
description: 'mutation',
status: 'ok',
origin: 'auto.graphql.otel.graphql',
origin: ORIGIN,
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',
'sentry.origin': ORIGIN,
}),
}),
]),
Expand Down Expand Up @@ -127,11 +132,11 @@ describe('GraphQL/Apollo Tests', () => {
'graphql.operation.name': 'Mutation',
'graphql.operation.type': 'mutation',
'graphql.source': 'mutation Mutation($email: String) {\n login(email: $email)\n}',
'sentry.origin': 'auto.graphql.otel.graphql',
'sentry.origin': ORIGIN,
},
description: 'mutation Mutation',
status: 'internal_error',
origin: 'auto.graphql.otel.graphql',
origin: ORIGIN,
}),
]),
};
Expand Down
Original file line number Diff line number Diff line change
@@ -1,152 +1,24 @@
/*
* Simplified types inlined from the `graphql` package.
* Only includes members accessed by this instrumentation.
*/

export type PromiseOrValue<T> = T | Promise<T>;

export type Maybe<T> = null | undefined | T;

export interface Location {
start: number;
end: number;
startToken: Token;
source: Source;
[key: string]: any;
}

export interface Token {
kind: string;
start: number;
end: number;
line: number;
column: number;
value: string;
prev: Token | null;
next: Token | null;
[key: string]: any;
}

export interface Source {
body: string;
name: string;
locationOffset: Location;
[key: string]: any;
}

export interface DocumentNode {
kind: string;
definitions: ReadonlyArray<DefinitionNode>;
loc?: Location;
[key: string]: any;
}

export interface DefinitionNode {
kind: string;
loc?: Location;
[key: string]: any;
}

export interface OperationDefinitionNode extends DefinitionNode {
operation: string;
name?: { kind: string; value: string; loc?: Location };
[key: string]: any;
}

export interface ParseOptions {
noLocation?: boolean;
[key: string]: any;
}

export interface ExecutionArgs {
schema: GraphQLSchema;
document: DocumentNode;
rootValue?: any;
contextValue?: any;
variableValues?: Maybe<{ [key: string]: any }>;
operationName?: Maybe<string>;
fieldResolver?: Maybe<GraphQLFieldResolver<any, any>>;
typeResolver?: Maybe<GraphQLTypeResolver<any, any>>;
[key: string]: any;
}

export interface ExecutionResult {
errors?: ReadonlyArray<GraphQLError>;
data?: Record<string, any> | null;
[key: string]: any;
}

export interface GraphQLError {
message: string;
locations?: ReadonlyArray<{ line: number; column: number }>;
path?: ReadonlyArray<string | number>;
[key: string]: any;
}

export interface GraphQLSchema {
getQueryType(): GraphQLObjectType | undefined | null;
getMutationType(): GraphQLObjectType | undefined | null;
[key: string]: any;
}

export interface GraphQLObjectType {
name: string;
getFields(): { [key: string]: GraphQLField };
[key: string]: any;
}

export interface GraphQLField {
name: string;
type: GraphQLOutputType;
resolve?: GraphQLFieldResolver<any, any>;
[key: string]: any;
}

export type GraphQLOutputType = GraphQLNamedOutputType | GraphQLWrappingType;

interface GraphQLNamedOutputType {
name: string;
[key: string]: any;
}

interface GraphQLWrappingType {
ofType: GraphQLOutputType;
[key: string]: any;
}

export interface GraphQLUnionType {
name: string;
getTypes(): ReadonlyArray<GraphQLObjectType>;
[key: string]: any;
}

export type GraphQLType = GraphQLOutputType | GraphQLUnionType;

export type GraphQLFieldResolver<TSource, TContext, TArgs = any> = (
source: TSource,
args: TArgs,
context: TContext,
info: GraphQLResolveInfo,
) => any;

export type GraphQLTypeResolver<TSource, TContext> = (
value: TSource,
context: TContext,
info: GraphQLResolveInfo,
abstractType: any,
) => any;

export interface GraphQLResolveInfo {
fieldName: string;
fieldNodes: ReadonlyArray<{ kind: string; loc?: Location; [key: string]: any }>;
returnType: { toString(): string; [key: string]: any };
parentType: { name: string; [key: string]: any };
path: any;
[key: string]: any;
}

export type ValidationRule = any;

export interface TypeInfo {
[key: string]: any;
}
export type {
DefinitionNode,
DocumentNode,
ExecutionArgs,
ExecutionResult,
GraphQLError,
GraphQLFieldResolver,
GraphQLObjectType,
GraphQLOutputType,
GraphQLResolveInfo,
GraphQLSchema,
GraphQLType,
GraphQLTypeResolver,
GraphQLUnionType,
Location,
Maybe,
OperationDefinitionNode,
ParseOptions,
PromiseOrValue,
Source,
Token,
TypeInfo,
ValidationRule,
} from '@sentry/server-utils/orchestrion';
Original file line number Diff line number Diff line change
Expand Up @@ -367,7 +367,7 @@ export class GraphQLInstrumentation extends InstrumentationBase<GraphQLInstrumen
});
}

private _createExecuteSpan(operation: DefinitionNode | undefined, processedArgs: ExecutionArgs): Span {
private _createExecuteSpan(operation: DefinitionNode | undefined, processedArgs: OtelExecutionArgs): Span {
const span = startInactiveSpan({
name: SpanNames.EXECUTE,
attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: ORIGIN },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -239,7 +239,9 @@ export function wrapFields(
}

if (field.resolve) {
field.resolve = wrapFieldResolver(getConfig, field.resolve);
// The shared structural types narrow the resolver context to `ObjectWithGraphQLData`; cast back
// to the field's own resolver type (behavior is unchanged — this is a type-only adjustment).
field.resolve = wrapFieldResolver(getConfig, field.resolve) as GraphQLFieldResolver;
}

if (field.type) {
Expand All @@ -254,7 +256,8 @@ export function wrapFields(
function unwrapType(type: GraphQLOutputType): readonly GraphQLObjectType[] {
// unwrap wrapping types (non-nullable and list types)
if ('ofType' in type) {
return unwrapType(type.ofType);
// The structural index signature widens `ofType` to `unknown`, so narrow it back explicitly.
return unwrapType(type.ofType as GraphQLOutputType);
}

// unwrap union types
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/*
* Constants ported from `@opentelemetry/instrumentation-graphql`, kept OTel-free.
* Span names/attribute names are preserved verbatim so spans match the OTel integration's output
* (existing tests, dashboards, and the SDK's span-description parsing all key off these).
*/

export const enum SpanNames {
EXECUTE = 'graphql.execute',
PARSE = 'graphql.parse',
RESOLVE = 'graphql.resolve',
VALIDATE = 'graphql.validate',
SCHEMA_VALIDATE = 'graphql.validateSchema',
SCHEMA_PARSE = 'graphql.parseSchema',
}

// graphql `source`/`field.*`/`parent.*` are OTel-specific keys preserved verbatim for span parity.
// `graphql.operation.{name,type}` and `sentry.graphql.operation` come from `@sentry/conventions/attributes` instead
export const enum AttributeNames {
SOURCE = 'graphql.source',
FIELD_NAME = 'graphql.field.name',
FIELD_PATH = 'graphql.field.path',
FIELD_TYPE = 'graphql.field.type',
PARENT_NAME = 'graphql.parent.name',
}

export const enum TokenKind {
STRING = 'String',
INT = 'Int',
FLOAT = 'Float',
BLOCK_STRING = 'BlockString',
EOF = '<EOF>',
}

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

// `Symbol.for` keys are shared with any co-resident OTel graphql instrumentation on purpose: the two
// paths are mutually exclusive at runtime, and reusing the key keeps nested-execute detection and
// resolver parenting consistent if both ever load.
export const GRAPHQL_DATA_SYMBOL = Symbol.for('opentelemetry.graphql_data');
export const GRAPHQL_PATCHED_SYMBOL = Symbol.for('opentelemetry.patched');
Loading
Loading