diff --git a/CHANGELOG.md b/CHANGELOG.md index d5535a598..ea34a3b18 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to this project will be documented in this file. - Exporter/Stats/Stackdriver: Add support for exemplar - exporter-stackdriver: Add support the credentials option used for authentication instead of your application default credentials +- Add support for HTTP tags propagation. ## 0.0.13 - 2019-05-20 - Exporter/Stats/Prometheus: Fix missing tags for HTTP metrics diff --git a/packages/opencensus-instrumentation-http/src/http.ts b/packages/opencensus-instrumentation-http/src/http.ts index d945f38e1..73bf55086 100644 --- a/packages/opencensus-instrumentation-http/src/http.ts +++ b/packages/opencensus-instrumentation-http/src/http.ts @@ -14,11 +14,12 @@ * limitations under the License. */ -import {BasePlugin, CanonicalCode, Func, HeaderGetter, HeaderSetter, MessageEventType, Span, SpanKind, TagMap, TagTtl, TraceOptions} from '@opencensus/core'; -import {ClientRequest, ClientResponse, IncomingMessage, request, RequestOptions, ServerResponse} from 'http'; +import {BasePlugin, CanonicalCode, deserializeTextFormat, Func, HeaderGetter, HeaderSetter, MessageEventType, serializeTextFormat, Span, SpanKind, TagMap, TagTtl, TraceOptions} from '@opencensus/core'; +import {ClientRequest, ClientResponse, IncomingHttpHeaders, IncomingMessage, request, RequestOptions, ServerResponse} from 'http'; 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'; @@ -35,6 +36,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 { @@ -216,7 +219,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.getTagContext(headers) || new TagMap(); rootSpan.addAttribute( HttpPlugin.ATTRIBUTE_HTTP_HOST, @@ -407,7 +410,18 @@ export class HttpPlugin extends BasePlugin { const userAgent = headers ? (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'; @@ -518,6 +532,20 @@ export class HttpPlugin extends BasePlugin { } } + /** + * 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 getTagContext(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 976735fa4..5993beab9 100644 --- a/packages/opencensus-instrumentation-http/test/test-http.ts +++ b/packages/opencensus-instrumentation-http/test/test-http.ts @@ -20,7 +20,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'; @@ -135,10 +134,18 @@ function assertCustomAttribute( } function assertClientStats( - testExporter: TestExporter, httpStatusCode: number, httpMethod: string) { + testExporter: TestExporter, httpStatusCode: number, 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( @@ -150,11 +157,18 @@ function assertClientStats( 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( @@ -269,7 +283,6 @@ describe('HttpPlugin', () => { }); } - it('should create a child span for GET requests', () => { const testPath = '/outgoing/rootSpan/childs/1'; doNock(urlHost, testPath, 200, 'Ok'); @@ -290,6 +303,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]}`, @@ -449,6 +516,38 @@ describe('HttpPlugin', () => { }); }); + 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=' +