diff --git a/CHANGELOG.md b/CHANGELOG.md index 9502021fb..3fd4b11ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ All notable changes to this project will be documented in this file. ## Unreleased +- Add support for HTTP tags propagation. ## 0.0.14 - 2019-06-04 - Exporter/Stats/Stackdriver: Add support for exemplar diff --git a/packages/opencensus-instrumentation-http/src/http.ts b/packages/opencensus-instrumentation-http/src/http.ts index 9b374f454..cedcef71e 100644 --- a/packages/opencensus-instrumentation-http/src/http.ts +++ b/packages/opencensus-instrumentation-http/src/http.ts @@ -26,9 +26,12 @@ import { TagMap, TagTtl, TraceOptions, + serializeTextFormat, + deserializeTextFormat, } from '@opencensus/core'; import { ClientRequest, + IncomingHttpHeaders, IncomingMessage, request, RequestOptions, @@ -37,6 +40,7 @@ import { import * as semver from 'semver'; import * as shimmer from 'shimmer'; import * as url from 'url'; + import * as stats from './http-stats'; import { HttpPluginConfig, IgnoreMatcher } from './types'; @@ -56,6 +60,8 @@ const UNLIMITED_PROPAGATION_MD = { }; const TAG_VALUE_MAX_LENGTH = 255; +/** A correlation context header under which TagMap is stored as a text value */ +export const CORRELATION_CONTEXT = 'Correlation-Context'; /** Http instrumentation plugin for Opencensus */ export class HttpPlugin extends BasePlugin { @@ -261,7 +267,7 @@ export class HttpPlugin extends BasePlugin { const host = headers.host || 'localhost'; const userAgent = (headers['user-agent'] || headers['User-Agent']) as string; - const tags = new TagMap(); + const tags = HttpPlugin.getTagMap(headers) || new TagMap(); rootSpan.addAttribute( HttpPlugin.ATTRIBUTE_HTTP_HOST, @@ -483,7 +489,19 @@ export class HttpPlugin extends BasePlugin { ? headers['user-agent'] || headers['User-Agent'] : null; - const tags = new TagMap(); + // record stats: new RPCs on client-side inherit the tag context from + // the current Context. + const tags = plugin.stats + ? plugin.stats.getCurrentTagContext() + : new TagMap(); + if (tags.tags.size > 0) { + if (plugin.hasExpectHeader(options) && options.headers) { + options.headers[CORRELATION_CONTEXT] = serializeTextFormat(tags); + } else { + request.setHeader(CORRELATION_CONTEXT, serializeTextFormat(tags)); + } + } + tags.set(stats.HTTP_CLIENT_METHOD, { value: method }); const host = options.hostname || options.host || 'localhost'; @@ -602,6 +620,20 @@ export class HttpPlugin extends BasePlugin { } catch (ignore) {} } + /** + * Returns a TagMap on incoming HTTP header if it exists and is well-formed, + * or null otherwise. + * @param headers The incoming HTTP header object from which TagMap should be + * retrieved. + */ + static getTagMap(headers: IncomingHttpHeaders): TagMap | null { + const contextValue = (headers[CORRELATION_CONTEXT.toLocaleLowerCase()] || + headers[CORRELATION_CONTEXT]) as string; + // Entry doesn't exist. + if (!contextValue) return null; + return deserializeTextFormat(contextValue); + } + /** * Returns whether the Expect header is on the given options object. * @param options Options for http.request. diff --git a/packages/opencensus-instrumentation-http/test/test-http.ts b/packages/opencensus-instrumentation-http/test/test-http.ts index ac8ad3855..1773f6975 100644 --- a/packages/opencensus-instrumentation-http/test/test-http.ts +++ b/packages/opencensus-instrumentation-http/test/test-http.ts @@ -38,7 +38,6 @@ import * as http from 'http'; import * as nock from 'nock'; import * as shimmer from 'shimmer'; import * as url from 'url'; - import { HttpPlugin, plugin } from '../src/'; import * as stats from '../src/http-stats'; @@ -183,11 +182,19 @@ function assertCustomAttribute( function assertClientStats( testExporter: TestExporter, httpStatusCode: number, - httpMethod: string + httpMethod: string, + tagCtx?: TagMap ) { const tags = new TagMap(); tags.set(stats.HTTP_CLIENT_METHOD, { value: httpMethod }); tags.set(stats.HTTP_CLIENT_STATUS, { value: `${httpStatusCode}` }); + + if (tagCtx) { + tagCtx.tags.forEach((tagValue: TagValue, tagKey: TagKey) => { + tags.set(tagKey, tagValue); + }); + } + assert.strictEqual(testExporter.registeredViews.length, 8); assert.strictEqual(testExporter.recordedMeasurements.length, 1); assert.strictEqual( @@ -202,12 +209,20 @@ function assertServerStats( testExporter: TestExporter, httpStatusCode: number, httpMethod: string, - path: string + path: string, + tagCtx?: TagMap ) { const tags = new TagMap(); tags.set(stats.HTTP_SERVER_METHOD, { value: httpMethod }); tags.set(stats.HTTP_SERVER_STATUS, { value: `${httpStatusCode}` }); tags.set(stats.HTTP_SERVER_ROUTE, { value: path }); + + if (tagCtx) { + tagCtx.tags.forEach((tagValue: TagValue, tagKey: TagKey) => { + tags.set(tagKey, tagValue); + }); + } + assert.strictEqual(testExporter.registeredViews.length, 8); assert.strictEqual(testExporter.recordedMeasurements.length, 1); assert.strictEqual( @@ -357,6 +372,60 @@ describe('HttpPlugin', () => { }); }); + it('should create a child span for GET requests with tag context', () => { + const testPath = '/outgoing/rootSpan/childs/1'; + doNock(urlHost, testPath, 200, 'Ok'); + const tags = new TagMap(); + tags.set({ name: 'testKey1' }, { value: 'value1' }); + tags.set({ name: 'testKey2' }, { value: 'value2' }); + return globalStats.withTagContext(tags, async () => { + return tracer.startRootSpan( + { name: 'TestRootSpan' }, + async (root: Span) => { + await httpRequest.get(`${urlHost}${testPath}`).then(result => { + assert.ok(root.name.indexOf('TestRootSpan') >= 0); + assert.strictEqual(root.spans.length, 1); + const [span] = root.spans; + assert.ok(span.name.indexOf(testPath) >= 0); + assert.strictEqual(root.traceId, span.traceId); + assertSpanAttributes(span, 200, 'GET', hostName, testPath); + assert.strictEqual(span.messageEvents.length, 1); + const [messageEvent] = span.messageEvents; + assert.strictEqual(messageEvent.type, MessageEventType.SENT); + assert.strictEqual(messageEvent.id, 1); + assertClientStats(testExporter, 200, 'GET', tags); + }); + } + ); + }); + }); + + it('should create a child span for GET requests with empty tag context', () => { + const testPath = '/outgoing/rootSpan/childs/1'; + doNock(urlHost, testPath, 200, 'Ok'); + const tags = new TagMap(); + return globalStats.withTagContext(tags, async () => { + return tracer.startRootSpan( + { name: 'TestRootSpan' }, + async (root: Span) => { + await httpRequest.get(`${urlHost}${testPath}`).then(result => { + assert.ok(root.name.indexOf('TestRootSpan') >= 0); + assert.strictEqual(root.spans.length, 1); + const [span] = root.spans; + assert.ok(span.name.indexOf(testPath) >= 0); + assert.strictEqual(root.traceId, span.traceId); + assertSpanAttributes(span, 200, 'GET', hostName, testPath); + assert.strictEqual(span.messageEvents.length, 1); + const [messageEvent] = span.messageEvents; + assert.strictEqual(messageEvent.type, MessageEventType.SENT); + assert.strictEqual(messageEvent.id, 1); + assertClientStats(testExporter, 200, 'GET'); + }); + } + ); + }); + }); + for (let i = 0; i < httpErrorCodes.length; i++) { it(`should test a child spans for GET requests with http error ${ httpErrorCodes[i] @@ -529,6 +598,84 @@ describe('HttpPlugin', () => { }); }); + it('should handle incoming requests with long request url path', async () => { + const testPath = '/test&code=' + 'a'.repeat(300); + const options = { + host: 'localhost', + path: testPath, + port: serverPort, + headers: { 'User-Agent': 'Android' }, + }; + shimmer.unwrap(http, 'get'); + shimmer.unwrap(http, 'request'); + nock.enableNetConnect(); + + assert.strictEqual(spanVerifier.endedSpans.length, 0); + + await httpRequest.get(options).then(result => { + assert.strictEqual(spanVerifier.endedSpans.length, 1); + assert.ok(spanVerifier.endedSpans[0].name.indexOf(testPath) >= 0); + const [span] = spanVerifier.endedSpans; + assertSpanAttributes( + span, + 200, + 'GET', + 'localhost', + testPath, + 'Android' + ); + assertServerStats( + testExporter, + 200, + 'GET', + '/test&code=' + 'a'.repeat(244) + ); + }); + }); + it('should create a root span for incoming requests with Correlation Context header', async () => { + const testPath = '/incoming/rootSpan/'; + const options = { + host: 'localhost', + path: testPath, + port: serverPort, + headers: { + 'User-Agent': 'Android', + 'Correlation-Context': 'k1=v1,k2=v2', + }, + }; + + const expectedTagsFromHeaders = new TagMap(); + expectedTagsFromHeaders.set({ name: 'k1' }, { value: 'v1' }); + expectedTagsFromHeaders.set({ name: 'k2' }, { value: 'v2' }); + + shimmer.unwrap(http, 'get'); + shimmer.unwrap(http, 'request'); + nock.enableNetConnect(); + + assert.strictEqual(spanVerifier.endedSpans.length, 0); + + await httpRequest.get(options).then(result => { + assert.ok(spanVerifier.endedSpans[0].name.indexOf(testPath) >= 0); + assert.strictEqual(spanVerifier.endedSpans.length, 1); + const [span] = spanVerifier.endedSpans; + assertSpanAttributes( + span, + 200, + 'GET', + 'localhost', + testPath, + 'Android' + ); + assertServerStats( + testExporter, + 200, + 'GET', + testPath, + expectedTagsFromHeaders + ); + }); + }); + it('should handle incoming requests with long request url path', async () => { const testPath = '/test&code=' + 'a'.repeat(300); const options = {