diff --git a/packages/opentelemetry-exporter-zipkin/package.json b/packages/opentelemetry-exporter-zipkin/package.json index 610acda179..3124d0b7ad 100644 --- a/packages/opentelemetry-exporter-zipkin/package.json +++ b/packages/opentelemetry-exporter-zipkin/package.json @@ -9,6 +9,7 @@ "tdd": "yarn test -- --watch-extensions ts --watch", "clean": "rimraf build/*", "check": "gts check", + "codecov": "nyc report --reporter=json && codecov -f coverage/*.json -p ../../", "compile": "tsc -p .", "fix": "gts fix" }, @@ -35,10 +36,12 @@ }, "devDependencies": { "@types/mocha": "^5.2.7", + "@types/nock": "^10.0.3", "@types/node": "^12.6.9", "codecov": "^3.5.0", "gts": "^1.1.0", "mocha": "^6.2.0", + "nock": "^10.0.6", "nyc": "^14.1.1", "tslint-microsoft-contrib": "^6.2.0", "tslint-consistent-codestyle":"^1.15.1", @@ -47,5 +50,8 @@ "typescript": "^3.5.3" }, "dependencies": { + "@opentelemetry/core": "^0.0.1", + "@opentelemetry/basic-tracer": "^0.0.1", + "@opentelemetry/types": "^0.0.1" } } diff --git a/packages/opentelemetry-exporter-zipkin/src/index.ts b/packages/opentelemetry-exporter-zipkin/src/index.ts index ae225f6b52..36741d5455 100644 --- a/packages/opentelemetry-exporter-zipkin/src/index.ts +++ b/packages/opentelemetry-exporter-zipkin/src/index.ts @@ -13,3 +13,5 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + +export * from './zipkin'; diff --git a/packages/opentelemetry-exporter-zipkin/src/transform.ts b/packages/opentelemetry-exporter-zipkin/src/transform.ts new file mode 100644 index 0000000000..256ccd6158 --- /dev/null +++ b/packages/opentelemetry-exporter-zipkin/src/transform.ts @@ -0,0 +1,95 @@ +/*! + * Copyright 2019, OpenTelemetry 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 + * + * https://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 * as types from '@opentelemetry/types'; +import { ReadableSpan } from '@opentelemetry/basic-tracer'; +import { hrTimeToMilliseconds } from '@opentelemetry/core'; +import * as zipkinTypes from './types'; + +const ZIPKIN_SPAN_KIND_MAPPING = { + [types.SpanKind.CLIENT]: zipkinTypes.SpanKind.CLIENT, + [types.SpanKind.SERVER]: zipkinTypes.SpanKind.SERVER, + [types.SpanKind.CONSUMER]: zipkinTypes.SpanKind.CONSUMER, + [types.SpanKind.PRODUCER]: zipkinTypes.SpanKind.PRODUCER, + // When absent, the span is local. + [types.SpanKind.INTERNAL]: undefined, +}; + +export const statusCodeTagName = 'ot.status_code'; +export const statusDescriptionTagName = 'ot.status_description'; + +/** + * Translate OpenTelemetry ReadableSpan to ZipkinSpan format + * @param span Span to be translated + */ +export function toZipkinSpan( + span: ReadableSpan, + serviceName: string, + statusCodeTagName: string, + statusDescriptionTagName: string +): zipkinTypes.Span { + const zipkinSpan: zipkinTypes.Span = { + traceId: span.spanContext.traceId, + parentId: span.parentSpanId, + name: span.name, + id: span.spanContext.spanId, + kind: ZIPKIN_SPAN_KIND_MAPPING[span.kind], + timestamp: hrTimeToMilliseconds(span.startTime), + duration: hrTimeToMilliseconds(span.duration), + localEndpoint: { serviceName }, + tags: _toZipkinTags( + span.attributes, + span.status, + statusCodeTagName, + statusDescriptionTagName + ), + annotations: span.events.length + ? _toZipkinAnnotations(span.events) + : undefined, + }; + + return zipkinSpan; +} + +/** Converts OpenTelemetry Attributes and Status to Zipkin Tags format. */ +export function _toZipkinTags( + attributes: types.Attributes, + status: types.Status, + statusCodeTagName: string, + statusDescriptionTagName: string +): zipkinTypes.Tags { + const tags: { [key: string]: string } = {}; + for (const key of Object.keys(attributes)) { + tags[key] = String(attributes[key]); + } + tags[statusCodeTagName] = String(types.CanonicalCode[status.code]); + if (status.message) { + tags[statusDescriptionTagName] = status.message; + } + return tags; +} + +/** + * Converts OpenTelemetry Events to Zipkin Annotations format. + */ +export function _toZipkinAnnotations( + events: types.TimedEvent[] +): zipkinTypes.Annotation[] { + return events.map(event => ({ + timestamp: hrTimeToMilliseconds(event.time), + value: event.name, + })); +} diff --git a/packages/opentelemetry-exporter-zipkin/src/types.ts b/packages/opentelemetry-exporter-zipkin/src/types.ts new file mode 100644 index 0000000000..f04d33e28b --- /dev/null +++ b/packages/opentelemetry-exporter-zipkin/src/types.ts @@ -0,0 +1,180 @@ +/*! + * Copyright 2019, OpenTelemetry 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 + * + * https://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 * as types from '@opentelemetry/types'; + +/** + * Exporter config + */ +export interface ExporterConfig { + logger?: types.Logger; + serviceName: string; + url?: string; + // Initiates a request with spans in memory to the backend. + forceFlush?: boolean; + // Optional mapping overrides for OpenTelemetry status code and description. + statusCodeTagName?: string; + statusDescriptionTagName?: string; +} + +/** + * Zipkin Span + * @see https://github.com/openzipkin/zipkin-api/blob/master/zipkin2-api.yaml + */ +export interface Span { + /** + * Trace identifier, set on all spans within it. + */ + traceId: string; + /** + * The logical operation this span represents in lowercase (e.g. rpc method). + * Leave absent if unknown. + */ + name: string; + /** + * The parent span ID or absent if this the root span in a trace. + */ + parentId?: string; + /** + * Unique 64bit identifier for this operation within the trace. + */ + id: string; + /** + * When present, kind clarifies timestamp, duration and remoteEndpoint. + * When absent, the span is local or incomplete. + */ + kind?: SpanKind; + /** + * Epoch microseconds of the start of this span, possibly absent if + * incomplete. + */ + timestamp: number; + /** + * Duration in microseconds of the critical path, if known. + */ + duration: number; + /** + * True is a request to store this span even if it overrides sampling policy. + * This is true when the `X-B3-Flags` header has a value of 1. + */ + debug?: boolean; + /** + * True if we are contributing to a span started by another tracer (ex on a + * different host). + */ + shared?: boolean; + /** + * The host that recorded this span, primarily for query by service name. + */ + localEndpoint: Endpoint; + /** + * Associates events that explain latency with the time they happened. + */ + annotations?: Annotation[]; + /** + * Tags give your span context for search, viewing and analysis. + */ + tags: Tags; + /** + * TODO: `remoteEndpoint`, do we need to support it? + * When an RPC (or messaging) span, indicates the other side of the + * connection. + */ +} + +/** + * Associates an event that explains latency with a timestamp. + * Unlike log statements, annotations are often codes. Ex. "ws" for WireSend + * Zipkin v1 core annotations such as "cs" and "sr" have been replaced with + * Span.Kind, which interprets timestamp and duration. + */ +export interface Annotation { + /** + * Epoch microseconds of this event. + * For example, 1502787600000000 corresponds to 2017-08-15 09:00 UTC + */ + timestamp: number; + /** + * Usually a short tag indicating an event, like "error" + * While possible to add larger data, such as garbage collection details, low + * cardinality event names both keep the size of spans down and also are easy + * to search against. + */ + value: string; +} + +/** + * The network context of a node in the service graph. + */ +export interface Endpoint { + /** + * Lower-case label of this node in the service graph, such as "favstar". + * Leave absent if unknown. + * This is a primary label for trace lookup and aggregation, so it should be + * intuitive and consistent. Many use a name from service discovery. + */ + serviceName?: string; + /** + * The text representation of the primary IPv4 address associated with this + * connection. Ex. 192.168.99.100 Absent if unknown. + */ + ipv4?: string; + /** + * The text representation of the primary IPv6 address associated with a + * connection. Ex. 2001:db8::c001 Absent if unknown. + * Prefer using the ipv4 field for mapped addresses. + */ + port?: number; +} + +/** + * Adds context to a span, for search, viewing and analysis. + * For example, a key "your_app.version" would let you lookup traces by version. + * A tag "sql.query" isn't searchable, but it can help in debugging when viewing + * a trace. + */ +export interface Tags { + [tagKey: string]: unknown; +} + +/** + * When present, kind clarifies timestamp, duration and remoteEndpoint. When + * absent, the span is local or incomplete. Unlike client and server, there + * is no direct critical path latency relationship between producer and + * consumer spans. + * `CLIENT` + * timestamp is the moment a request was sent to the server. + * duration is the delay until a response or an error was received. + * remoteEndpoint is the server. + * `SERVER` + * timestamp is the moment a client request was received. + * duration is the delay until a response was sent or an error. + * remoteEndpoint is the client. + * `PRODUCER` + * timestamp is the moment a message was sent to a destination. + * duration is the delay sending the message, such as batching. + * remoteEndpoint is the broker. + * `CONSUMER` + * timestamp is the moment a message was received from an origin. + * duration is the delay consuming the message, such as from backlog. + * remoteEndpoint - Represents the broker. Leave serviceName absent if unknown. + */ +export enum SpanKind { + CLIENT = 'CLIENT', + SERVER = 'SERVER', + CONSUMER = 'CONSUMER', + PRODUCER = 'PRODUCER', +} diff --git a/packages/opentelemetry-exporter-zipkin/src/zipkin.ts b/packages/opentelemetry-exporter-zipkin/src/zipkin.ts new file mode 100644 index 0000000000..6449ad23d8 --- /dev/null +++ b/packages/opentelemetry-exporter-zipkin/src/zipkin.ts @@ -0,0 +1,170 @@ +/*! + * Copyright 2019, OpenTelemetry 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 + * + * https://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 * as types from '@opentelemetry/types'; +import * as http from 'http'; +import * as https from 'https'; +import * as url from 'url'; +import { NoopLogger } from '@opentelemetry/core'; +import { + SpanExporter, + ReadableSpan, + ExportResult, +} from '@opentelemetry/basic-tracer'; + +import * as zipkinTypes from './types'; +import { + toZipkinSpan, + statusCodeTagName, + statusDescriptionTagName, +} from './transform'; + +/** + * Zipkin Exporter + */ +export class ZipkinExporter implements SpanExporter { + static readonly DEFAULT_URL = 'http://localhost:9411/api/v2/spans'; + + private readonly _forceFlush: boolean; + private readonly _logger: types.Logger; + private readonly _serviceName: string; + private readonly _statusCodeTagName: string; + private readonly _statusDescriptionTagName: string; + private readonly _reqOpts: http.RequestOptions; + + constructor(config: zipkinTypes.ExporterConfig) { + const urlStr = config.url || ZipkinExporter.DEFAULT_URL; + const urlOpts = url.parse(urlStr); + + this._forceFlush = config.forceFlush || true; + this._logger = config.logger || new NoopLogger(); + this._reqOpts = Object.assign( + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }, + urlOpts + ); + this._serviceName = config.serviceName; + this._statusCodeTagName = config.statusCodeTagName || statusCodeTagName; + this._statusDescriptionTagName = + config.statusDescriptionTagName || statusDescriptionTagName; + } + + /** + * Export spans. + */ + export( + spans: ReadableSpan[], + resultCallback: (result: ExportResult) => void + ) { + this._logger.debug('Zipkin exporter export'); + // @todo: buffer spans (batch based on both time and max number) + return this._sendSpans(spans, resultCallback); + } + + /** + * Shutdown exporter. Noop operation in this exporter. + */ + shutdown() { + this._logger.debug('Zipkin exporter shutdown'); + // Make an optimistic flush. + if (this._forceFlush) { + // @todo get spans from span processor (batch) + this._sendSpans([]); + } + } + + /** + * Transforms an OpenTelemetry span to a Zipkin span. + */ + private _toZipkinSpan(span: ReadableSpan): zipkinTypes.Span { + return toZipkinSpan( + span, + this._serviceName, + this._statusCodeTagName, + this._statusDescriptionTagName + ); + } + + /** + * Transform spans and sends to Zipkin service. + */ + private _sendSpans( + spans: ReadableSpan[], + done?: (result: ExportResult) => void + ) { + const zipkinSpans = spans.map(span => this._toZipkinSpan(span)); + return this._send(zipkinSpans, (result: ExportResult) => { + if (done) { + return done(result); + } + }); + } + + /** + * Send spans to the remote Zipkin service. + */ + private _send( + zipkinSpans: zipkinTypes.Span[], + done: (result: ExportResult) => void + ) { + if (zipkinSpans.length === 0) { + this._logger.debug('Zipkin send with empty spans'); + return done(ExportResult.SUCCESS); + } + + const { request } = this._reqOpts.protocol === 'http:' ? http : https; + const req = request(this._reqOpts, (res: http.IncomingMessage) => { + let rawData = ''; + res.on('data', chunk => { + rawData += chunk; + }); + res.on('end', () => { + const statusCode = res.statusCode || 0; + this._logger.debug( + 'Zipkin response status code: %d, body: %s', + statusCode, + rawData + ); + + // Consider 2xx and 3xx as success. + if (statusCode < 400) { + return done(ExportResult.SUCCESS); + // Consider 4xx as failed non-retriable. + } else if (statusCode < 500) { + return done(ExportResult.FAILED_NOT_RETRYABLE); + // Consider 5xx as failed retriable. + } else { + return done(ExportResult.FAILED_RETRYABLE); + } + }); + }); + + req.on('error', (err: Error) => { + this._logger.error('Zipkin request error', err); + return done(ExportResult.FAILED_RETRYABLE); + }); + + // Issue request to remote service + const payload = JSON.stringify(zipkinSpans); + this._logger.debug('Zipkin request payload: %s', payload); + req.write(payload, 'utf8'); + req.end(); + } +} diff --git a/packages/opentelemetry-exporter-zipkin/test/e2e.test.ts b/packages/opentelemetry-exporter-zipkin/test/e2e.test.ts new file mode 100644 index 0000000000..643a06a74c --- /dev/null +++ b/packages/opentelemetry-exporter-zipkin/test/e2e.test.ts @@ -0,0 +1,19 @@ +/** + * Copyright 2019, OpenTelemetry 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 + * + * https://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. + */ + +describe('Zipkin Exporter E2E', () => { + it('should report spans to Zipkin server'); +}); diff --git a/packages/opentelemetry-exporter-zipkin/test/transform.test.ts b/packages/opentelemetry-exporter-zipkin/test/transform.test.ts new file mode 100644 index 0000000000..a865e29380 --- /dev/null +++ b/packages/opentelemetry-exporter-zipkin/test/transform.test.ts @@ -0,0 +1,283 @@ +/** + * Copyright 2019, OpenTelemetry 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 + * + * https://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 * as assert from 'assert'; +import * as types from '@opentelemetry/types'; +import { Span, BasicTracer } from '@opentelemetry/basic-tracer'; +import { + NoopLogger, + hrTimeToMilliseconds, + hrTimeDuration, +} from '@opentelemetry/core'; +import { + toZipkinSpan, + _toZipkinTags, + _toZipkinAnnotations, + statusCodeTagName, + statusDescriptionTagName, +} from '../src/transform'; +import * as zipkinTypes from '../src/types'; + +const logger = new NoopLogger(); +const tracer = new BasicTracer({ + logger, +}); +const parentId = '5c1c63257de34c67'; +const spanContext: types.SpanContext = { + traceId: 'd4cda95b652f4a1592b449d5929fda1b', + spanId: '6e0c63257de34c92', + traceFlags: types.TraceFlags.SAMPLED, +}; + +describe('transform', () => { + describe('toZipkinSpan', () => { + it('should convert an OpenTelemetry span to a Zipkin span', () => { + const span = new Span( + tracer, + 'my-span', + spanContext, + types.SpanKind.SERVER, + parentId + ); + span.setAttributes({ + key1: 'value1', + key2: 'value2', + }); + span.addEvent('my-event', { key3: 'value3' }); + span.end(); + + const zipkinSpan = toZipkinSpan( + span.toReadableSpan(), + 'my-service', + statusCodeTagName, + statusDescriptionTagName + ); + assert.deepStrictEqual(zipkinSpan, { + kind: 'SERVER', + annotations: [ + { + value: 'my-event', + timestamp: hrTimeToMilliseconds(span.events[0].time), + }, + ], + duration: hrTimeToMilliseconds( + hrTimeDuration(span.startTime, span.endTime) + ), + id: span.spanContext.spanId, + localEndpoint: { + serviceName: 'my-service', + }, + name: span.name, + parentId, + tags: { + key1: 'value1', + key2: 'value2', + [statusCodeTagName]: 'OK', + }, + timestamp: hrTimeToMilliseconds(span.startTime), + traceId: span.spanContext.traceId, + }); + }); + it("should skip parentSpanId if doesn't exist", () => { + const span = new Span( + tracer, + 'my-span', + spanContext, + types.SpanKind.SERVER + ); + span.end(); + + const zipkinSpan = toZipkinSpan( + span.toReadableSpan(), + 'my-service', + statusCodeTagName, + statusDescriptionTagName + ); + assert.deepStrictEqual(zipkinSpan, { + kind: 'SERVER', + annotations: undefined, + duration: hrTimeToMilliseconds( + hrTimeDuration(span.startTime, span.endTime) + ), + id: span.spanContext.spanId, + localEndpoint: { + serviceName: 'my-service', + }, + name: span.name, + parentId: undefined, + tags: { + [statusCodeTagName]: 'OK', + }, + timestamp: hrTimeToMilliseconds(span.startTime), + traceId: span.spanContext.traceId, + }); + }); + // SpanKind mapping tests + [ + { ot: types.SpanKind.CLIENT, zipkin: 'CLIENT' }, + { ot: types.SpanKind.SERVER, zipkin: 'SERVER' }, + { ot: types.SpanKind.CONSUMER, zipkin: 'CONSUMER' }, + { ot: types.SpanKind.PRODUCER, zipkin: 'PRODUCER' }, + { ot: types.SpanKind.INTERNAL, zipkin: undefined }, + ].forEach(item => + it(`should map OpenTelemetry SpanKind ${ + types.SpanKind[item.ot] + } to Zipkin ${item.zipkin}`, () => { + const span = new Span(tracer, 'my-span', spanContext, item.ot); + span.end(); + + const zipkinSpan = toZipkinSpan( + span.toReadableSpan(), + 'my-service', + statusCodeTagName, + statusDescriptionTagName + ); + assert.deepStrictEqual(zipkinSpan, { + kind: item.zipkin, + annotations: undefined, + duration: hrTimeToMilliseconds( + hrTimeDuration(span.startTime, span.endTime) + ), + id: span.spanContext.spanId, + localEndpoint: { + serviceName: 'my-service', + }, + name: span.name, + parentId: undefined, + tags: { + [statusCodeTagName]: 'OK', + }, + timestamp: hrTimeToMilliseconds(span.startTime), + traceId: span.spanContext.traceId, + }); + }) + ); + }); + + describe('_toZipkinTags', () => { + it('should convert OpenTelemetry attributes to Zipkin tags', () => { + const span = new Span( + tracer, + 'my-span', + spanContext, + types.SpanKind.SERVER, + parentId + ); + span.setAttributes({ + key1: 'value1', + key2: 'value2', + }); + const tags: zipkinTypes.Tags = _toZipkinTags( + span.attributes, + span.status, + statusCodeTagName, + statusDescriptionTagName + ); + + assert.deepStrictEqual(tags, { + key1: 'value1', + key2: 'value2', + [statusCodeTagName]: 'OK', + }); + }); + it('should map OpenTelemetry Status.code to a Zipkin tag', () => { + const span = new Span( + tracer, + 'my-span', + spanContext, + types.SpanKind.SERVER, + parentId + ); + const status: types.Status = { + code: types.CanonicalCode.ABORTED, + }; + span.setStatus(status); + span.setAttributes({ + key1: 'value1', + key2: 'value2', + }); + const tags: zipkinTypes.Tags = _toZipkinTags( + span.attributes, + span.status, + statusCodeTagName, + statusDescriptionTagName + ); + + assert.deepStrictEqual(tags, { + key1: 'value1', + key2: 'value2', + [statusCodeTagName]: 'ABORTED', + }); + }); + it('should map OpenTelemetry Status.message to a Zipkin tag', () => { + const span = new Span( + tracer, + 'my-span', + spanContext, + types.SpanKind.SERVER, + parentId + ); + const status: types.Status = { + code: types.CanonicalCode.ABORTED, + message: 'my-message', + }; + span.setStatus(status); + span.setAttributes({ + key1: 'value1', + key2: 'value2', + }); + const tags: zipkinTypes.Tags = _toZipkinTags( + span.attributes, + span.status, + statusCodeTagName, + statusDescriptionTagName + ); + + assert.deepStrictEqual(tags, { + key1: 'value1', + key2: 'value2', + [statusCodeTagName]: 'ABORTED', + [statusDescriptionTagName]: status.message, + }); + }); + }); + + describe('_toZipkinAnnotations', () => { + it('should convert OpenTelemetry events to Zipkin annotations', () => { + const span = new Span( + tracer, + 'my-span', + spanContext, + types.SpanKind.SERVER, + parentId + ); + span.addEvent('my-event1'); + span.addEvent('my-event2', { key1: 'value1' }); + + const annotations = _toZipkinAnnotations(span.events); + assert.deepStrictEqual(annotations, [ + { + value: 'my-event1', + timestamp: hrTimeToMilliseconds(span.events[0].time), + }, + { + value: 'my-event2', + timestamp: hrTimeToMilliseconds(span.events[1].time), + }, + ]); + }); + }); +}); diff --git a/packages/opentelemetry-exporter-zipkin/test/zipkin.test.ts b/packages/opentelemetry-exporter-zipkin/test/zipkin.test.ts new file mode 100644 index 0000000000..9401d00ced --- /dev/null +++ b/packages/opentelemetry-exporter-zipkin/test/zipkin.test.ts @@ -0,0 +1,314 @@ +/** + * Copyright 2019, OpenTelemetry 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 + * + * https://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 * as assert from 'assert'; +import * as nock from 'nock'; +import { ExportResult, ReadableSpan } from '@opentelemetry/basic-tracer'; +import { NoopLogger, hrTimeToMilliseconds } from '@opentelemetry/core'; +import * as types from '@opentelemetry/types'; +import { ZipkinExporter } from '../src'; +import * as zipkinTypes from '../src/types'; + +const MICROS_PER_MILLI = 1000; + +function getReadableSpan() { + const startTime = 1566156729709; + const duration = 2000; + const readableSpan: ReadableSpan = { + name: 'my-span', + kind: types.SpanKind.INTERNAL, + spanContext: { + traceId: 'd4cda95b652f4a1592b449d5929fda1b', + spanId: '6e0c63257de34c92', + }, + startTime: [startTime, 0], + endTime: [startTime + duration, 0], + duration: [duration, 0], + status: { + code: types.CanonicalCode.OK, + }, + attributes: {}, + links: [], + events: [], + }; + return readableSpan; +} + +describe('ZipkinExporter', () => { + describe('constructor', () => { + it('should construct an exporter', () => { + const exporter = new ZipkinExporter({ serviceName: 'my-service' }); + assert.ok(typeof exporter.export === 'function'); + assert.ok(typeof exporter.shutdown === 'function'); + }); + it('should construct an exporter with url', () => { + const exporter = new ZipkinExporter({ + serviceName: 'my-service', + url: 'http://localhost', + }); + assert.ok(typeof exporter.export === 'function'); + assert.ok(typeof exporter.shutdown === 'function'); + }); + it('should construct an exporter with logger', () => { + const exporter = new ZipkinExporter({ + serviceName: 'my-service', + logger: new NoopLogger(), + }); + assert.ok(typeof exporter.export === 'function'); + assert.ok(typeof exporter.shutdown === 'function'); + }); + it('should construct an exporter with forceFlush', () => { + const exporter = new ZipkinExporter({ + serviceName: 'my-service', + forceFlush: false, + }); + assert.ok(typeof exporter.export === 'function'); + assert.ok(typeof exporter.shutdown === 'function'); + }); + it('should construct an exporter with statusCodeTagName', () => { + const exporter = new ZipkinExporter({ + serviceName: 'my-service', + statusCodeTagName: 'code', + }); + assert.ok(typeof exporter.export === 'function'); + assert.ok(typeof exporter.shutdown === 'function'); + }); + it('should construct an exporter with statusDescriptionTagName', () => { + const exporter = new ZipkinExporter({ + serviceName: 'my-service', + statusDescriptionTagName: 'description', + }); + assert.ok(typeof exporter.export === 'function'); + assert.ok(typeof exporter.shutdown === 'function'); + }); + }); + + describe('export', () => { + before(() => { + nock.disableNetConnect(); + }); + + after(() => { + nock.enableNetConnect(); + }); + + it('should skip send with empty array', () => { + const exporter = new ZipkinExporter({ + serviceName: 'my-service', + logger: new NoopLogger(), + }); + + exporter.export([], (result: ExportResult) => { + assert.strictEqual(result, ExportResult.SUCCESS); + }); + }); + + it('should send spans to Zipkin backend and return with Success', () => { + let requestBody: [zipkinTypes.Span]; + const scope = nock('http://localhost:9411') + .post('/api/v2/spans', (body: [zipkinTypes.Span]) => { + requestBody = body; + return true; + }) + .reply(202); + + const parentSpanId = '5c1c63257de34c67'; + const startTime = 1566156729709; + const duration = 2000; + + const span1: ReadableSpan = { + name: 'my-span', + kind: types.SpanKind.INTERNAL, + parentSpanId, + spanContext: { + traceId: 'd4cda95b652f4a1592b449d5929fda1b', + spanId: '6e0c63257de34c92', + }, + startTime: [startTime, 0], + endTime: [startTime + duration, 0], + duration: [duration, 0], + status: { + code: types.CanonicalCode.OK, + }, + attributes: { + key1: 'value1', + key2: 'value2', + }, + links: [], + events: [ + { + name: 'my-event', + time: [startTime + 10, 0], + attributes: { key3: 'value3' }, + }, + ], + }; + const span2: ReadableSpan = { + name: 'my-span', + kind: types.SpanKind.SERVER, + spanContext: { + traceId: 'd4cda95b652f4a1592b449d5929fda1b', + spanId: '6e0c63257de34c92', + }, + startTime: [startTime, 0], + endTime: [startTime + duration, 0], + duration: [duration, 0], + status: { + code: types.CanonicalCode.OK, + }, + attributes: {}, + links: [], + events: [], + }; + + const exporter = new ZipkinExporter({ + serviceName: 'my-service', + logger: new NoopLogger(), + }); + + exporter.export([span1, span2], (result: ExportResult) => { + scope.done(); + assert.strictEqual(result, ExportResult.SUCCESS); + assert.deepStrictEqual(requestBody, [ + // Span 1 + { + annotations: [ + { + value: 'my-event', + timestamp: (startTime + 10) * MICROS_PER_MILLI, + }, + ], + duration: duration * MICROS_PER_MILLI, + id: span1.spanContext.spanId, + localEndpoint: { + serviceName: 'my-service', + }, + name: span1.name, + parentId: parentSpanId, + tags: { + key1: 'value1', + key2: 'value2', + 'ot.status_code': 'OK', + }, + timestamp: startTime * MICROS_PER_MILLI, + traceId: span1.spanContext.traceId, + }, + // Span 2 + { + duration: duration * MICROS_PER_MILLI, + id: span2.spanContext.spanId, + kind: 'SERVER', + localEndpoint: { + serviceName: 'my-service', + }, + name: span2.name, + tags: { + 'ot.status_code': 'OK', + }, + timestamp: hrTimeToMilliseconds([startTime, 0]), + traceId: span2.spanContext.traceId, + }, + ]); + }); + }); + + it('should support https protocol', () => { + const scope = nock('https://localhost:9411') + .post('/api/v2/spans') + .reply(200); + + const exporter = new ZipkinExporter({ + serviceName: 'my-service', + logger: new NoopLogger(), + url: 'https://localhost:9411/api/v2/spans', + }); + + exporter.export([getReadableSpan()], (result: ExportResult) => { + scope.done(); + assert.strictEqual(result, ExportResult.SUCCESS); + }); + }); + + it('should return FailedNonRetryable with 4xx', () => { + const scope = nock('http://localhost:9411') + .post('/api/v2/spans') + .reply(400); + + const exporter = new ZipkinExporter({ + serviceName: 'my-service', + logger: new NoopLogger(), + }); + + exporter.export([getReadableSpan()], (result: ExportResult) => { + scope.done(); + assert.strictEqual(result, ExportResult.FAILED_NOT_RETRYABLE); + }); + }); + + it('should return FailedRetryable with 5xx', () => { + const scope = nock('http://localhost:9411') + .post('/api/v2/spans') + .reply(500); + + const exporter = new ZipkinExporter({ + serviceName: 'my-service', + logger: new NoopLogger(), + }); + + exporter.export([getReadableSpan()], (result: ExportResult) => { + scope.done(); + assert.strictEqual(result, ExportResult.FAILED_RETRYABLE); + }); + }); + + it('should return FailedRetryable with socket error', () => { + const scope = nock('http://localhost:9411') + .post('/api/v2/spans') + .replyWithError(new Error('My Socket Error')); + + const exporter = new ZipkinExporter({ + serviceName: 'my-service', + logger: new NoopLogger(), + }); + + exporter.export([getReadableSpan()], (result: ExportResult) => { + scope.done(); + assert.strictEqual(result, ExportResult.FAILED_RETRYABLE); + }); + }); + }); + + describe('shutdown', () => { + before(() => { + nock.disableNetConnect(); + }); + + after(() => { + nock.enableNetConnect(); + }); + + // @todo: implement + it('should send by default'); + it('should not send with forceFlush=false', () => { + const exporter = new ZipkinExporter({ + serviceName: 'my-service', + forceFlush: false, + }); + + exporter.shutdown(); + }); + }); +});