apollo-server/packages/apollo-engine-reporting/src/agent.ts

298 lines
12 KiB
TypeScript
Raw Normal View History

Import graphql-extensions+apollo-engine-reporting/no global apollo-server-env (#1259) * Export polyfills and types separately * More imports from apollo-server-env * Initial commit * Add .npmignore to avoid ignoring lib when publishing * 0.0.2 * Reorganize code and clean up GraphQLExtension interface * 0.0.3 * Add support for timing callbacks and add GraphQLExtensionStack * 0.0.4 * Downgrade target in tsconfig.json from es2015 to es5 * 0.0.5 * Bump `graphql` peerDependency. (#3) * 0.0.6 * Update dependencies * 0.0.7 * whenResultIsFinished fix for array results (#4) * 0.0.8 * [apollo-bot] Update the Issue/PR Templates with auto label (#6) * Bump `graphql` peerDependency. (#7) * Update `graphql` peer dependency range to allow 0.13.x. (#8) * Update `devDependencies` to latest versions. (#9) * dev: Update TypeScript to latest version, v2.7.2. * dev: Update `graphql` to latest version, v0.13.2. * dev: Update jest & dependencies to latest versions. * dev: Update type definitions for `graphql`, `node` and `jest`. * Allow `undefined` return values to `GraphQLExtension`'s `format()`. (#10) In some cases, it's conceivable that the `format()` method may need to abort its decision to provide extension information at runtime, in the event that it doesn't have the proper information to return a full-result. The `format` method already removed false-y results, so this simply changes the types to allow the same. * 0.0.9 * Fix lifecycle method invocations on extensions * 0.0.10 * Add changelog * Upgrade to TypeScript 2.8 Makes my editor integration happier (a bugfix in tsserver I think) * Add tslint and prettier Same configuration as apollo-engine-js * Remove magic from GraphQLExtensionStack constructor It's not hard to consistently pass in an actual extension object to this low-level API. * New extension API: didStart handlers return didEnd handlers This is a backwards-incompatible change: GraphQLExtension implementations and users of GraphQLExtensionStack (ie apollo-server-core) must change their implementations, if they implement any of the xDidStart/xDidEnd APIs. This allows "didEnd" handlers to refer to closure variables from the "didStart" handler rather than needing to store state on the extension. The new "didEnd" handlers run in the opposite order of the "didStart" handlers, so that they properly nest. * 0.1.0-beta.0 * Changelog * Add magic back into GraphQLExtensionStack constructor But now it actually gets more context (the execution arguments) and doesn't have to be a constructor. * 0.1.0-beta.1 * Export more types * 0.1.0-beta.2 * Fix lifecycle handlers to pass proper "this" * 0.1.0-beta.3 * Pass options directly to start handlers; eliminate factory again * 0.1.0-beta.4 * error handling in didEnd * 0.1.0-beta.5 * pass multiple errors to EndHandler * 0.1.0-beta.6 * add willSendResponse * 0.1.0-beta.7 * prettier * setFieldResolver for custom fieldResolver * reverse * get more initial options into requestDidStart * 0.1.0-beta.8 * 0.1.0-beta.9 * Actually, we already get the fieldResolver! * 0.1.0-beta.10 * work without extensionStack * 0.1.0-beta.11 * 0.1.0-beta.12 * Send errors to willResolveField callback * 0.1.0-beta.13 * willSendResponse can return a result * 0.1.0-beta.14 * Revert 1063be8..56912fc This reverts commit 1063be8..56912fc. * add PQ options to requestDidStart * 0.1.0-beta.14 * 0.1.0-beta.15 * Initialize an empty TypeScript/Jest package Template based on apollo-engine-js * Basic trace node structure building * basic timing * Checkpoint towards signature implementation The new signature implementation does not try to compress whitespace. * Basic signature implementation * progress towards actual reporting * basic checkpoint for reporting * 0.0.0-beta.1 * pull in @types/long, since it is in the external api * 0.0.0-beta.2 * get rid of Long * 0.0.0-beta.3 * debug log request what happened * 0.0.0-beta.4 * 0.0.0-beta.5 * correct url * 0.0.0-beta.6 * request headers * 0.0.0-beta.7 * leave out a few headers * 0.0.0-beta.8 * prettier * move stuff into multiple files, and stop exporting the extension * lots of doc comments * address agent.ts XXX comments * implement privateVariables simplify API by removing flush() and allowing flush-ers to just call sendReport directly * privateHeaders and error tracking * gzip, signals * fix test * 0.0.0-beta.9 * Error handling for reports * 0.0.0-beta.10 * no need to include boring stacktrace * 0.0.0-beta.11 * tweak error reporting * 0.0.0-beta.12 * package-lock update (npm@6?) * Reduce target report size to 512KB from 4MB. Load testing revealed that protobuf encoding for large FullTraceReports could tie up CPU and reduce p99 request latency (eg, to 200ms from 10ms). Reducing the default target report size spreads out the encoding time and mitigates the impact on latency. If this is not acceptable for all users, we may have to investigate reintroducing agent-side stats aggregation to keep report sizes small. * 0.0.0-beta.13 * Encode Traces as they come in This improves p99 times with little effect on p50 times. It also lets us get rid of the heuristic average trace size estimation. * 0.0.0-beta.14 * support PQ fields * npm audit fix * 0.0.0-beta.15 * ignore coverage * Make the default signature more aggressive We'd rather tell people confused by literal removal to tweak the signature than tell people causing outages to do so. * 0.0.0-beta.16 * Remove obsolete files from graphql-extensions and apollo-engine-reporting * Fix dependencies and configs * Fix apollo-server-cloudflare to import from apollo-server-env * Fix compilation and test configs * Get all tests passing again * Switch to Lerna independent versioning * Polyfill promisify for Node < 8 and load polyfills in tests * ES2016 exponentiation operator is only supported in Node > 6 * add dependency cache for circle * add missing env dependencies in REST datasource
2018-06-28 01:29:00 +02:00
import * as os from 'os';
import { gzip } from 'zlib';
import * as request from 'requestretry';
import { DocumentNode } from 'graphql';
import {
FullTracesReport,
ReportHeader,
Traces,
Trace,
} from 'apollo-engine-reporting-protobuf';
import { EngineReportingExtension } from './extension';
// Override the generated protobuf Traces.encode function so that it will look
// for Traces that are already encoded to Buffer as well as unencoded
// Traces. This amortizes the protobuf encoding time over each generated Trace
// instead of bunching it all up at once at sendReport time. In load tests, this
// change improved p99 end-to-end HTTP response times by a factor of 11 without
// a casually noticeable effect on p50 times. This also makes it easier for us
// to implement maxUncompressedReportSize as we know the encoded size of traces
// as we go.
const originalTracesEncode = Traces.encode;
Traces.encode = function(message, originalWriter) {
const writer = originalTracesEncode(message, originalWriter);
const encodedTraces = (message as any).encodedTraces;
if (encodedTraces != null && encodedTraces.length) {
for (let i = 0; i < encodedTraces.length; ++i) {
writer.uint32(/* id 1, wireType 2 =*/ 10);
writer.bytes(encodedTraces[i]);
}
}
return writer;
};
export interface EngineReportingOptions {
// API key for the service. Get this from
// [Engine](https://engine.apollographql.com) by logging in and creating
// a service. You may also specify this with the `ENGINE_API_KEY`
// environment variable; the option takes precedence. __Required__.
apiKey?: string;
// Specify the function for creating a signature for a query. See signature.ts
// for details.
calculateSignature?: (ast: DocumentNode, operationName: string) => string;
// How often to send reports to the Engine server. We'll also send reports
// when the report gets big; see maxUncompressedReportSize.
reportIntervalMs?: number;
// We send a report when the report size will become bigger than this size in
// bytes (default: 4MB). (This is a rough limit --- we ignore the size of the
// report header and some other top level bytes. We just add up the lengths of
// the serialized traces and signatures.)
maxUncompressedReportSize?: number;
// The URL of the Engine report ingress server.
endpointUrl?: string;
// If set, prints all reports as JSON when they are sent.
debugPrintReports?: boolean;
// Reporting is retried with exponential backoff up to this many times
// (including the original request). Defaults to 5.
maxAttempts?: number;
// Minimum backoff for retries. Defaults to 100ms.
minimumRetryDelayMs?: number;
// By default, errors sending reports to Engine servers will be logged
// to standard error. Specify this function to process errors in a different
// way.
reportErrorFunction?: (err: Error) => void;
// A case-sensitive list of names of variables whose values should not be sent
// to Apollo servers, or 'true' to leave out all variables. In the former
// case, the report will indicate that each private variable was redacted; in
// the latter case, no variables are sent at all.
privateVariables?: Array<String> | boolean;
// A case-insensitive list of names of HTTP headers whose values should not be
// sent to Apollo servers, or 'true' to leave out all HTTP headers. Unlike
// with privateVariables, names of dropped headers are not reported.
privateHeaders?: Array<String> | boolean;
// By default, EngineReportingAgent listens for the 'SIGINT' and 'SIGTERM'
// signals, stops, sends a final report, and re-sends the signal to
// itself. Set this to false to disable. You can manually invoke 'stop()' and
// 'sendReport()' on other signals if you'd like. Note that 'sendReport()'
// does not run synchronously so it cannot work usefully in an 'exit' handler.
handleSignals?: boolean;
// XXX Provide a way to set client_name, client_version, client_address,
// service, and service_version fields. They are currently not revealed in the
// Engine frontend app.
}
const REPORT_HEADER = new ReportHeader({
hostname: os.hostname(),
// tslint:disable-next-line no-var-requires
agentVersion: `apollo-engine-reporting@${require('../package.json').version}`,
runtimeVersion: `node ${process.version}`,
// XXX not actually uname, but what node has easily.
uname: `${os.platform()}, ${os.type()}, ${os.release()}, ${os.arch()})`,
});
// EngineReportingAgent is a persistent object which creates
// EngineReportingExtensions for each request and sends batches of trace reports
// to the Engine server.
export class EngineReportingAgent<TContext = any> {
private options: EngineReportingOptions;
private apiKey: string;
private report!: FullTracesReport;
private reportSize!: number;
private reportTimer: any; // timer typing is weird and node-specific
public constructor(options: EngineReportingOptions = {}) {
this.options = options;
this.apiKey = options.apiKey || process.env.ENGINE_API_KEY || '';
if (!this.apiKey) {
throw new Error(
'To use EngineReportingAgent, you must specify an API key via the apiKey option or the ENGINE_API_KEY environment variable.',
);
}
this.resetReport();
this.reportTimer = setInterval(
() => this.sendReportAndReportErrors(),
this.options.reportIntervalMs || 10 * 1000,
);
if (this.options.handleSignals !== false) {
const signals: NodeJS.Signals[] = ['SIGINT', 'SIGTERM'];
signals.forEach(signal => {
process.once(signal, async () => {
this.stop();
await this.sendReportAndReportErrors();
process.kill(process.pid, signal);
});
});
}
}
public newExtension(): EngineReportingExtension<TContext> {
return new EngineReportingExtension<TContext>(
this.options,
this.addTrace.bind(this),
);
}
public addTrace(signature: string, operationName: string, trace: Trace) {
// Ignore traces that come in after stop().
if (!this.reportTimer) {
return;
}
const protobufError = Trace.verify(trace);
if (protobufError) {
throw new Error(`Error encoding trace: ${protobufError}`);
}
const encodedTrace = Trace.encode(trace).finish();
const statsReportKey = `# ${operationName || '-'}\n${signature}`;
if (!this.report.tracesPerQuery.hasOwnProperty(statsReportKey)) {
this.report.tracesPerQuery[statsReportKey] = new Traces();
(this.report.tracesPerQuery[statsReportKey] as any).encodedTraces = [];
}
// See comment on our override of Traces.encode to learn more about this
// strategy.
(this.report.tracesPerQuery[statsReportKey] as any).encodedTraces.push(
encodedTrace,
);
this.reportSize += encodedTrace.length + Buffer.byteLength(statsReportKey);
// If the buffer gets big (according to our estimate), send.
if (
this.reportSize >=
(this.options.maxUncompressedReportSize || 4 * 1024 * 1024)
) {
this.sendReportAndReportErrors();
}
}
public sendReport(): Promise<void> {
const report = this.report;
this.resetReport();
if (Object.keys(report.tracesPerQuery).length === 0) {
return Promise.resolve();
}
// Send traces asynchronously, so that (eg) addTrace inside a resolver
// doesn't block on it.
return Promise.resolve()
.then(() => {
if (this.options.debugPrintReports) {
// tslint:disable-next-line no-console
console.log(
`Engine sending report: ${JSON.stringify(report.toJSON())}`,
);
}
const protobufError = FullTracesReport.verify(report);
if (protobufError) {
throw new Error(`Error encoding report: ${protobufError}`);
}
const message = FullTracesReport.encode(report).finish();
return new Promise((resolve, reject) => {
// The protobuf library gives us a Uint8Array. Node 8's zlib lets us
// pass it directly; convert for the sake of Node 6. (No support right
// now for Node 4, which lacks Buffer.from.)
const messageBuffer = Buffer.from(
message.buffer as ArrayBuffer,
message.byteOffset,
message.byteLength,
);
gzip(messageBuffer, (err, compressed) => {
if (err) {
reject(err);
} else {
resolve(compressed);
}
});
});
})
.then(compressed => {
// Grab this here because the delayStrategy function has a different 'this'.
const minimumRetryDelayMs = this.options.minimumRetryDelayMs || 100;
// note: retryrequest has built-in Promise support, unlike the base 'request'.
return (request({
url:
(this.options.endpointUrl ||
'https://engine-report.apollodata.com') + '/api/ingress/traces',
method: 'POST',
headers: {
'user-agent': 'apollo-engine-reporting',
'x-api-key': this.apiKey,
'content-encoding': 'gzip',
},
body: compressed,
// By default, retryrequest will retry on network errors and 5xx HTTP
// responses.
maxAttempts: this.options.maxAttempts || 5,
// Note: use a non-arrow function as this API gives us useful information
// on 'this', and use an 'as any' because the type definitions don't know
// about the function version of this parameter.
delayStrategy: function() {
return Math.pow(minimumRetryDelayMs * 2, this.attempts);
},
// XXX Back in Optics, we had an explicit proxyUrl option for corporate
// proxies. I was never clear on why `request`'s handling of the
// standard env vars wasn't good enough (see
// https://github.com/apollographql/optics-agent-js/pull/70#discussion_r89374066).
// We may have to add it here.
// Include 'as any's because @types/requestretry doesn't understand the
// promise API or delayStrategy.
} as any) as any).catch((err: Error) => {
throw new Error(`Error sending report to Engine servers: ${err}`);
});
})
.then(response => {
if (response.statusCode < 200 || response.statusCode >= 300) {
// Note that we don't expect to see a 3xx here because request follows
// redirects.
throw new Error(
`Error sending report to Engine servers (HTTP status ${
response.statusCode
}): ${response.body}`,
);
}
if (this.options.debugPrintReports) {
// tslint:disable-next-line no-console
console.log(`Engine report: status ${response.statusCode}`);
}
});
}
// Stop prevents reports from being sent automatically due to time or buffer
// size, and stop buffering new traces. You may still manually send a last
// report by calling sendReport().
public stop() {
if (this.reportTimer) {
clearInterval(this.reportTimer);
this.reportTimer = undefined;
}
}
private sendReportAndReportErrors(): Promise<void> {
return this.sendReport().catch(err => {
// This catch block is primarily intended to catch network errors from
// the retried request itself, which include network errors and non-2xx
// HTTP errors.
if (this.options.reportErrorFunction) {
this.options.reportErrorFunction(err);
} else {
console.error(err.message);
}
});
}
private resetReport() {
this.report = new FullTracesReport({ header: REPORT_HEADER });
this.reportSize = 0;
}
}