lib/tracecontext/tracestate.js (184 lines of code) (raw):
/*
* Copyright Elasticsearch B.V. and other contributors where applicable.
* Licensed under the BSD 2-Clause License; you may not use this file except in
* compliance with the BSD 2-Clause License.
*/
'use strict';
// Indirect usage of the singleton `Agent` to log.
function getLogger() {
return require('../..').logger;
}
/**
* Class for Managing Tracestate
*
* Class that creates objects for managing trace state.
* This class is capable of parsing both tracestate strings
* and tracestate binary representations, allowing clients
* to get and set values in a single list-member/namespace
* while preserving values in the other namespaces.
*
* Capable of working with either the binary of string
* formatted tracestate values.
*
* Usage:
* const tracestate = TraceState.fromStringFormatString(headerTracestate, 'es')
* tracestate.setValue('s',1)
* const newHeader = tracestate.toW3cString()
*/
class TraceState {
constructor(sourceBuffer, listMemberNamespace = 'es', defaultValues = {}) {
if (!this._validateVendorKey(listMemberNamespace)) {
throw new Error('Vendor namespace failed validation.');
}
// buffer representation of the trace state string.
// The initial value of this.buffer will keep the
// values set in the listMemberNamespace list-member,
// but as soon as an initial value is set (via setValue)
// then the listMemberNamespace values will be removed
// from this.buffer and stored in the this.values. While
// slightly more complicated, this allows us to maintain
// the order of list-member keys in an un-mutated tracestate
// string, per the W3C spec
this.buffer = sourceBuffer;
this.listMemberNamespace = listMemberNamespace;
// values for our namespace, set via setValue to
// ensure names conform
this.values = {};
for (const key in defaultValues) {
const value = defaultValues[key];
this.setValue(key, value);
}
}
setValue(key, value) {
const strKey = String(key);
const strValue = String(value);
if (!this._validateElasicKeyAndValue(strKey, strValue)) {
getLogger().trace(
'could not set tracestate key, invalid characters detected',
);
return false;
}
const isFirstSet = Object.keys(this.values).length === 0;
const oldValue = this.values[strKey];
this.values[strKey] = value;
// per: https://github.com/elastic/apm/blob/d5b2c87326548befcfec6731713932a00e430b99/specs/agents/tracing-distributed-tracing.md
// If adding another key/value pair to the es entry would exceed the limit
// of 256 chars, that key/value pair MUST be ignored by agents.
// The key/value and entry separators : and ; have to be considered as well.
const serializedValue = this._serializeValues(this.values);
if (serializedValue.length > 256 && typeof oldValue === 'undefined') {
delete this.values[strKey];
return false;
}
if (serializedValue.length > 256 && typeof oldValue !== 'undefined') {
this.values[strKey] = oldValue;
return false;
}
// the first time we set a value, extract the mutable values from the
// buffer and set this.values appropriately
if (isFirstSet && Object.keys(this.values).length === 1) {
const [buffer, values] = TraceState._removeMemberNamespaceFromBuffer(
this.buffer,
this.listMemberNamespace,
);
this.buffer = buffer;
this.values = values;
values[strKey] = value;
}
return true;
}
getValue(keyToGet) {
const allValues = this.toObject();
const rawValue = allValues[this.listMemberNamespace];
if (!rawValue) {
return rawValue;
}
const values = TraceState._parseValues(rawValue);
return values[keyToGet];
}
toHexString() {
const newBuffer = Buffer.alloc(this.buffer.length);
let newBufferOffset = 0;
for (let i = 0; i < this.buffer.length; i++) {
const byte = this.buffer[i];
if (byte === 0) {
const indexOfKeyLength = i + 1;
const indexOfKey = i + 2;
const lengthKey = this.buffer[indexOfKeyLength];
const indexOfValueLength = indexOfKey + lengthKey;
const indexOfValue = indexOfValueLength + 1;
const lengthValue = this.buffer[indexOfValueLength];
const key = this.buffer
.slice(indexOfKey, indexOfKey + lengthKey)
.toString();
// bail out if this is our mutable namespace
if (key === this.listMemberNamespace) {
continue;
}
// if this is not our key copy from the `0` byte to the end of the value
this.buffer.copy(
newBuffer,
newBufferOffset,
i,
indexOfValue + lengthValue,
);
newBufferOffset += indexOfValue + lengthValue;
// skip ahead to first byte after end of value
i = indexOfValue + lengthValue - 1;
continue;
}
}
// now serialize the internal representation
const ourBytes = [];
if (Object.keys(this.values).length > 0) {
// the zero byte
ourBytes.push(0);
// the length of the vendor namespace
ourBytes.push(this.listMemberNamespace.length);
// the chars of the vendor namespace
for (let i = 0; i < this.listMemberNamespace.length; i++) {
ourBytes.push(this.listMemberNamespace.charCodeAt(i));
}
// add the length of the value
const serializedValue = this._serializeValues(this.values);
ourBytes.push(serializedValue.length);
// add the bytes of the value
for (let i = 0; i < serializedValue.length; i++) {
ourBytes.push(serializedValue.charCodeAt(i));
}
}
const ourBuffer = Buffer.from(ourBytes);
return Buffer.concat(
[newBuffer, ourBuffer],
newBuffer.length + ourBuffer.length,
).toString('hex');
}
/**
* Returns JSON reprenstation of tracestate key/value pairs
*
* Does not parse the mutable list namespace
*/
toObject() {
const map = this.toMap();
const obj = {};
for (const key of map.keys()) {
obj[key] = map.get(key);
}
return obj;
}
toMap() {
const map = new Map();
// first, serialize values from the internal representation. This means
// The W3C spec dictates that mutated values need to be on
// the left of the new trace string
if (Object.keys(this.values).length) {
map.set(this.listMemberNamespace, this._serializeValues(this.values));
}
for (let i = 0; i < this.buffer.length; i++) {
const byte = this.buffer[i];
if (byte === 0) {
const indexOfKeyLength = i + 1;
const indexOfKey = i + 2;
const lengthKey = this.buffer[indexOfKeyLength];
const indexOfValueLength = indexOfKey + lengthKey;
const indexOfValue = indexOfValueLength + 1;
const lengthValue = this.buffer[indexOfValueLength];
const key = this.buffer
.slice(indexOfKey, indexOfKey + lengthKey)
.toString();
const value = this.buffer
.slice(indexOfValue, indexOfValue + lengthValue)
.toString();
map.set(key, value);
// skip ahead
i = indexOfValue + lengthValue - 1;
continue;
}
}
return map;
}
toString() {
return this.toW3cString();
}
toW3cString() {
const json = this.toObject();
const chars = [];
for (const key in json) {
const value = json[key];
if (!value) {
continue;
}
chars.push(key);
chars.push('=');
chars.push(value);
chars.push(',');
}
chars.pop(); // remove final comma
return chars.join('');
}
_serializeValues(keyValues) {
const chars = [];
for (const key in keyValues) {
const value = keyValues[key];
chars.push(`${key}:${value}`);
chars.push(';');
}
chars.pop(); // last semi-colon
return chars.join('');
}
_validateVendorKey(key) {
if (key.length > 256 || key.length < 1) {
return false;
}
const re = /^[abcdefghijklmnopqrstuvwxyz0123456789_\-*/]*$/;
if (!key.match(re)) {
return false;
}
return true;
}
_validateElasicKeyAndValue(key, value) {
// 0x20` to `0x7E WITHOUT `,` or `=` or `;` or `;`
const re =
/^[ \][!"#$%&'()*+\-./0123456789<>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ^_abcdefghijklmnopqrstuvwxyz{|}~]*$/;
if (!key.match(re) || !value.match(re)) {
return false;
}
if (key.length > 256 || value.length > 256) {
return false;
}
return true;
}
static fromBinaryFormatHexString(string, listMemberNamespace = 'es') {
return new TraceState(Buffer.from(string, 'hex'), listMemberNamespace);
}
static fromStringFormatString(string = '', listMemberNamespace = 'es') {
// converts string format to byte format
const bytes = [];
const parts = string.split(',');
for (let part of parts) {
part = part.trim(); // optional whitespace (OWS)
if (!part) {
continue;
}
const [listMember, value] = part.split('=');
if (!listMember || !value) {
continue;
}
bytes.push(0);
bytes.push(listMember.length);
for (let i = 0; i < listMember.length; i++) {
bytes.push(listMember.charCodeAt(i));
}
bytes.push(value.length);
for (let i = 0; i < value.length; i++) {
bytes.push(value.charCodeAt(i));
}
}
return new TraceState(Buffer.from(bytes), listMemberNamespace);
}
static _parseValues(rawValues) {
const parsedValues = {};
const parts = rawValues.split(';');
for (const keyValue of parts) {
if (!keyValue) {
continue;
}
const [key, value] = keyValue.split(':');
if (!key || !value) {
continue;
}
parsedValues[key] = value;
}
return parsedValues;
}
static _removeMemberNamespaceFromBuffer(buffer, listMemberNamespace) {
const newBuffer = Buffer.alloc(buffer.length);
let newBufferOffset = 0;
const values = {};
for (let i = 0; i < buffer.length; i++) {
const byte = buffer[i];
if (byte === 0) {
const indexOfKeyLength = i + 1;
const indexOfKey = i + 2;
const lengthKey = buffer[indexOfKeyLength];
const indexOfValueLength = indexOfKey + lengthKey;
const indexOfValue = indexOfValueLength + 1;
const lengthValue = buffer[indexOfValueLength];
const key = buffer.slice(indexOfKey, indexOfKey + lengthKey).toString();
// if this is our mutable namespace extract
// and set the value in values, otherwise
// copy into new buffer
if (key === listMemberNamespace) {
const rawValues = buffer
.slice(indexOfValue, indexOfValue + lengthValue)
.toString();
const parsedValues = TraceState._parseValues(rawValues);
for (const key in parsedValues) {
values[key] = parsedValues[key];
}
continue;
} else {
buffer.copy(
newBuffer,
newBufferOffset,
i,
indexOfValue + lengthValue,
);
newBufferOffset += indexOfValue + lengthValue - i;
}
// skip ahead to first byte after end of value
i = indexOfValue + lengthValue - 1;
continue;
}
}
// trim off extra 0 bytes
const trimmedBuffer = newBuffer.slice(0, newBufferOffset);
return [trimmedBuffer, values];
}
}
module.exports = TraceState;