Skip to content

Instantly share code, notes, and snippets.

@garthk
Created April 23, 2019 22:46
Show Gist options
  • Save garthk/3df39791cff980e5cc17d460dba17b97 to your computer and use it in GitHub Desktop.
Save garthk/3df39791cff980e5cc17d460dba17b97 to your computer and use it in GitHub Desktop.
Honeycomb Beeline Header Parsing and Formatting in Elixir
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
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