diff --git a/src/index.ts b/src/index.ts index 1f81da2..505f3c9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -34,6 +34,7 @@ export * from './trace/SpanOptions'; export * from './trace/status'; export * from './trace/trace_flags'; export * from './trace/trace_state'; +export { createTraceState } from './trace/internal/utils'; export * from './trace/tracer_provider'; export * from './trace/tracer'; diff --git a/src/trace/internal/tracestate-impl.ts b/src/trace/internal/tracestate-impl.ts new file mode 100644 index 0000000..4c69a3a --- /dev/null +++ b/src/trace/internal/tracestate-impl.ts @@ -0,0 +1,110 @@ +/* + * Copyright The 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 { TraceState } from '../trace_state'; +import { validateKey, validateValue } from './tracestate-validators'; + +const MAX_TRACE_STATE_ITEMS = 32; +const MAX_TRACE_STATE_LEN = 512; +const LIST_MEMBERS_SEPARATOR = ','; +const LIST_MEMBER_KEY_VALUE_SPLITTER = '='; + +/** + * TraceState must be a class and not a simple object type because of the spec + * requirement (https://www.w3.org/TR/trace-context/#tracestate-field). + * + * Here is the list of allowed mutations: + * - New key-value pair should be added into the beginning of the list + * - The value of any key can be updated. Modified keys MUST be moved to the + * beginning of the list. + */ +export class TraceStateImpl implements TraceState { + private _internalState: Map = new Map(); + + constructor(rawTraceState?: string) { + if (rawTraceState) this._parse(rawTraceState); + } + + set(key: string, value: string): TraceStateImpl { + // TODO: Benchmark the different approaches(map vs list) and + // use the faster one. + const traceState = this._clone(); + if (traceState._internalState.has(key)) { + traceState._internalState.delete(key); + } + traceState._internalState.set(key, value); + return traceState; + } + + unset(key: string): TraceStateImpl { + const traceState = this._clone(); + traceState._internalState.delete(key); + return traceState; + } + + get(key: string): string | undefined { + return this._internalState.get(key); + } + + serialize(): string { + return this._keys() + .reduce((agg: string[], key) => { + agg.push(key + LIST_MEMBER_KEY_VALUE_SPLITTER + this.get(key)); + return agg; + }, []) + .join(LIST_MEMBERS_SEPARATOR); + } + + private _parse(rawTraceState: string) { + if (rawTraceState.length > MAX_TRACE_STATE_LEN) return; + this._internalState = rawTraceState + .split(LIST_MEMBERS_SEPARATOR) + .reverse() // Store in reverse so new keys (.set(...)) will be placed at the beginning + .reduce((agg: Map, part: string) => { + const listMember = part.trim(); // Optional Whitespace (OWS) handling + const i = listMember.indexOf(LIST_MEMBER_KEY_VALUE_SPLITTER); + if (i !== -1) { + const key = listMember.slice(0, i); + const value = listMember.slice(i + 1, part.length); + if (validateKey(key) && validateValue(value)) { + agg.set(key, value); + } else { + // TODO: Consider to add warning log + } + } + return agg; + }, new Map()); + + // Because of the reverse() requirement, trunc must be done after map is created + if (this._internalState.size > MAX_TRACE_STATE_ITEMS) { + this._internalState = new Map( + Array.from(this._internalState.entries()) + .reverse() // Use reverse same as original tracestate parse chain + .slice(0, MAX_TRACE_STATE_ITEMS) + ); + } + } + + private _keys(): string[] { + return Array.from(this._internalState.keys()).reverse(); + } + + private _clone(): TraceStateImpl { + const traceState = new TraceStateImpl(); + traceState._internalState = new Map(this._internalState); + return traceState; + } +} diff --git a/src/trace/internal/tracestate-validators.ts b/src/trace/internal/tracestate-validators.ts new file mode 100644 index 0000000..78fbee8 --- /dev/null +++ b/src/trace/internal/tracestate-validators.ts @@ -0,0 +1,45 @@ +/* + * Copyright The 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. + */ + +const VALID_KEY_CHAR_RANGE = '[_0-9a-z-*/]'; +const VALID_KEY = `[a-z]${VALID_KEY_CHAR_RANGE}{0,255}`; +const VALID_VENDOR_KEY = `[a-z0-9]${VALID_KEY_CHAR_RANGE}{0,240}@[a-z]${VALID_KEY_CHAR_RANGE}{0,13}`; +const VALID_KEY_REGEX = new RegExp(`^(?:${VALID_KEY}|${VALID_VENDOR_KEY})$`); +const VALID_VALUE_BASE_REGEX = /^[ -~]{0,255}[!-~]$/; +const INVALID_VALUE_COMMA_EQUAL_REGEX = /,|=/; + +/** + * Key is opaque string up to 256 characters printable. It MUST begin with a + * lowercase letter, and can only contain lowercase letters a-z, digits 0-9, + * underscores _, dashes -, asterisks *, and forward slashes /. + * For multi-tenant vendor scenarios, an at sign (@) can be used to prefix the + * vendor name. Vendors SHOULD set the tenant ID at the beginning of the key. + * see https://www.w3.org/TR/trace-context/#key + */ +export function validateKey(key: string): boolean { + return VALID_KEY_REGEX.test(key); +} + +/** + * Value is opaque string up to 256 characters printable ASCII RFC0020 + * characters (i.e., the range 0x20 to 0x7E) except comma , and =. + */ +export function validateValue(value: string): boolean { + return ( + VALID_VALUE_BASE_REGEX.test(value) && + !INVALID_VALUE_COMMA_EQUAL_REGEX.test(value) + ); +} diff --git a/src/trace/internal/utils.ts b/src/trace/internal/utils.ts new file mode 100644 index 0000000..080be77 --- /dev/null +++ b/src/trace/internal/utils.ts @@ -0,0 +1,23 @@ +/* + * Copyright The 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 { TraceState } from '../trace_state'; +import { TraceStateImpl } from './tracestate-impl'; + + +export function createTraceState(rawTraceState?: string): TraceState { + return new TraceStateImpl(rawTraceState); +} diff --git a/test/trace/tracestate-validators.test.ts b/test/trace/tracestate-validators.test.ts new file mode 100644 index 0000000..0f355f1 --- /dev/null +++ b/test/trace/tracestate-validators.test.ts @@ -0,0 +1,92 @@ +/* + * Copyright The 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 { validateKey, validateValue } from '../../src/trace/internal/tracestate-validators'; + +describe('validators', () => { + describe('validateKey', () => { + const validKeysTestCases = [ + 'abcdefghijklmnopqrstuvwxyz0123456789-_*/', + 'baz-', + 'baz_', + 'baz*', + 'baz*bar', + 'baz/', + 'tracestate', + 'fw529a3039@dt', + '6cab5bb-29a@dt', + ]; + validKeysTestCases.forEach(testCase => + it(`returns true when key contains valid chars ${testCase}`, () => { + assert.ok(validateKey(testCase), `${testCase} should be valid`); + }) + ); + + const invalidKeysTestCases = [ + '1_key', + 'kEy_1', + 'k'.repeat(257), + 'key,', + 'TrAcEsTaTE', + 'TRACESTATE', + '', + '6num', + ]; + invalidKeysTestCases.forEach(testCase => + it(`returns true when key contains invalid chars ${testCase}`, () => { + assert.ok(!validateKey(testCase), `${testCase} should be invalid`); + }) + ); + }); + + describe('validateValue', () => { + const validValuesTestCases = [ + 'first second', + 'baz*', + 'baz$', + 'baz@', + 'first-second', + 'baz~bar', + 'test-v1:120', + '-second', + 'first.second', + 'TrAcEsTaTE', + 'TRACESTATE', + ]; + validValuesTestCases.forEach(testCase => + it(`returns true when value contains valid chars ${testCase}`, () => { + assert.ok(validateValue(testCase)); + }) + ); + + const invalidValuesTestCases = [ + 'my_value=5', + 'first,second', + 'first ', + 'k'.repeat(257), + ',baz', + 'baz,', + 'baz=', + '', + ]; + invalidValuesTestCases.forEach(testCase => + it(`returns true when value contains invalid chars ${testCase}`, () => { + assert.ok(!validateValue(testCase)); + }) + ); + }); +}); diff --git a/test/trace/tracestate.test.ts b/test/trace/tracestate.test.ts new file mode 100644 index 0000000..699c6cd --- /dev/null +++ b/test/trace/tracestate.test.ts @@ -0,0 +1,139 @@ +/* + * Copyright The 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 { createTraceState } from '../../src/trace/internal/utils'; +import { TraceStateImpl } from '../../src/trace/internal/tracestate-impl'; + +describe('TraceState', () => { + describe('.serialize()', () => { + it('returns serialize string', () => { + const state = createTraceState('a=1,b=2'); + assert.deepStrictEqual(state.serialize(), 'a=1,b=2'); + }); + + it('must create a createTraceState and move updated keys to the front', () => { + const orgState = createTraceState('a=1,b=2'); + const state = orgState.set('b', '3'); + assert.deepStrictEqual(orgState.serialize(), 'a=1,b=2'); + assert.deepStrictEqual(state.serialize(), 'b=3,a=1'); + }); + + it('must create a createTraceState and add new keys to the front', () => { + let state = createTraceState().set('vendorname1', 'opaqueValue1'); + assert.deepStrictEqual(state.serialize(), 'vendorname1=opaqueValue1'); + + state = state.set('vendorname2', 'opaqueValue2'); + assert.deepStrictEqual( + state.serialize(), + 'vendorname2=opaqueValue2,vendorname1=opaqueValue1' + ); + }); + + it('must create a createTraceState and unset the entries', () => { + const orgState = createTraceState('c=4,b=3,a=1'); + let state = orgState.unset('b'); + assert.deepStrictEqual(state.serialize(), 'c=4,a=1'); + state = state.unset('c').unset('A'); + assert.deepStrictEqual(state.serialize(), 'a=1'); + assert.strictEqual(orgState.serialize(), 'c=4,b=3,a=1'); + }); + }); + + describe('.parse()', () => { + it('must successfully parse valid state value', () => { + const state = createTraceState( + 'vendorname2=opaqueValue2,vendorname1=opaqueValue1' + ); + assert.deepStrictEqual(state.get('vendorname1'), 'opaqueValue1'); + assert.deepStrictEqual(state.get('vendorname2'), 'opaqueValue2'); + assert.deepStrictEqual( + state.serialize(), + 'vendorname2=opaqueValue2,vendorname1=opaqueValue1' + ); + }); + + it('must drop states when the items are too long', () => { + const state = createTraceState('a=' + 'b'.repeat(512)); + assert.deepStrictEqual(state.get('a'), undefined); + assert.deepStrictEqual(state.serialize(), ''); + }); + + it('must drop states which cannot be parsed', () => { + const state = createTraceState('a=1,b,c=3'); + assert.deepStrictEqual(state.get('a'), '1'); + assert.deepStrictEqual(state.get('b'), undefined); + assert.deepStrictEqual(state.get('c'), '3'); + assert.deepStrictEqual(state.serialize(), 'a=1,c=3'); + }); + + it('must skip states that only have a single value with an equal sign', () => { + const state = createTraceState('a=1='); + assert.deepStrictEqual(state.get('a'), undefined); + }); + + it('must successfully parse valid state keys', () => { + const state = createTraceState('a-b=1,c/d=2,p*q=3,x_y=4'); + assert.deepStrictEqual(state.get('a-b'), '1'); + assert.deepStrictEqual(state.get('c/d'), '2'); + assert.deepStrictEqual(state.get('p*q'), '3'); + assert.deepStrictEqual(state.get('x_y'), '4'); + }); + + it('must successfully parse valid state value with spaces in between', () => { + const state = createTraceState('a=1,foo=bar baz'); + assert.deepStrictEqual(state.get('foo'), 'bar baz'); + assert.deepStrictEqual(state.serialize(), 'a=1,foo=bar baz'); + }); + + it('must truncate states with too many items', () => { + const state = createTraceState( + new Array(33) + .fill(0) + .map((_: null, num: number) => `a${num}=${num}`) + .join(',') + ) as TraceStateImpl; + assert.deepStrictEqual(state['_keys']().length, 32); + assert.deepStrictEqual(state.get('a0'), '0'); + assert.deepStrictEqual(state.get('a31'), '31'); + assert.deepStrictEqual( + state.get('a32'), + undefined, + 'should truncate from the tail' + ); + }); + + it('should not count invalid items towards max limit', () => { + const tracestate = new Array(32) + .fill(0) + .map((_: null, num: number) => `a${num}=${num}`) + .concat('invalid.suffix.key=1'); // add invalid key to beginning + tracestate.unshift('invalid.prefix.key=1'); + tracestate.splice(15, 0, 'invalid.middle.key.a=1'); + tracestate.splice(15, 0, 'invalid.middle.key.b=2'); + tracestate.splice(15, 0, 'invalid.middle.key.c=3'); + + const state = createTraceState(tracestate.join(',')) as TraceStateImpl; + + assert.deepStrictEqual(state['_keys']().length, 32); + assert.deepStrictEqual(state.get('a0'), '0'); + assert.deepStrictEqual(state.get('a31'), '31'); + assert.deepStrictEqual(state.get('invalid.middle.key.a'), undefined); + assert.deepStrictEqual(state.get('invalid.middle.key.b'), undefined); + assert.deepStrictEqual(state.get('invalid.middle.key.c'), undefined); + }); + }); +});