Seemed like a great idea until I was assured that the momentum was with Opencensus, not Spandex. Between this and the recently announced merger of Opencensus and OpenTracing, it strikes me that it's a reasonable bet to invest on Opencensus propagation headers instead.
Created
April 23, 2019 22:46
-
-
Save garthk/3df39791cff980e5cc17d460dba17b97 to your computer and use it in GitHub Desktop.
Honeycomb Beeline Header Parsing and Formatting in Elixir
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
defmodule SpandexHoneycomb.Header do | |
@moduledoc """ | |
Functions to consume and produce Honeycomb trace headers, which can carry | |
three values: | |
* `trace_id` - the identifier of the current trace; required | |
* `parent_id` - the identifier of the parent span in that trace; required | |
* `context` - Base64-encoded JSON object passing event data; optional | |
* `dataset` - the Honeycomb dataset to which our parent span recommends we send this event | |
See Chris' [RFC in a comment](https://git.io/fjLWv#L14) for more detail. | |
""" | |
@ok_keys Enum.join(["trace_id", "parent_id", "context", "dataset"], "|") | |
@version_and_pairs_re ~r/^(?<version>[0-9]+);(?<pairs>.*)$/ | |
@commas_re ~r/,/ | |
@pair_re ~r/^(?<key>#{@ok_keys})=(?<value>.*)$/ | |
@typedoc """ | |
Internal representation of the values carried by Honeycomb trace headers. | |
""" | |
@type trace_info :: %{ | |
required(:trace_id) => binary(), | |
required(:parent_id) => binary(), | |
optional(:dataset) => binary(), | |
optional(:context) => map() | |
} | |
@doc """ | |
Parse a Honeycomb header. | |
Returns either a well formed `t:trace_info/0`, or `nil` if it can't. | |
Example: | |
iex> header = "1;trace_id=T,parent_id=P,context=e30K" | |
iex> SpandexHoneycomb.Header.parse(header) | |
%{:trace_id => "T", :parent_id => "P", :context => %{}} | |
""" | |
@spec parse(binary()) :: trace_info | nil | |
def parse(header) when is_binary(header) do | |
case extract_version_and_pairs(header) do | |
{"1", t} -> | |
check_trace_info(extract_trace_info(t)) | |
_ -> | |
nil | |
end | |
end | |
def parse(_header) do | |
nil | |
end | |
@spec extract_version_and_pairs(binary()) :: nil | {binary(), list(binary())} | |
defp extract_version_and_pairs(t) do | |
case Regex.named_captures(@version_and_pairs_re, t) do | |
%{"version" => version, "pairs" => pairs} -> | |
{version, Regex.split(@commas_re, pairs)} | |
_ -> | |
nil | |
end | |
end | |
defp parse_pair_capture(k, v) when k == "context" do | |
case parse_context_base64(v) do | |
nil -> nil | |
context -> {:context, context} | |
end | |
end | |
defp parse_pair_capture(k, v) do | |
{String.to_atom(k), v} | |
end | |
defp parse_context_base64(t) do | |
try do | |
%{} = Jason.decode!(Base.decode64!(t)) | |
rescue | |
ArgumentError -> nil | |
Jason.DecodeError -> nil | |
end | |
end | |
@spec extract_trace_info(list(binary())) :: map() | |
defp extract_trace_info(pairs) when is_list(pairs) do | |
pairs | |
|> Enum.map(&split_pairs/1) | |
|> Enum.filter(fn pair -> pair != nil end) | |
|> Enum.into(%{:context => %{}}) | |
end | |
@spec split_pairs(binary()) :: nil | {atom(), binary()} | |
defp split_pairs(t) do | |
case Regex.named_captures(@pair_re, t) do | |
%{"key" => key, "value" => value} -> | |
parse_pair_capture(key, value) | |
_ -> | |
nil | |
end | |
end | |
@spec check_trace_info(map()) :: trace_info | nil | |
defp check_trace_info(info) when is_map(info) do | |
if Map.has_key?(info, :trace_id) and Map.has_key?(info, :parent_id) do | |
info | |
end | |
end | |
@doc """ | |
Format a Honeycomb header. | |
Returns either a header Honeycomb's beelines can handle, or "" if it can't. | |
Example: | |
iex> info = %{:trace_id => "T", :parent_id => "P", :context => %{}} | |
iex> SpandexHoneycomb.Header.format(info) | |
"1;trace_id=T,parent_id=P,context=e30=" | |
""" | |
@spec format(trace_info) :: binary() | |
def format(%{:trace_id => _t, :parent_id => _p} = parsed) do | |
"1;" <> format_pairs(parsed) | |
end | |
def format(_parsed), do: "" | |
defp format_pairs(parsed) do | |
[ | |
format_pair(parsed, :trace_id), | |
format_pair(parsed, :parent_id), | |
format_pair(parsed, :dataset), | |
format_pair(parsed, :context, &repr_context/1) | |
] | |
|> Enum.filter(& &1) | |
|> Enum.join(",") | |
end | |
defp format_pair(parsed, key, repr \\ & &1) do | |
case parsed do | |
%{^key => value} -> "#{key}=#{repr.(value)}" | |
_ -> nil | |
end | |
end | |
defp repr_context(c) do | |
case c do | |
%{} -> Base.encode64(Jason.encode!(c)) | |
_ -> raise ArgumentError, message: ":context must be a map" | |
end | |
end | |
end |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
defmodule SpandexHoneycomb.AdapterTest do | |
use ExUnit.Case, async: true | |
doctest SpandexHoneycomb.Header | |
test "round trip with context" do | |
original = %{ | |
:trace_id => Ksuid.generate(), | |
:parent_id => Ksuid.generate(), | |
:dataset => "beeline", | |
:context => %{ | |
"customer_id" => 23 | |
} | |
} | |
header = SpandexHoneycomb.Header.format(original) | |
parsed = SpandexHoneycomb.Header.parse(header) | |
assert parsed == original | |
end | |
test "round trip without context or dataset" do | |
original = %{ | |
:trace_id => Ksuid.generate(), | |
:parent_id => Ksuid.generate() | |
} | |
header = SpandexHoneycomb.Header.format(original) | |
parsed = SpandexHoneycomb.Header.parse(header) | |
assert parsed[:trace_id] == original[:trace_id] | |
assert parsed[:parent_id] == original[:parent_id] | |
assert parsed[:context] == %{} | |
end | |
test "parse nil header -> nil" do | |
parsed = SpandexHoneycomb.Header.parse(nil) | |
assert parsed == nil | |
end | |
test "parse empty header -> nil" do | |
parsed = SpandexHoneycomb.Header.parse("") | |
assert parsed == nil | |
end | |
test "parse header with unexpected version -> nil" do | |
parsed = SpandexHoneycomb.Header.parse("2;newformat=breakingchange") | |
assert parsed == nil | |
end | |
test "parse header with only a version -> nil" do | |
parsed = SpandexHoneycomb.Header.parse("1;") | |
assert parsed == nil | |
end | |
test "parse header with only trace_id -> nil" do | |
parsed = SpandexHoneycomb.Header.parse("1;trace_id=T") | |
assert parsed == nil | |
end | |
test "parse header with only parent_id -> nil" do | |
parsed = SpandexHoneycomb.Header.parse("1;parent_id=P") | |
assert parsed == nil | |
end | |
test "parse header with extra content -> extra content ignored" do | |
parsed = SpandexHoneycomb.Header.parse("1;trace_id=T,parent_id=P,x=y") | |
assert %{:trace_id => "T", :parent_id => "P"} = parsed | |
assert not Map.has_key?(parsed, "x") | |
assert not Map.has_key?(parsed, :x) | |
end | |
test "parse header with whitespace in values -> whitespace kept" do | |
parsed = SpandexHoneycomb.Header.parse("1;trace_id=T ,parent_id= P") | |
assert %{:trace_id => "T ", :parent_id => " P"} = parsed | |
end | |
test "parse header with whitespace around keys -> nil" do | |
# Honeycomb ruling: these keys are " trace_id" and "parent_id ". | |
# We need "trace_id" and "parent_id", so parsing fails. | |
parsed = SpandexHoneycomb.Header.parse("1; trace_id=T ,parent_id =P") | |
assert parsed == nil | |
end | |
test "format nil -> empty string" do | |
header = SpandexHoneycomb.Header.format(nil) | |
assert header == "" | |
end | |
test "format empty map -> empty string" do | |
header = SpandexHoneycomb.Header.format(%{}) | |
assert header == "" | |
end | |
test "format map missing :trace_id -> empty string" do | |
info = %{:parent_id => "p", :dataset => "d", :context => %{}} | |
header = SpandexHoneycomb.Header.format(info) | |
assert header == "" | |
end | |
test "format map missing :parent_id -> empty string" do | |
info = %{:trace_id => "t", :dataset => "d", :context => %{}} | |
header = SpandexHoneycomb.Header.format(info) | |
assert header == "" | |
end | |
end |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment