src/census/plugins/http.ts
/**
* Copyright 2018, OpenCensus Authors
*
* Licensed under the Apache License, Version 2.0 (the "License")
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { BasePlugin, Func, HeaderGetter, HeaderSetter, Span, TraceOptions, MessageEventType, SpanKind, CanonicalCode } from '@opencensus/core'
import * as httpModule from 'http'
import * as semver from 'semver'
import * as shimmer from 'shimmer'
import * as url from 'url'
import * as uuid from 'uuid'
import { kMiddlewareStack } from './express'
export type IgnoreMatcher<T> = string | RegExp | ((url: string, request: T) => boolean)
export type HttpPluginConfig = {
/**
* Ignore specific incoming request depending on their path
*/
ignoreIncomingPaths: Array<IgnoreMatcher<httpModule.IncomingMessage>>
/**
* Ignore specific outgoing request depending on their url
*/
ignoreOutgoingUrls: Array<IgnoreMatcher<httpModule.ClientRequest>>
/**
* Disable the creation of root span with http server
* mainly used if net plugin is implemented
*/
createSpanWithNet: boolean
}
export type HttpModule = typeof httpModule
export type RequestFunction = typeof httpModule.request
/** Http instrumentation plugin for Opencensus */
export class HttpPlugin extends BasePlugin {
/**
* Attributes Names according to Opencensus HTTP Specs
* https://github.com/census-instrumentation/opencensus-specs/blob/master/trace/HTTP.md
*/
static ATTRIBUTE_HTTP_HOST = 'http.host'
static ATTRIBUTE_HTTP_METHOD = 'http.method'
static ATTRIBUTE_HTTP_PATH = 'http.path'
static ATTRIBUTE_HTTP_ROUTE = 'http.route'
static ATTRIBUTE_HTTP_USER_AGENT = 'http.user_agent'
static ATTRIBUTE_HTTP_STATUS_CODE = 'http.status_code'
// NOT ON OFFICIAL SPEC
static ATTRIBUTE_HTTP_ERROR_NAME = 'http.error_name'
static ATTRIBUTE_HTTP_ERROR_MESSAGE = 'http.error_message'
protected options: HttpPluginConfig
/** Constructs a new HttpPlugin instance. */
constructor (moduleName: string) {
super(moduleName)
}
/**
* Patches HTTP incoming and outcoming request functions.
*/
protected applyPatch () {
this.logger.debug('applying patch to %s@%s', this.moduleName, this.version)
shimmer.wrap(
this.moduleExports, 'request', this.getPatchOutgoingRequestFunction())
// In Node 8, http.get calls a private request method, therefore we patch it
// here too.
if (semver.satisfies(this.version, '>=8.0.0')) {
shimmer.wrap(
this.moduleExports, 'get', () => {
// Re-implement http.get. This needs to be done (instead of using
// makeRequestTrace to patch it) because we need to set the trace
// context header before the returned ClientRequest is ended.
// The Node.js docs state that the only differences between request and
// get are that (1) get defaults to the HTTP GET method and (2) the
// returned request object is ended immediately.
// The former is already true (at least in supported Node versions up to
// v9), so we simply follow the latter.
// Ref:
// https://nodejs.org/dist/latest/docs/api/http.html#http_http_get_options_callback
// https://github.com/googleapis/cloud-trace-nodejs/blob/master/src/plugins/plugin-http.ts#L198
return function getTrace (options, callback) {
const req = httpModule.request(options, callback)
req.end()
return req
}
})
}
if (this.moduleExports && this.moduleExports.Server &&
this.moduleExports.Server.prototype) {
shimmer.wrap(
this.moduleExports.Server.prototype, 'emit',
this.getPatchIncomingRequestFunction())
} else {
this.logger.error(
'Could not apply patch to %s.emit. Interface is not as expected.',
this.moduleName)
}
return this.moduleExports
}
/** Unpatches all HTTP patched function. */
protected applyUnpatch (): void {
shimmer.unwrap(this.moduleExports, 'request')
if (semver.satisfies(this.version, '>=8.0.0')) {
shimmer.unwrap(this.moduleExports, 'get')
}
if (this.moduleExports && this.moduleExports.Server &&
this.moduleExports.Server.prototype) {
shimmer.unwrap(this.moduleExports.Server.prototype, 'emit')
}
}
/**
* Check whether the given request is ignored by configuration
* @param url URL of request
* @param request Request to inspect
* @param list List of ignore patterns
*/
protected isIgnored<T> (
url: string, request: T, list: Array<IgnoreMatcher<T>>): boolean {
if (!list) {
// No ignored urls - trace everything
return false
}
for (const pattern of list) {
if (this.isSatisfyPattern(url, request, pattern)) {
return true
}
}
return false
}
/**
* Check whether the given request match pattern
* @param url URL of request
* @param request Request to inspect
* @param pattern Match pattern
*/
protected isSatisfyPattern<T> (
url: string, request: T, pattern: IgnoreMatcher<T>): boolean {
if (typeof pattern === 'string') {
return pattern === url
} else if (pattern instanceof RegExp) {
return pattern.test(url)
} else if (typeof pattern === 'function') {
return pattern(url, request)
} else {
throw new TypeError('Pattern is in unsupported datatype')
}
}
/**
* Creates spans for incoming requests, restoring spans' context if applied.
*/
protected getPatchIncomingRequestFunction () {
return (original: (event: string) => boolean) => {
const plugin = this
// This function's signature is that of an event listener, which can have
// any number of variable-type arguments.
// tslint:disable-next-line:no-any
return function incomingRequest (event: string, ...args: any[]): boolean {
// Only traces request events
if (event !== 'request') {
return original.apply(this, arguments)
}
const request: httpModule.IncomingMessage = args[0]
const response: httpModule.ServerResponse = args[1]
// @ts-ignore
const path = url.parse(request.url).pathname
plugin.logger.debug('%s plugin incomingRequest', plugin.moduleName)
// @ts-ignore
if (plugin.isIgnored(path, request, plugin.options.ignoreIncomingPaths)) {
return original.apply(this, arguments)
}
const propagation = plugin.tracer.propagation
const headers = request.headers
const getter: HeaderGetter = {
getHeader (name: string) {
return headers[name]
}
}
const context = propagation ? propagation.extract(getter) : null
const traceOptions: TraceOptions = {
// @ts-ignore
name: path,
kind: SpanKind.SERVER,
spanContext: context !== null ? context : undefined
}
return plugin.createSpan(traceOptions, rootSpan => {
if (!rootSpan) return original.apply(this, arguments)
plugin.tracer.wrapEmitter(request)
plugin.tracer.wrapEmitter(response)
// Wraps end (inspired by:
// https://github.com/GoogleCloudPlatform/cloud-trace-nodejs/blob/master/src/plugins/plugin-connect.ts#L75)
const originalEnd = response.end
response.end = function (this: httpModule.ServerResponse) {
response.end = originalEnd
const returned = response.end.apply(this, arguments)
const requestUrl = url.parse(request.url || 'localhost')
const host = headers.host || 'localhost'
const userAgent =
(headers['user-agent'] || headers['User-Agent']) as string
rootSpan.addAttribute(
HttpPlugin.ATTRIBUTE_HTTP_HOST,
host.replace(/^(.*)(\:[0-9]{1,5})/, '$1'))
rootSpan.addAttribute(
HttpPlugin.ATTRIBUTE_HTTP_METHOD, request.method || 'GET')
rootSpan.addAttribute(
HttpPlugin.ATTRIBUTE_HTTP_PATH, `${requestUrl.pathname}`)
let route = `${requestUrl.path}`
const middlewareStack: string[] = request[kMiddlewareStack]
if (middlewareStack) {
route = middlewareStack
.filter(path => path !== '/')
.map(path => {
return path[0] === '/' ? path : '/' + path
}).join('')
}
rootSpan.addAttribute(HttpPlugin.ATTRIBUTE_HTTP_ROUTE, route)
rootSpan.addAttribute(
HttpPlugin.ATTRIBUTE_HTTP_USER_AGENT, userAgent)
rootSpan.addAttribute(
HttpPlugin.ATTRIBUTE_HTTP_STATUS_CODE,
response.statusCode.toString())
rootSpan.setStatus(HttpPlugin.convertTraceStatus(response.statusCode))
// Message Event ID is not defined
rootSpan.addMessageEvent(
MessageEventType.RECEIVED, uuid.v4().split('-').join(''))
rootSpan.end()
return returned
}
return original.apply(this, arguments)
})
}
}
}
/**
* Creates spans for outgoing requests, sending spans' context for distributed
* tracing.
*/
protected getPatchOutgoingRequestFunction () {
return (original: Func<httpModule.ClientRequest>): Func<
httpModule.ClientRequest> => {
const plugin = this
const kind = plugin.moduleName === 'https' ? 'HTTPS' : 'HTTP'
return function outgoingRequest (
options: httpModule.RequestOptions | string,
callback): httpModule.ClientRequest {
if (!options) {
return original.apply(this, arguments)
}
// Makes sure the url is an url object
let pathname = ''
let method = 'GET'
let origin = ''
if (typeof (options) === 'string') {
const parsedUrl = url.parse(options)
options = parsedUrl
pathname = parsedUrl.pathname || '/'
origin = `${parsedUrl.protocol || 'http:'}//${parsedUrl.host}`
} else {
// Do not trace ourselves
if (options.headers &&
options.headers['x-opencensus-outgoing-request']) {
plugin.logger.debug(
'header with "x-opencensus-outgoing-request" - do not trace')
return original.apply(this, arguments)
}
try {
pathname = (options as url.URL).pathname || ''
if (pathname.length === 0 && typeof options.path === 'string') {
pathname = url.parse(options.path).pathname || ''
}
method = options.method || 'GET'
origin = `${options.protocol || 'http:'}//${options.host}`
} catch (e) {
return original.apply(this, arguments)
}
}
const request: httpModule.ClientRequest =
original.apply(this, arguments)
if (plugin.isIgnored(origin + pathname, request, plugin.options.ignoreOutgoingUrls)) {
return request
}
plugin.tracer.wrapEmitter(request)
plugin.logger.debug('%s plugin outgoingRequest', plugin.moduleName)
const traceOptions = {
name: `${kind.toLowerCase()}-${(method || 'GET').toLowerCase()}`,
kind: SpanKind.CLIENT
}
// Checks if this outgoing request is part of an operation by checking
// if there is a current root span, if so, we create a child span. In
// case there is no root span, this means that the outgoing request is
// the first operation, therefore we create a root span.
if (!plugin.tracer.currentRootSpan) {
plugin.logger.debug('outgoingRequest starting a root span')
return plugin.tracer.startRootSpan(
traceOptions,
plugin.getMakeRequestTraceFunction(request, options, plugin))
} else {
plugin.logger.debug('outgoingRequest starting a child span')
const span = plugin.tracer.startChildSpan(
traceOptions.name, traceOptions.kind)
return (plugin.getMakeRequestTraceFunction(request, options, plugin))(
span)
}
}
}
}
/**
* Injects span's context to header for distributed tracing and finshes the
* span when the response is finished.
* @param original The original patched function.
* @param options The arguments to the original function.
*/
private getMakeRequestTraceFunction (
request: httpModule.ClientRequest, options: httpModule.RequestOptions,
plugin: HttpPlugin): Func<httpModule.ClientRequest> {
return (span: Span): httpModule.ClientRequest => {
plugin.logger.debug('makeRequestTrace')
if (!span) {
plugin.logger.debug('makeRequestTrace span is null')
return request
}
const setter: HeaderSetter = {
setHeader (name: string, value: string) {
// If outgoing request headers contain the "Expect" header, the returned
// ClientRequest will throw an error if any new headers are added. For this
// reason, only in this scenario, we opt to clone the options object to
// inject the trace context header instead of using ClientRequest#setHeader.
// (We don't do this generally because cloning the options object is an
// expensive operation.)
if (plugin.hasExpectHeader(options) && options.headers) {
// @ts-ignore
if (options.__cloned !== true) {
options = Object.assign({}, options) as httpModule.ClientRequestArgs
options.headers = Object.assign({}, options.headers)
// @ts-ignore
options.__cloned = true
}
// Inject the trace context header.
options.headers[name] = value
} else {
request.setHeader(name, value)
}
}
}
const propagation = plugin.tracer.propagation
if (propagation) {
propagation.inject(setter, span.spanContext)
}
request.on('response', (response: httpModule.IncomingMessage) => {
plugin.tracer.wrapEmitter(response)
plugin.logger.debug('outgoingRequest on response()')
response.on('end', () => {
plugin.logger.debug('outgoingRequest on end()')
const method = response.method ? response.method : 'GET'
const headers = options.headers
const userAgent =
headers ? (headers['user-agent'] || headers['User-Agent']) : null
if (options.host || options.hostname) {
const value = options.host || options.hostname
span.addAttribute(HttpPlugin.ATTRIBUTE_HTTP_HOST, `${value}`)
}
span.addAttribute(HttpPlugin.ATTRIBUTE_HTTP_METHOD, method)
span.addAttribute(HttpPlugin.ATTRIBUTE_HTTP_PATH, `${options.path}`)
span.addAttribute(HttpPlugin.ATTRIBUTE_HTTP_ROUTE, `${options.path}`)
if (userAgent) {
span.addAttribute(
HttpPlugin.ATTRIBUTE_HTTP_USER_AGENT, userAgent.toString())
}
span.addAttribute(HttpPlugin.ATTRIBUTE_HTTP_STATUS_CODE, `${response.statusCode}`)
span.setStatus(HttpPlugin.convertTraceStatus(response.statusCode || 0))
// Message Event ID is not defined
span.addMessageEvent(MessageEventType.SENT, uuid.v4().split('-').join(''))
span.end()
})
response.on('error', error => {
span.addAttribute(HttpPlugin.ATTRIBUTE_HTTP_ERROR_NAME, error.name)
span.addAttribute(
HttpPlugin.ATTRIBUTE_HTTP_ERROR_MESSAGE, error.message)
span.setStatus(CanonicalCode.UNKNOWN)
span.end()
})
})
request.on('error', error => {
span.addAttribute(HttpPlugin.ATTRIBUTE_HTTP_ERROR_NAME, error.name)
span.addAttribute(
HttpPlugin.ATTRIBUTE_HTTP_ERROR_MESSAGE, error.message)
span.setStatus(CanonicalCode.UNKNOWN)
span.end()
})
plugin.logger.debug('makeRequestTrace return request')
return request
}
}
private createSpan<T> (options: TraceOptions, fn: (span: Span) => T): T {
const forceChildspan = this.options.createSpanWithNet === true
if (forceChildspan) {
const span = this.tracer.startChildSpan(options.name, options.kind)
return fn(span)
} else {
return this.tracer.startRootSpan(options, fn)
}
}
/**
* Converts an HTTP status code to an OpenCensus Trace status code.
* @param statusCode The HTTP status code to convert.
*/
static convertTraceStatus (statusCode: number): number {
if (statusCode < 200 || statusCode > 504) {
return TraceStatusCodes.UNKNOWN
} else if (statusCode >= 200 && statusCode < 400) {
return TraceStatusCodes.OK
} else {
switch (statusCode) {
case (400):
return TraceStatusCodes.INVALID_ARGUMENT
case (504):
return TraceStatusCodes.DEADLINE_EXCEEDED
case (404):
return TraceStatusCodes.NOT_FOUND
case (403):
return TraceStatusCodes.PERMISSION_DENIED
case (401):
return TraceStatusCodes.UNAUTHENTICATED
case (429):
return TraceStatusCodes.RESOURCE_EXHAUSTED
case (501):
return TraceStatusCodes.UNIMPLEMENTED
case (503):
return TraceStatusCodes.UNAVAILABLE
default:
return TraceStatusCodes.UNKNOWN
}
}
}
/**
* Returns whether the Expect header is on the given options object.
* @param options Options for http.request.
*/
hasExpectHeader (options: httpModule.ClientRequestArgs | url.URL): boolean {
return !!(
(options as httpModule.ClientRequestArgs).headers &&
(options as httpModule.ClientRequestArgs).headers!.Expect)
}
}
/**
* An enumeration of OpenCensus Trace status codes.
*/
export enum TraceStatusCodes {
UNKNOWN = 2,
OK = 0,
INVALID_ARGUMENT = 3,
DEADLINE_EXCEEDED = 4,
NOT_FOUND = 5,
PERMISSION_DENIED = 7,
UNAUTHENTICATED = 16,
RESOURCE_EXHAUSTED = 8,
UNIMPLEMENTED = 12,
UNAVAILABLE = 14
}
export const plugin = new HttpPlugin('http')