Repository: honeycombio/beeline-nodejs
Pull request #253 (2020-09-29 17:43:19)

Add prebuilt interop propagation hooks
Adds prebuilt interop propagation hooks so configuration can look like this:

```
const beeline = require("honeycomb-beeline");

beeline({
    httpTracePropagationHook: beeline.w3c.httpTracePropagationHook,
    httpTraceParserHook: beeline.w3c.httpTraceParserHook,
});
```

This also includes a testing suite refactor from a previously closed PR.

For the AWS propagator, this includes a fallback to self (rather than parent) if parent is not present. This PR also includes utility functions for converting an object to a string for propagation, and a string to an object for ingestion. The join characters default to w3c/honeycomb (,, =) but can be overridden


paulosman: @toshok took a crack at the "meh" comments (which I agreed with, so went ahead and changed). I'll work on the defaults next.

Patch:
diff --git a/lib/api/index.js b/lib/api/index.js
index 85d73bf..e30e0a5 100644
--- a/lib/api/index.js
+++ b/lib/api/index.js
@@ -67,18 +67,24 @@ module.exports = {
     TRACE_HTTP_HEADER: honeycomb.TRACE_HTTP_HEADER,
     unmarshalTraceContext: honeycomb.unmarshalTraceContext,
     marshalTraceContext: honeycomb.marshalTraceContext,
+    httpTraceParserHook: honeycomb.httpTraceParserHook,
+    httpTracePropagationHook: honeycomb.httpTracePropagationHook,
   },
 
   aws: {
     TRACE_HTTP_HEADER: aws.TRACE_HTTP_HEADER,
     unmarshalTraceContext: aws.unmarshalTraceContext,
     marshalTraceContext: aws.marshalTraceContext,
+    httpTraceParserHook: aws.httpTraceParserHook,
+    httpTracePropagationHook: aws.httpTracePropagationHook,
   },
 
   w3c: {
     TRACE_HTTP_HEADER: w3c.TRACE_HTTP_HEADER,
     unmarshalTraceContext: w3c.unmarshalTraceContext,
     marshalTraceContext: w3c.marshalTraceContext,
+    httpTraceParserHook: w3c.httpTraceParserHook,
+    httpTracePropagationHook: w3c.httpTracePropagationHook,
   },
 
   startTrace(fields, withTraceId, withParentSpanId, withDataset, propagatedContext = {}) {
diff --git a/lib/propagation/aws.js b/lib/propagation/aws.js
index 0c12f8e..d7fc16b 100644
--- a/lib/propagation/aws.js
+++ b/lib/propagation/aws.js
@@ -13,16 +13,19 @@ function marshalTraceContextv1(context) {
   // fall back to execution context structure for backwards compatibility
   let traceId = context.traceId || context.id;
   let spanId = context.parentSpanId || util.currentSpanId(context);
-  let contextToSend = context.customContext || context.traceContext || {};
+  let traceFields = context.customContext || context.traceContext || {};
 
-  if (context.traceContext) {
-    const elements = Object.keys(context.traceContext).map(key => {
-      return `${key}=${context.traceContext[key]}`;
-    });
-    contextToSend = ";" + elements.join(";");
+  if (!traceId || !spanId) {
+    return null;
   }
 
-  return `Root=1-${traceId};Parent=${spanId}${contextToSend}`;
+  let contextToSend = "";
+
+  if (Object.keys(traceFields).length > 0) {
+    contextToSend = util.objToString(traceFields, ";", "=", ";");
+  }
+
+  return `Root=${traceId};Parent=${spanId}${contextToSend}`;
 }
 
 // unmarshalTraceContext takes a string trace header and returns a context
@@ -30,18 +33,26 @@ exports.unmarshalTraceContext = unmarshalTraceContext;
 
 function unmarshalTraceContext(header) {
   let traceId, parentSpanId, customContext;
+  let self;
 
   const split = header.split(";");
   for (const s of split) {
     const [name, value] = s.split("=");
-    if (name === "Root") {
-      traceId = value;
-    } else if (name === "Parent") {
-      parentSpanId = value;
-    } else {
-      customContext = Object.assign({}, customContext, {
-        [name]: value,
-      });
+    switch (name.toLowerCase()) {
+      case "root":
+        traceId = value;
+        break;
+      case "parent":
+        parentSpanId = value;
+        break;
+      case "self":
+        self = value;
+        break;
+      default:
+        // propagate trace headers without case changes
+        customContext = Object.assign({}, customContext, {
+          [name]: value,
+        });
     }
   }
 
@@ -50,6 +61,11 @@ function unmarshalTraceContext(header) {
     return;
   }
 
+  // use self if parent was not present
+  if (!parentSpanId && self) {
+    parentSpanId = self;
+  }
+
   if (!parentSpanId) {
     parentSpanId = traceId;
   }
@@ -60,3 +76,23 @@ function unmarshalTraceContext(header) {
     customContext,
   };
 }
+
+exports.httpTraceParserHook = httpTraceParserHook;
+
+function httpTraceParserHook(httpReq) {
+  const header = httpReq.headers["x-amzn-trace-id"];
+  if (!header) {
+    return null;
+  }
+  return unmarshalTraceContext(header);
+}
+
+exports.httpTracePropagationHook = httpTracePropagationHook;
+
+function httpTracePropagationHook(context) {
+  const headerValue = marshalTraceContextv1(context);
+  if (!headerValue) {
+    return null;
+  }
+  return { [TRACE_HTTP_HEADER]: headerValue };
+}
diff --git a/lib/propagation/honeycomb.js b/lib/propagation/honeycomb.js
index 5a4faa2..1ce3caa 100644
--- a/lib/propagation/honeycomb.js
+++ b/lib/propagation/honeycomb.js
@@ -126,3 +126,31 @@ function unmarshalTraceContextv1(payload) {
     dataset,
   };
 }
+
+exports.httpTraceParserHook = httpTraceParserHook;
+
+function httpTraceParserHook(httpReq) {
+  const traceHeaderValue = httpReq.headers[TRACE_HTTP_HEADER.toLowerCase()];
+  if (!traceHeaderValue) {
+    return null;
+  }
+  return unmarshalTraceContext(traceHeaderValue);
+}
+
+exports.httpTracePropagationHook = httpTracePropagationHook;
+
+function httpTracePropagationHook(context = {}) {
+  // tracestate default to undefined so it will only be included when needed
+  let headers = {
+    [TRACE_HTTP_HEADER]: marshalTraceContextv1(context),
+  };
+
+  // add tracestate if there are fields in traceContext
+  const { customContext } = context;
+
+  if (customContext.tracestate) {
+    headers.tracestate = customContext.tracestate;
+  }
+
+  return headers;
+}
diff --git a/lib/propagation/util.js b/lib/propagation/util.js
index 5a113ed..68d16b9 100644
--- a/lib/propagation/util.js
+++ b/lib/propagation/util.js
@@ -23,3 +23,35 @@ function getPropagationContext(context = {}) {
     customContext: context.traceContext,
   };
 }
+
+exports.objToString = objToString;
+
+// pass this util an object, return as string formatted to go into a trace header
+// joinChar separates fields from each other, keyChar separates keys from values
+function objToString(obj, joinChar = ",", keyChar = "=", prefix = "") {
+  const keys = Object.keys(obj);
+  if (keys.length == 0) {
+    return "";
+  }
+  const fields = keys.map(key => {
+    return key + keyChar + obj[key];
+  });
+  return prefix + fields.join(joinChar);
+}
+
+exports.stringToObj = stringToObj;
+
+// pass this util a string, return as JSON obj with keys and values
+function stringToObj(string, joinChar = ",", keyChar = "=") {
+  let obj;
+  const fields = string.split(joinChar);
+
+  fields.forEach(field => {
+    const [name, value] = field.split(keyChar);
+    obj = Object.assign({}, obj, {
+      [name]: value,
+    });
+  });
+
+  return obj;
+}
diff --git a/lib/propagation/w3c.js b/lib/propagation/w3c.js
index 1f33c3b..253b031 100644
--- a/lib/propagation/w3c.js
+++ b/lib/propagation/w3c.js
@@ -1,12 +1,17 @@
 /* global require, exports, __dirname */
+// requiring the OTEL trace header key from the api
+// as well as the parser
 const { TRACE_PARENT_HEADER, parseTraceParent } = require("@opentelemetry/core"),
   path = require("path"),
   pkg = require(path.join(__dirname, "..", "..", "package.json")),
   debug = require("debug")(`${pkg.name}:propagation:w3c`),
   util = require("./util");
-// requiring the OTEL trace header key from the api
-// as well as the parser
+
 exports.TRACE_HTTP_HEADER = TRACE_PARENT_HEADER;
+const TRACE_HTTP_HEADER = TRACE_PARENT_HEADER;
+
+const TRACE_STATE_HEADER = "tracestate";
+exports.TRACE_STATE_HEADER = TRACE_STATE_HEADER;
 
 const TRACE_ID_REGEX = /^[A-Fa-f0-9]{32}$/g;
 const SPAN_ID_REGEX = /^[A-Fa-f0-9]{16}$/g;
@@ -24,24 +29,28 @@ exports.unmarshalTraceContext = unmarshalTraceContextv1;
 // traceState (optional)     -->  context.traceState (nested field in context: {})
 
 // v1 here refers to honeycomb, the context structure
-function unmarshalTraceContextv1(header) {
-  // pull out required fields, ... for all optional
-  // using spread operator defaults all values to undefined for invalid header values
-
+function unmarshalTraceContextv1(traceparent, tracestate) {
+  let parsedTraceState;
   try {
-    const parsed = parseTraceParent(header);
-    if (!parsed) {
+    const parsedTraceParent = parseTraceParent(traceparent);
+    if (!parsedTraceParent) {
       return;
     }
-    const { traceId, spanId } = parsed;
+    const { traceId, spanId } = parsedTraceParent;
+
+    if (tracestate) {
+      parsedTraceState = util.stringToObj(tracestate);
+    }
+
     return {
       traceId,
       parentSpanId: spanId,
+      customContext: parsedTraceState,
     };
   } catch (error) {
     debug(
       `error: ${error},
-       unable to parse trace header: expected string value of "traceparent", received ${header}`
+       unable to parse trace header: expected string value of "traceparent", received ${traceparent}`
     );
   }
 }
@@ -77,3 +86,34 @@ function marshalTraceContextv1(context) {
   // we will revisit as opentelemetry sampling evolves
   return `00-${traceId}-${parentSpanId}-01`;
 }
+
+exports.httpTraceParserHook = httpTraceParserHook;
+
+function httpTraceParserHook(httpReq) {
+  const { traceparent, tracestate } = httpReq.headers;
+
+  if (!traceparent) {
+    debug("'traceparent' request header not detected");
+    return null;
+  }
+
+  return exports.unmarshalTraceContext(traceparent, tracestate);
+}
+
+exports.httpTracePropagationHook = httpTracePropagationHook;
+
+function httpTracePropagationHook(context = {}) {
+  // tracestate default to undefined so it will only be included when needed
+  let headers = {
+    [TRACE_HTTP_HEADER]: marshalTraceContext(context),
+  };
+
+  // add tracestate if there are fields in traceContext
+  const { customContext } = context;
+
+  if (customContext && Object.keys(customContext).length > 0) {
+    headers.tracestate = util.objToString(customContext);
+  }
+
+  return headers;
+}


Test patch:
diff --git a/lib/propagation/aws.test.js b/lib/propagation/aws.test.js
new file mode 100644
index 0000000..6f082aa
--- /dev/null
+++ b/lib/propagation/aws.test.js
@@ -0,0 +1,168 @@
+/* global require describe test expect */
+const propagation = require("."),
+  schema = require("../schema"),
+  Span = require("../api/span"),
+  cases = require("jest-in-case");
+
+const { aws } = propagation;
+
+// this context structure is the same as what the libhoney event api implementation generate.
+let testContext = {
+  id: "abcdef123456",
+  dataset: "testDataset",
+  stack: [new Span({ [schema.TRACE_SPAN_ID]: "0102030405" })],
+  traceContext: {
+    userID: 1,
+    errorMsg: "failed to sign on",
+    toRetry: true,
+  },
+};
+
+cases(
+  "marshaling aws",
+  opts => expect(aws.marshalTraceContext(opts.testContext)).toEqual(opts.header),
+  [
+    {
+      name: "propagate span as Parent",
+      testContext: {
+        id: "abcdef123456",
+        dataset: "testDataset",
+        stack: [new Span({ [schema.TRACE_SPAN_ID]: "0102030405" })],
+        traceContext: {
+          userID: 1,
+          errorMsg: "failed to sign on",
+          toRetry: true,
+        },
+      },
+      header:
+        "Root=abcdef123456;Parent=0102030405;userID=1;errorMsg=failed to sign on;toRetry=true",
+    },
+    {
+      name: "no trace id",
+      testContext: {
+        id: "",
+        dataset: "testDataset",
+        stack: [new Span({ [schema.TRACE_SPAN_ID]: "0102030405" })],
+        traceContext: {
+          userID: 1,
+          errorMsg: "failed to sign on",
+          toRetry: true,
+        },
+      },
+      header: null,
+    },
+  ]
+);
+
+cases(
+  "unmarshaling aws",
+  opts => expect(aws.unmarshalTraceContext(opts.contextStr)).toEqual(opts.value),
+  [
+    {
+      name: "aws header with trace context",
+      contextStr:
+        "Root=1-abcdef123456;Self=1-0102030405;userID=1;errorMsg=failed to sign on;toRetry=true",
+      value: {
+        traceId: "1-abcdef123456",
+        parentSpanId: "1-0102030405",
+        customContext: {
+          userID: "1",
+          errorMsg: "failed to sign on",
+          toRetry: "true",
+        },
+      },
+    },
+    {
+      name: "lowercase keys work, custom keys remain unchanged",
+      contextStr:
+        "root=abcdef123456;self=0102030405;userID=1;errorMsg=failed to sign on;toRetry=true",
+      value: {
+        traceId: "abcdef123456",
+        parentSpanId: "0102030405",
+        customContext: {
+          userID: "1",
+          errorMsg: "failed to sign on",
+          toRetry: "true",
+        },
+      },
+    },
+    {
+      name: "aws header with no trace context",
+      contextStr: "Root=abcdef123456;Self=0102030405",
+      value: {
+        traceId: "abcdef123456",
+        parentSpanId: "0102030405",
+      },
+    },
+    {
+      name: "root / parent / no self",
+      contextStr: "Root=abcdef123456;Parent=37501823472",
+      value: {
+        traceId: "abcdef123456",
+        parentSpanId: "37501823472",
+      },
+    },
+    {
+      name: "root / parent / self, use parent as parentSpanId",
+      contextStr: "Root=abcdef123456;Parent=37501823472;Self=0102030405",
+      value: {
+        traceId: "abcdef123456",
+        parentSpanId: "37501823472",
+      },
+    },
+    {
+      name: "aws header missing root",
+      contextStr: "Root=;Self=0102030405",
+      value: undefined,
+    },
+    {
+      name: "aws header with no parent span id, no trace context",
+      contextStr: "Root=abcdef123456",
+      value: {
+        traceId: "abcdef123456",
+        parentSpanId: "abcdef123456",
+      },
+    },
+    {
+      name: "aws header with no parent span id, with trace context",
+      contextStr: "Root=1-abcdef123456;userID=1;errorMsg=failed to sign on;toRetry=true",
+      value: {
+        traceId: "1-abcdef123456",
+        parentSpanId: "1-abcdef123456",
+        customContext: {
+          userID: "1",
+          errorMsg: "failed to sign on",
+          toRetry: "true",
+        },
+      },
+    },
+    {
+      name: "aws header with empty string parent",
+      contextStr: "Root=1-abcdef123456;Parent=",
+      value: {
+        traceId: "1-abcdef123456",
+        parentSpanId: "1-abcdef123456",
+      },
+    },
+    {
+      name: "aws header with empty string root",
+      contextStr: "Root=;Self=0102030405",
+      value: undefined,
+    },
+  ]
+);
+
+describe("roundtrip", () => {
+  test("works", () => {
+    let contextStr = aws.marshalTraceContext(testContext);
+    expect(aws.unmarshalTraceContext(contextStr)).toEqual({
+      traceId: "abcdef123456",
+      parentSpanId: "0102030405",
+      customContext: {
+        userID: "1",
+        errorMsg: "failed to sign on",
+        toRetry: "true",
+      },
+    });
+  });
+});
diff --git a/lib/propagation/honeycomb.test.js b/lib/propagation/honeycomb.test.js
new file mode 100644
index 0000000..c8d8d1d
--- /dev/null
+++ b/lib/propagation/honeycomb.test.js
@@ -0,0 +1,96 @@
+/* global require describe test expect */
+const propagation = require("."),
+  schema = require("../schema"),
+  Span = require("../api/span"),
+  cases = require("jest-in-case");
+
+const { honeycomb } = propagation;
+// this context structure is the same as what the libhoney event api implementation generate.
+let testContext = {
+  id: "abcdef123456",
+  dataset: "testDataset",
+  stack: [new Span({ [schema.TRACE_SPAN_ID]: "0102030405" })],
+  traceContext: {
+    userID: 1,
+    errorMsg: "failed to sign on",
+    toRetry: true,
+  },
+};
+
+describe("marshaling", () => {
+  test("version string prefix", () => {
+    expect(
+      honeycomb.marshalTraceContext(testContext).startsWith(`${honeycomb.VERSION};`)
+    ).toBeTruthy();
+  });
+});
+
+cases(
+  "unmarshaling",
+  opts => expect(honeycomb.unmarshalTraceContext(opts.contextStr)).toEqual(opts.value),
+  [
+    {
+      name: "unsupported version",
+      contextStr: "9999999;.....",
+      value: undefined,
+    },
+    {
+      name: "v1 trace_id + parent_id, missing context",
+      contextStr: "1;trace_id=abcdef,parent_id=12345",
+      value: {
+        traceId: "abcdef",
+        parentSpanId: "12345",
+      },
+    },
+    {
+      name: "v1, missing trace_id",
+      contextStr: "1;parent_id=12345",
+      value: undefined,
+    },
+    {
+      name: "v1, missing parent_id",
+      contextStr: "1;trace_id=12345",
+      value: undefined,
+    },
+    {
+      name: "v1, garbled context",
+      contextStr: "1;trace_id=abcdef,parent_id=12345,context=123~!@@&^@",
+      value: undefined,
+    },
+    {
+      name: "v1, unknown key (otherwise valid)",
+      contextStr: "1;trace_id=abcdef,parent_id=12345,something=unsupported",
+      value: {
+        traceId: "abcdef",
+        parentSpanId: "12345",
+      },
+    },
+    {
+      name: "v1, with context",
+      contextStr: "1;trace_id=abcdef,parent_id=12345,context=eyJmb28iOiJiYXIifQo=",
+      value: {
+        traceId: "abcdef",
+        parentSpanId: "12345",
+        customContext: {
+          foo: "bar",
+        },
+      },
+    },
+  ]
+);
+
+describe("roundtrip", () => {
+  test("works", () => {
+    let contextStr = honeycomb.marshalTraceContext(testContext);
+    expect(honeycomb.unmarshalTraceContext(contextStr)).toEqual({
+      traceId: "abcdef123456",
+      parentSpanId: "0102030405",
+      dataset: "testDataset",
+      customContext: {
+        userID: 1,
+        errorMsg: "failed to sign on",
+        toRetry: true,
+      },
+    });
+  });
+});
diff --git a/lib/propagation/propagation.test.js b/lib/propagation/propagation.test.js
index 1e9acde..ac366cb 100644
--- a/lib/propagation/propagation.test.js
+++ b/lib/propagation/propagation.test.js
@@ -28,7 +28,7 @@ describe("marshaling", () => {
 describe("marshaling aws", () => {
   test("version string prefix", () => {
     expect(aws.marshalTraceContext(testContext)).toEqual(
-      "Root=1-abcdef123456;Parent=0102030405;userID=1;errorMsg=failed to sign on;toRetry=true"
+      "Root=abcdef123456;Parent=0102030405;userID=1;errorMsg=failed to sign on;toRetry=true"
     );
   });
 });
diff --git a/lib/propagation/w3c.test.js b/lib/propagation/w3c.test.js
new file mode 100644
index 0000000..cde344f
--- /dev/null
+++ b/lib/propagation/w3c.test.js
@@ -0,0 +1,99 @@
+/* global require describe test expect */
+const propagation = require("."),
+  schema = require("../schema"),
+  Span = require("../api/span"),
+  cases = require("jest-in-case");
+
+const { w3c } = propagation;
+
+// this context structure is the same as what the libhoney event api implementation generate.
+let testContext = {
+  id: "7f042f75651d9782dcff93a45fa99be0",
+  dataset: "testDataset",
+  stack: [new Span({ [schema.TRACE_SPAN_ID]: "c998e73e5420f609" })],
+};
+
+cases(
+  "marshaling w3c",
+  opts => expect(w3c.marshalTraceContext(opts.testContext)).toEqual(opts.header),
+  [
+    {
+      name: "don't propagate non-standard ids, no custom context",
+      testContext: {
+        id: "abcdef123456",
+        dataset: "testDataset",
+        stack: [new Span({ [schema.TRACE_SPAN_ID]: "0102030405" })],
+      },
+      header: "",
+    },
+    {
+      name: "w3c-standard ids, custom context does not propagate in traceparent",
+      testContext: {
+        id: "7f042f75651d9782dcff93a45fa99be0",
+        dataset: "testDataset",
+        stack: [new Span({ [schema.TRACE_SPAN_ID]: "c998e73e5420f609" })],
+        traceContext: {
+          userID: 1,
+          errorMsg: "failed to sign on",
+          toRetry: true,
+        },
+      },
+      header: "00-7f042f75651d9782dcff93a45fa99be0-c998e73e5420f609-01",
+    },
+  ]
+);
+
+cases(
+  "unmarshaling w3c headers",
+  opts => expect(w3c.unmarshalTraceContext(opts.contextStr)).toEqual(opts.value),
+  [
+    {
+      name: "v00, with parent, with span, with traceflags",
+      contextStr: "00-7f042f75651d9782dcff93a45fa99be0-c998e73e5420f609-01",
+      value: {
+        parentSpanId: "c998e73e5420f609",
+        traceId: "7f042f75651d9782dcff93a45fa99be0",
+      },
+    },
+    {
+      name: "unsupported version",
+      contextStr: "99-7f042f75651d9782dcff93a45fa99be0-c998e73e5420f609-01",
+      value: undefined,
+    },
+    {
+      name: "invalid trace id",
+      contextStr: "00-00000000000000000000000000000000-c998e73e5420f609-01",
+      value: undefined,
+    },
+    {
+      name: "invalid trace id, invalid span id",
+      contextStr: "00-00000000000000000000000000000000-0000000000000000-01",
+      value: undefined,
+    },
+    {
+      name: "invalid span id",
+      contextStr: "00-7f042f75651d9782dcff93a45fa99be0-0000000000000000-01",
+      value: undefined,
+    },
+    {
+      name: "v00, missing span id",
+      contextStr: "00-7f042f75651d9782dcff93a45fa99be0-01",
+      value: undefined,
+    },
+    {
+      name: "v00, missing trace id",
+      contextStr: "00-c998e73e5420f609-01",
+      value: undefined,
+    },
+  ]
+);
+
+describe("roundtrip", () => {
+  test("works", () => {
+    let contextStr = w3c.marshalTraceContext(testContext);
+    expect(w3c.unmarshalTraceContext(contextStr)).toEqual({
+      traceId: "7f042f75651d9782dcff93a45fa99be0",
+      parentSpanId: "c998e73e5420f609",
+    });
+  });
+});