diff --git a/README.md b/README.md index d2c6e8f..8cd8095 100644 --- a/README.md +++ b/README.md @@ -2,41 +2,109 @@ A partial implementation of the [OpenTracing JavaScript API](https://opentracing-javascript.surge.sh) for [honeycomb.io](https://www.honeycomb.io) backend. - -[![homecomb-ui](https://user-images.githubusercontent.com/229881/53273403-ed56fd80-36c1-11e9-95b5-d5277bb621ff.png)](https://ui.honeycomb.io) +[![homecomb-ui](https://user-images.githubusercontent.com/229881/53371218-a1a09000-391d-11e9-9956-8ee2b5d62a0f.png)](https://ui.honeycomb.io) ## Usage ```ts -import { Tracer } from '@zeit/tracing-js'; +import { IncomingMessage, ServerResponse, createServer } from 'http'; +import { Tracer, SpanContext, Tags, DeterministicSampler } from '@zeit/tracing-js'; -const tracer = new Tracer('service-name', { - writeKey: process.env.HONEYCOMB_KEY, - dataset: process.env.HONEYCOMB_DATASET -}); +const tracer = new Tracer( + { + serviceName: 'routing-example', + environment: process.env.ENVIRONMENT, + dc: process.env.DC, + podName: process.env.PODNAME, + hostName: process.env.HOSTNAME, + sampler: new DeterministicSampler(process.env.TRACE_SAMPLE_RATE), + }, + { + writeKey: process.env.HONEYCOMB_KEY!, + dataset: process.env.HONEYCOMB_DATASET!, + }, +); // example child function we wish to trace -async function sleep(ms, parentSpan) { - const span = tracer.startSpan(sleep.name, { childOf: parentSpan }); - return new Promise(resolve => - setTimeout(() => { - span.finish(); - resolve(); - }, ms) - ); +async function sleep(ms: number, childOf: SpanContext) { + const span = tracer.startSpan(sleep.name, { childOf }); + return new Promise(resolve => + setTimeout(() => { + span.finish(); + resolve(); + }, ms), + ); } -// example parent function we wish to trace -async function handler(req, res) { - const span = tracer.startSpan(handler.name); - await sleep(300, span); - console.log('end trace'); +// example child function we wish to trace +async function route(path: string, childOf: SpanContext) { + const span = tracer.startSpan(route.name, { childOf }); + const spanContext = span.context(); + + await sleep(200, spanContext); + + if (!path || path === '/') { span.finish(); -}; + return 'Home page'; + } else if (path === '/next') { + span.finish(); + return 'Next page'; + } + + span.finish(); + throw new Error('Page not found'); +} + +// example parent function we wish to trace +async function handler(req: IncomingMessage, res: ServerResponse) { + const { tags, childOf } = parseRequest(req); + const span = tracer.startSpan(handler.name, { tags, childOf }); + const spanContext = span.context(); + let statusCode = 200; + + try { + const { url = '/' } = req; + await sleep(100, spanContext); + const output = await route(url, spanContext); + res.write(output); + } catch (error) { + statusCode = 500; + tags[Tags.ERROR] = true; + res.write(error.message); + } + + tags[Tags.HTTP_STATUS_CODE] = statusCode; + res.statusCode = statusCode; + res.end(); + span.finish(); +} + +function getFirstHeader(req: IncomingMessage, key: string) { + const value = req.headers[key]; + return Array.isArray(value) ? value[0] : value; +} + +function parseRequest(req: IncomingMessage) { + const tags: { [key: string]: any } = {}; + tags[Tags.HTTP_METHOD] = req.method; + tags[Tags.HTTP_URL] = req.url; + + const priority = getFirstHeader(req, 'x-now-trace-priority'); + if (typeof priority !== 'undefined') { + tags[Tags.SAMPLING_PRIORITY] = Number.parseInt(priority); + } + + let childOf: SpanContext | undefined; + const traceId = getFirstHeader(req, 'x-now-id'); + const parentId = getFirstHeader(req, 'x-now-parent-id'); + if (traceId) { + childOf = new SpanContext(traceId, parentId, tags); + } + + return { tags, childOf }; +} -handler('req', 'res') - .then(() => console.log('done')) - .catch(e => console.error(e)); +createServer(handler).listen(3000); ``` ## Connecting traces across multiple services @@ -45,10 +113,10 @@ You can set a parent trace, even if you don't have a reference to the `Span` obj Instead, you can create a new `SpanContext`. -You'll need the `parentTraceId` and `parentSpanId` (typically found in `req.headers`). +You'll need the `traceId` and `parentSpanId` (typically found in `req.headers`). ```ts -const context = new SpanContext(parentTraceId, parentSpanId); +const context = new SpanContext(traceId, parentSpanId); const childSpan = tracer.startSpan('child', { childOf: context }); // ...do stuff like normal childSpan.finish(); diff --git a/examples/routing-example.ts b/examples/routing-example.ts new file mode 100644 index 0000000..77cc3dc --- /dev/null +++ b/examples/routing-example.ts @@ -0,0 +1,98 @@ +import { IncomingMessage, ServerResponse, createServer } from 'http'; +import { Tracer, SpanContext, Tags, DeterministicSampler } from '../src/index'; + +const tracer = new Tracer( + { + serviceName: 'routing-example', + environment: process.env.ENVIRONMENT, + dc: process.env.DC, + podName: process.env.PODNAME, + hostName: process.env.HOSTNAME, + sampler: new DeterministicSampler(process.env.TRACE_SAMPLE_RATE), + }, + { + writeKey: process.env.HONEYCOMB_KEY!, + dataset: process.env.HONEYCOMB_DATASET!, + }, +); + +// example child function we wish to trace +async function sleep(ms: number, childOf: SpanContext) { + const span = tracer.startSpan(sleep.name, { childOf }); + return new Promise(resolve => + setTimeout(() => { + span.finish(); + resolve(); + }, ms), + ); +} + +// example child function we wish to trace +async function route(path: string, childOf: SpanContext) { + const span = tracer.startSpan(route.name, { childOf }); + const spanContext = span.context(); + + await sleep(200, spanContext); + + if (!path || path === '/') { + span.finish(); + return 'Home page'; + } else if (path === '/next') { + span.finish(); + return 'Next page'; + } + + span.finish(); + throw new Error('Page not found'); +} + +// example parent function we wish to trace +async function handler(req: IncomingMessage, res: ServerResponse) { + const { tags, childOf } = parseRequest(req); + const span = tracer.startSpan(handler.name, { tags, childOf }); + const spanContext = span.context(); + let statusCode = 200; + + try { + const { url = '/' } = req; + await sleep(100, spanContext); + const output = await route(url, spanContext); + res.write(output); + } catch (error) { + statusCode = 500; + tags[Tags.ERROR] = true; + res.write(error.message); + } + + tags[Tags.HTTP_STATUS_CODE] = statusCode; + res.statusCode = statusCode; + res.end(); + span.finish(); +} + +function getFirstHeader(req: IncomingMessage, key: string) { + const value = req.headers[key]; + return Array.isArray(value) ? value[0] : value; +} + +function parseRequest(req: IncomingMessage) { + const tags: { [key: string]: any } = {}; + tags[Tags.HTTP_METHOD] = req.method; + tags[Tags.HTTP_URL] = req.url; + + const priority = getFirstHeader(req, 'x-now-trace-priority'); + if (typeof priority !== 'undefined') { + tags[Tags.SAMPLING_PRIORITY] = Number.parseInt(priority); + } + + let childOf: SpanContext | undefined; + const traceId = getFirstHeader(req, 'x-now-id'); + const parentId = getFirstHeader(req, 'x-now-parent-id'); + if (traceId) { + childOf = new SpanContext(traceId, parentId, tags); + } + + return { tags, childOf }; +} + +createServer(handler).listen(3000); diff --git a/package.json b/package.json index f06276b..423ee2a 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "scripts": { "build": "tsc", "watch": "tsc --watch", - "fmt": "prettier --single-quote --bracket-spacing --trailing-comma all --write './{src,test,types}/**/*.ts'", + "fmt": "prettier --single-quote --bracket-spacing --trailing-comma all --write './{src,test,types,examples}/**/*.ts'", "test": "tape dist/test/**.js" }, "author": "styfle", diff --git a/src/deterministic-sampler.ts b/src/deterministic-sampler.ts index d81d8ad..07d5b49 100644 --- a/src/deterministic-sampler.ts +++ b/src/deterministic-sampler.ts @@ -1,18 +1,34 @@ -import { createHash } from 'crypto'; // TODO: add browser support with Web Crypto // https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/digest +import { createHash } from 'crypto'; +import { SamplerBase } from './shared'; const MAX_UINT32 = Math.pow(2, 32) - 1; -export class DeterministicSampler { +export class DeterministicSampler implements SamplerBase { private upperBound: number; - constructor(sampleRate: number) { - this.upperBound = (MAX_UINT32 / sampleRate) >>> 0; + + /** + * Determinisically sample a trace based on the trace id. + * Each service will share the same trace id so this works + * across multiple services/spans that are part of the same trace. + * @param sampleRate Defaults to 1 (100%). Set to 2 for 50%, 4 for 25%, etc. + */ + constructor(sampleRate: string | number | undefined) { + let rate: number; + if (typeof sampleRate === 'number') { + rate = sampleRate; + } else if (typeof sampleRate === 'string') { + rate = Number.parseInt(sampleRate); + } else { + rate = 1; + } + this.upperBound = (MAX_UINT32 / rate) >>> 0; } - sample(data: string) { - let sum = createHash('SHA1') - .update(data) + sample(traceId: string) { + const sum = createHash('SHA1') + .update(traceId) .digest(); return sum.readUInt32BE(0) <= this.upperBound; } diff --git a/src/index.ts b/src/index.ts index dd945d3..af585ad 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,10 @@ import { Tracer } from './tracer'; +import { Span as PrivateSpan } from './span'; import { SpanContext } from './span-context'; import * as Tags from './tags'; +import { DeterministicSampler } from './deterministic-sampler'; +import { SamplerBase } from './shared'; -export { Tracer, SpanContext, Tags }; +const Span = typeof PrivateSpan; + +export { Tracer, Span, SpanContext, Tags, DeterministicSampler, SamplerBase }; diff --git a/src/shared.ts b/src/shared.ts index 96b6a20..6e4a12e 100644 --- a/src/shared.ts +++ b/src/shared.ts @@ -3,10 +3,23 @@ import { Span } from './span'; import { SpanContext } from './span-context'; import { HoneyOptions } from 'libhoney'; -export type TracerOptions = HoneyOptions | Libhoney; +export interface TracerOptions { + serviceName: string; + environment?: string; + dc?: string; + podName?: string; + hostName?: string; + sampler?: SamplerBase; +} + +export type TracerHoneyOptions = HoneyOptions | Libhoney; export type SpanTags = { [key: string]: any }; export interface SpanOptions { childOf?: SpanContext | Span; tags?: SpanTags; } + +export interface SamplerBase { + sample(data: string): boolean; +} diff --git a/src/span-context.ts b/src/span-context.ts index d5f7faf..8f0a002 100644 --- a/src/span-context.ts +++ b/src/span-context.ts @@ -1,13 +1,14 @@ import { SpanTags } from './shared'; +import { generateId } from './generate-id'; export class SpanContext { constructor( private traceId: string, - private spanId: string, + private spanId: string | undefined, private tags: SpanTags, ) { this.traceId = traceId; - this.spanId = spanId; + this.spanId = spanId || generateId(); this.tags = tags; } toSpanId() { diff --git a/src/span.ts b/src/span.ts index e9a8dc4..337bf98 100644 --- a/src/span.ts +++ b/src/span.ts @@ -1,34 +1,37 @@ import { HoneyEvent } from 'libhoney'; import { SpanContext } from './span-context'; import { generateId } from './generate-id'; -import { SpanTags } from './shared'; +import { SpanTags, SamplerBase } from './shared'; +import { SAMPLING_PRIORITY } from './tags'; export class Span { + private spanId: string; private event: HoneyEvent; private serviceName: string; private name: string; private traceId: string; private parentId: string | undefined; private tags: SpanTags; - private spanId: string; + private sampler: SamplerBase; private start: Date; constructor( event: HoneyEvent, serviceName: string, name: string, - traceId = generateId(), - parentId?: string, - tags?: SpanTags, + traceId: string | undefined, + parentId: string | undefined, + tags: SpanTags, + sampler: SamplerBase, ) { - const spanId = generateId(); + this.spanId = generateId(); this.event = event; this.name = name; this.serviceName = serviceName; - this.traceId = traceId; - this.spanId = spanId; + this.traceId = traceId || generateId(); this.parentId = parentId; - this.tags = tags || {}; + this.tags = tags; + this.sampler = sampler; this.start = new Date(); } @@ -52,7 +55,21 @@ export class Span { this.name = name; } + private isSendable() { + const priority = this.tags[SAMPLING_PRIORITY]; + + if (typeof priority === 'undefined') { + return this.sampler.sample(this.traceId); + } + + return priority > 0; + } + finish() { + if (!this.isSendable()) { + return; + } + const duration = Date.now() - this.start.getTime(); this.event.addField('duration_ms', duration); this.event.addField('name', this.name); diff --git a/src/tags.ts b/src/tags.ts index e7b6dd6..640bf55 100644 --- a/src/tags.ts +++ b/src/tags.ts @@ -20,7 +20,7 @@ export const SPAN_KIND_MESSAGING_CONSUMER = 'consumer'; export const ERROR = 'error'; /** - * COMPONENT (string) ia s low-cardinality identifier of the module, library, + * COMPONENT (string) A low-cardinality identifier of the module, library, * or package that is generating a span. */ export const COMPONENT = 'component'; diff --git a/src/tracer.ts b/src/tracer.ts index e0e712a..1043fe4 100644 --- a/src/tracer.ts +++ b/src/tracer.ts @@ -1,23 +1,31 @@ -import { TracerOptions, SpanOptions } from './shared'; +import { + TracerOptions, + TracerHoneyOptions, + SpanOptions, + SamplerBase, +} from './shared'; import { Span } from './span'; import { SpanContext } from './span-context'; import { SAMPLING_PRIORITY } from './tags'; import Libhoney from 'libhoney'; +import { DeterministicSampler } from './deterministic-sampler'; export class Tracer { - private hny: Libhoney; + private honey: Libhoney; private serviceName: string; + private sampler: SamplerBase; - constructor(serviceName: string, tracerOptions: TracerOptions) { - this.serviceName = serviceName; - if (tracerOptions instanceof Libhoney) { - this.hny = tracerOptions; + constructor(tracerOptions: TracerOptions, honeyOptions: TracerHoneyOptions) { + this.serviceName = tracerOptions.serviceName; + this.sampler = tracerOptions.sampler || new DeterministicSampler(1); + if (honeyOptions instanceof Libhoney) { + this.honey = honeyOptions; } else { - this.hny = new Libhoney(tracerOptions); + this.honey = new Libhoney(honeyOptions); } } startSpan(name: string, spanOptions: SpanOptions = {}) { - const { childOf, tags={} } = spanOptions; + const { childOf, tags = {} } = spanOptions; let traceId: string | undefined; let parentId: string | undefined; let samplingPriority: number | undefined; @@ -41,12 +49,13 @@ export class Tracer { } return new Span( - this.hny.newEvent(), + this.honey.newEvent(), this.serviceName, name, traceId, parentId, tags, + this.sampler, ); } } diff --git a/test/deterministic-sampler.test.ts b/test/deterministic-sampler.test.ts index 7f8e613..a3b3faa 100644 --- a/test/deterministic-sampler.test.ts +++ b/test/deterministic-sampler.test.ts @@ -23,6 +23,12 @@ test('deterministic sampler same result each time', t => { t.equal(first, second); }); +test('deterministic sampler allow 0%', t => { + t.plan(1); + const passed = testSampleRate(0, 1); + t.false(passed); +}); + test('deterministic sampler allow 100%', t => { t.plan(1); const passed = testSampleRate(1, 1); diff --git a/test/span.test.ts b/test/span.test.ts index 31358cb..3636ba2 100644 --- a/test/span.test.ts +++ b/test/span.test.ts @@ -1,6 +1,8 @@ import test from 'tape'; import { Span } from '../src/span'; import { HoneyEvent } from 'libhoney'; +import { DeterministicSampler } from '../src/deterministic-sampler'; +import { SAMPLING_PRIORITY } from '../src/tags'; const noop = () => {}; @@ -9,11 +11,22 @@ test('test span context', t => { const serviceName = 'service name'; const name = 'function name'; const traceId = 'trace123'; + const parentId = undefined; + const tags = {}; + const sampler = new DeterministicSampler(1); const event: HoneyEvent = { addField: noop, send: noop, }; - const span = new Span(event, serviceName, name, traceId); + const span = new Span( + event, + serviceName, + name, + traceId, + parentId, + tags, + sampler, + ); const ctx = span.context(); t.equal(ctx.toTraceId(), traceId); t.notEqual(ctx.toSpanId(), ''); @@ -24,6 +37,9 @@ test('test span setTag', t => { const serviceName = 'service name'; const name = 'function name'; const traceId = 'trace123'; + const parentId = undefined; + const tags = {}; + const sampler = new DeterministicSampler(1); const event: HoneyEvent = { addField: (key: string, value: any) => { if (key === 'tag.key1') { @@ -34,7 +50,15 @@ test('test span setTag', t => { }, send: noop, }; - const span = new Span(event, serviceName, name, traceId); + const span = new Span( + event, + serviceName, + name, + traceId, + parentId, + tags, + sampler, + ); span .setTag('key1', 'value1') .setTag('key2', 'value2') @@ -46,6 +70,9 @@ test('test span addTags', t => { const serviceName = 'service name'; const name = 'function name'; const traceId = 'trace123'; + const parentId = undefined; + const tags = {}; + const sampler = new DeterministicSampler(1); const event: HoneyEvent = { addField: (key: string, value: any) => { if (key === 'tag.key1') { @@ -56,7 +83,15 @@ test('test span addTags', t => { }, send: noop, }; - const span = new Span(event, serviceName, name, traceId); + const span = new Span( + event, + serviceName, + name, + traceId, + parentId, + tags, + sampler, + ); span.addTags({ key1: 'value1', key2: 'value2' }).finish(); }); @@ -66,7 +101,12 @@ test('test span addField', t => { const name = 'function name'; const traceId = 'trace123'; const parentId = 'parent123'; + const tags = {}; + const sampler = new DeterministicSampler(1); const event: HoneyEvent = { + send: () => { + t.true(event.timestamp && event.timestamp > new Date(0)); + }, addField: (key: string, value: any) => { switch (key) { case 'duration_ms': @@ -89,10 +129,96 @@ test('test span addField', t => { break; } }, + }; + const span = new Span( + event, + serviceName, + name, + traceId, + parentId, + tags, + sampler, + ); + setTimeout(() => span.finish(), 50); +}); + +test('test span sample rate 0 should not send', t => { + t.plan(1); + const serviceName = 'service name'; + const name = 'function name'; + const traceId = 'trace123'; + const parentId = 'parent123'; + const event: HoneyEvent = { + addField: noop, send: () => { - t.true(event.timestamp && event.timestamp > new Date(0)); + t.true(false, 'should not send'); }, }; - const span = new Span(event, serviceName, name, traceId, parentId); - setTimeout(() => span.finish(), 50); + const tags = {}; + const sampler = new DeterministicSampler(0); + const span = new Span( + event, + serviceName, + name, + traceId, + parentId, + tags, + sampler, + ); + span.finish(); + t.true(true, 'finish'); +}); + +test('test span sample rate 0, tag priority 1 should send', t => { + t.plan(2); + const serviceName = 'service name'; + const name = 'function name'; + const traceId = 'trace123'; + const parentId = 'parent123'; + const event: HoneyEvent = { + addField: noop, + send: () => { + t.true(true, 'should send'); + }, + }; + const tags = { [SAMPLING_PRIORITY]: 1 }; + const sampler = new DeterministicSampler(0); + const span = new Span( + event, + serviceName, + name, + traceId, + parentId, + tags, + sampler, + ); + span.finish(); + t.true(true, 'finish'); +}); + +test('test span sample rate 1, tag priority 0 should not send', t => { + t.plan(1); + const serviceName = 'service name'; + const name = 'function name'; + const traceId = 'trace123'; + const parentId = 'parent123'; + const event: HoneyEvent = { + addField: noop, + send: () => { + t.true(false, 'should not send'); + }, + }; + const tags = { [SAMPLING_PRIORITY]: 0 }; + const sampler = new DeterministicSampler(1); + const span = new Span( + event, + serviceName, + name, + traceId, + parentId, + tags, + sampler, + ); + span.finish(); + t.true(true, 'finish'); }); diff --git a/test/tracer.test.ts b/test/tracer.test.ts index d958fd7..328cbff 100644 --- a/test/tracer.test.ts +++ b/test/tracer.test.ts @@ -1,11 +1,14 @@ import test from 'tape'; import { Tracer } from '../src/tracer'; import Libhoney, { HoneyEvent, HoneyOptions } from 'libhoney'; -import {SAMPLING_PRIORITY} from '../src/tags'; +import { SAMPLING_PRIORITY } from '../src/tags'; -function newDummyHoney(addField?: (key: string, value: any) => void) { - const noop = () => {}; +const noop = () => {}; +function newDummyHoney( + addField?: (key: string, value: any) => void, + send?: () => void, +) { class DummyHoney extends Libhoney { constructor(options: HoneyOptions) { super(options); @@ -13,7 +16,7 @@ function newDummyHoney(addField?: (key: string, value: any) => void) { newEvent(): HoneyEvent { return { addField: addField || noop, - send: noop, + send: send || noop, }; } } @@ -27,7 +30,7 @@ function newDummyHoney(addField?: (key: string, value: any) => void) { test('test tracer with no options', t => { t.plan(3); - const tracer = new Tracer('service name', newDummyHoney()); + const tracer = new Tracer({ serviceName: 'service name' }, newDummyHoney()); const span = tracer.startSpan('hello'); const ctx = span.context(); const traceId = ctx.toTraceId(); @@ -39,7 +42,7 @@ test('test tracer with no options', t => { test('test tracer traceId is same for parent & child', t => { t.plan(1); - const tracer = new Tracer('service name', newDummyHoney()); + const tracer = new Tracer({ serviceName: 'service name' }, newDummyHoney()); const parentSpan = tracer.startSpan('parent'); const childSpan = tracer.startSpan('child', { childOf: parentSpan }); t.equal(parentSpan.context().toTraceId(), childSpan.context().toTraceId()); @@ -47,9 +50,9 @@ test('test tracer traceId is same for parent & child', t => { test('test tracer sample priority is same for parent & child', t => { t.plan(2); - const tracer = new Tracer('service name', newDummyHoney()); + const tracer = new Tracer({ serviceName: 'service name' }, newDummyHoney()); const tags = { [SAMPLING_PRIORITY]: 75 }; - const parentSpan = tracer.startSpan('parent', {tags}); + const parentSpan = tracer.startSpan('parent', { tags }); const childSpan = tracer.startSpan('child', { childOf: parentSpan }); const parentPriority = parentSpan.context().getTag(SAMPLING_PRIORITY); const childPriority = childSpan.context().getTag(SAMPLING_PRIORITY); @@ -66,7 +69,10 @@ test('test tracer tags', t => { t.equal(value, 'value2'); } }; - const tracer = new Tracer('service name', newDummyHoney(addField)); + const tracer = new Tracer( + { serviceName: 'service name' }, + newDummyHoney(addField), + ); const tags = { key1: 'value1', key2: 'value2' }; const span = tracer.startSpan('hello', { tags }); span.finish(); diff --git a/tsconfig.json b/tsconfig.json index 402cd19..94dea03 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -19,6 +19,6 @@ "./node_modules/@types" ] }, - "include": ["./src", "./test", "./types"] + "include": ["./src", "./test", "./types", "./examples"] } \ No newline at end of file