apollo-server/packages/apollo-engine-reporting/src/extension.ts
2018-06-28 19:06:53 +02:00

304 lines
11 KiB
TypeScript

import { Request } from 'apollo-server-env';
import {
GraphQLResolveInfo,
responsePathAsArray,
ResponsePath,
DocumentNode,
ExecutionArgs,
GraphQLError,
} from 'graphql';
import {
GraphQLExtension,
GraphQLResponse,
EndHandler,
} from 'graphql-extensions';
import { Trace, google } from 'apollo-engine-reporting-protobuf';
import { EngineReportingOptions } from './agent';
import { defaultSignature } from './signature';
// EngineReportingExtension is the per-request GraphQLExtension which creates a
// trace (in protobuf Trace format) for a single request. When the request is
// done, it passes the Trace back to its associated EngineReportingAgent via the
// addTrace callback in its constructor. This class isn't for direct use; its
// constructor is a private API for communicating with EngineReportingAgent.
// Its public methods all implement the GraphQLExtension interface.
export class EngineReportingExtension<TContext = any>
implements GraphQLExtension<TContext> {
public trace = new Trace();
private nodes = new Map<string, Trace.Node>();
private startHrTime!: [number, number];
private operationName?: string;
private queryString?: string;
private documentAST?: DocumentNode;
private options: EngineReportingOptions;
private addTrace: (
signature: string,
operationName: string,
trace: Trace,
) => void;
public constructor(
options: EngineReportingOptions,
addTrace: (signature: string, operationName: string, trace: Trace) => void,
) {
this.options = options;
this.addTrace = addTrace;
const root = new Trace.Node();
this.trace.root = root;
this.nodes.set(responsePathAsString(undefined), root);
}
public requestDidStart(o: {
request: Request;
queryString?: string;
parsedQuery?: DocumentNode;
variables: Record<string, any>;
persistedQueryHit?: boolean;
persistedQueryRegister?: boolean;
}): EndHandler {
this.trace.startTime = dateToTimestamp(new Date());
this.startHrTime = process.hrtime();
// Generally, we'll get queryString here and not parsedQuery; we only get
// parsedQuery if you're using an OperationStore. In normal cases we'll get
// our documentAST in the execution callback after it is parsed.
this.queryString = o.queryString;
this.documentAST = o.parsedQuery;
this.trace.http = new Trace.HTTP({
method:
Trace.HTTP.Method[o.request.method as keyof typeof Trace.HTTP.Method] ||
Trace.HTTP.Method.UNKNOWN,
// Host and path are not used anywhere on the backend, so let's not bother
// trying to parse request.url to get them, which is a potential
// source of bugs because integrations have different behavior here.
// On Node's HTTP module, request.url only includes the path
// (see https://nodejs.org/api/http.html#http_message_url)
// The same is true on Lambda (where we pass event.path)
// But on environments like Cloudflare we do get a complete URL.
host: null,
path: null,
});
if (this.options.privateHeaders !== true) {
for (const [key, value] of o.request.headers) {
if (
this.options.privateHeaders &&
typeof this.options.privateHeaders === 'object' &&
// We assume that most users only have a few private headers, or will
// just set privateHeaders to true; we can change this linear-time
// operation if it causes real performance issues.
this.options.privateHeaders.includes(key.toLowerCase())
) {
break;
}
switch (key) {
case 'authorization':
case 'cookie':
case 'set-cookie':
break;
default:
this.trace.http!.requestHeaders![key] = new Trace.HTTP.Values({
value: [value],
});
}
}
if (o.persistedQueryHit) {
this.trace.persistedQueryHit = true;
}
if (o.persistedQueryRegister) {
this.trace.persistedQueryRegister = true;
}
}
if (this.options.privateVariables !== true && o.variables) {
// Note: we explicitly do *not* include the details.rawQuery field. The
// Engine web app currently does nothing with this other than store it in
// the database and offer it up via its GraphQL API, and sending it means
// that using calculateSignature to hide sensitive data in the query
// string is ineffective.
this.trace.details = new Trace.Details();
Object.keys(o.variables).forEach(name => {
if (
this.options.privateVariables &&
typeof this.options.privateVariables === 'object' &&
// We assume that most users will have only a few private variables,
// or will just set privateVariables to true; we can change this
// linear-time operation if it causes real performance issues.
this.options.privateVariables.includes(name)
) {
// Special case for private variables. Note that this is a different
// representation from a variable containing the empty string, as that
// will be sent as '""'.
this.trace.details!.variablesJson![name] = '';
} else {
this.trace.details!.variablesJson![name] = JSON.stringify(
o.variables[name],
);
}
});
}
return () => {
this.trace.durationNs = durationHrTimeToNanos(
process.hrtime(this.startHrTime),
);
this.trace.endTime = dateToTimestamp(new Date());
const operationName = this.operationName || '';
let signature;
if (this.documentAST) {
const calculateSignature =
this.options.calculateSignature || defaultSignature;
signature = calculateSignature(this.documentAST, operationName);
} else if (this.queryString) {
// We didn't get an AST, possibly because of a parse failure. Let's just
// use the full query string.
//
// XXX This does mean that even if you use a calculateSignature which
// hides literals, you might end up sending literals for queries
// that fail parsing or validation. Provide some way to mask them
// anyway?
signature = this.queryString;
} else {
// This shouldn't happen: one of those options must be passed to runQuery.
throw new Error('No queryString or parsedQuery?');
}
this.addTrace(signature, operationName, this.trace);
};
}
public executionDidStart(o: { executionArgs: ExecutionArgs }) {
// If the operationName is explicitly provided, save it. If there's just one
// named operation, the client doesn't have to provide it, but we still want
// to know the operation name so that the server can identify the query by
// it without having to parse a signature.
//
// Fortunately, in the non-error case, we can just pull this out of
// the first call to willResolveField's `info` argument. In an
// error case (eg, the operationName isn't found, or there are more
// than one operation and no specified operationName) it's OK to continue
// to file this trace under the empty operationName.
if (o.executionArgs.operationName) {
this.operationName = o.executionArgs.operationName;
}
this.documentAST = o.executionArgs.document;
}
public willResolveField(
_source: any,
_args: { [argName: string]: any },
_context: TContext,
info: GraphQLResolveInfo,
): ((error: Error | null, result: any) => void) | void {
if (this.operationName === undefined) {
this.operationName =
(info.operation.name && info.operation.name.value) || '';
}
const path = info.path;
const node = this.newNode(path);
node.type = info.returnType.toString();
node.parentType = info.parentType.toString();
node.startTime = durationHrTimeToNanos(process.hrtime(this.startHrTime));
return () => {
node.endTime = durationHrTimeToNanos(process.hrtime(this.startHrTime));
// We could save the error into the trace here, but it won't have all
// the information that graphql-js adds to it later, like 'locations'.
};
}
public willSendResponse(o: { graphqlResponse: GraphQLResponse }) {
const { errors } = o.graphqlResponse;
if (errors) {
errors.forEach((error: GraphQLError) => {
// By default, put errors on the root node.
let node = this.nodes.get('');
if (error.path) {
const specificNode = this.nodes.get(error.path.join('.'));
if (specificNode) {
node = specificNode;
}
}
node!.error!.push(
new Trace.Error({
message: error.message,
location: (error.locations || []).map(
({ line, column }) => new Trace.Location({ line, column }),
),
json: JSON.stringify(error),
}),
);
});
}
}
private newNode(path: ResponsePath): Trace.Node {
const node = new Trace.Node();
const id = path.key;
if (typeof id === 'number') {
node.index = id;
} else {
node.fieldName = id;
}
this.nodes.set(responsePathAsString(path), node);
const parentNode = this.ensureParentNode(path);
parentNode.child.push(node);
return node;
}
private ensureParentNode(path: ResponsePath): Trace.Node {
const parentPath = responsePathAsString(path.prev);
const parentNode = this.nodes.get(parentPath);
if (parentNode) {
return parentNode;
}
// Because we set up the root path in the constructor, we now know that
// path.prev isn't undefined.
return this.newNode(path.prev!);
}
}
// Helpers for producing traces.
// Convert from the linked-list ResponsePath format to a dot-joined
// string. Includes the full path (field names and array indices).
function responsePathAsString(p: ResponsePath | undefined) {
if (p === undefined) {
return '';
}
return responsePathAsArray(p).join('.');
}
// Converts a JS Date into a Timestamp.
function dateToTimestamp(date: Date): google.protobuf.Timestamp {
const totalMillis = +date;
const millis = totalMillis % 1000;
return new google.protobuf.Timestamp({
seconds: (totalMillis - millis) / 1000,
nanos: millis * 1e6,
});
}
// Converts an hrtime array (as returned from process.hrtime) to nanoseconds.
//
// ONLY CALL THIS ON VALUES REPRESENTING DELTAS, NOT ON THE RAW RETURN VALUE
// FROM process.hrtime() WITH NO ARGUMENTS.
//
// The entire point of the hrtime data structure is that the JavaScript Number
// type can't represent all int64 values without loss of precision:
// Number.MAX_SAFE_INTEGER nanoseconds is about 104 days. Calling this function
// on a duration that represents a value less than 104 days is fine. Calling
// this function on an absolute time (which is generally roughly time since
// system boot) is not a good idea.
//
// XXX We should probably use google.protobuf.Duration on the wire instead of
// ever trying to store durations in a single number.
function durationHrTimeToNanos(hrtime: [number, number]) {
return hrtime[0] * 1e9 + hrtime[1];
}