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
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