Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add attribute linting and ecosystem support. #19

Closed
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .circleci/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ jobs:

format:
docker:
- image: elixir:1.7
- image: elixir:1.8.1
working_directory: ~/repo
steps:
- checkout
Expand Down
12 changes: 9 additions & 3 deletions config/config.exs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@
# and its dependencies with the aid of the Mix.Config module.
use Mix.Config

config :junit_formatter,
report_file: "report.xml",
report_dir: "reports/exunit"
if Mix.env() == :test do
config :junit_formatter,
report_file: "report.xml",
report_dir: "reports/exunit"

config :opencensus,
reporters: [{Opencensus.TestSupport.SpanCaptureReporter, []}],
send_interval_ms: 0
end
2 changes: 2 additions & 0 deletions dialyzer.ignore-warnings
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
:0: Unknown type erlang:stack_item/0
:0: Unknown type opencensus:span_kind/0
220 changes: 220 additions & 0 deletions lib/opencensus/attributes.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
defmodule Opencensus.Attributes do
@moduledoc """
Types and functions for Opencensus-compatibile span attributes.

To be compatible with the OpenCensus protobuf protocol, an [attribute value][AttributeValue]
[MUST] be one of:

* `TruncatableString`
* `int64`
* `bool_value`
* `double_value`

Some destinations are even stricter, e.g. [Datadog].

[Datadog]: https://github.com/DataDog/documentation/blob/0564879/content/en/api/tracing/send_trace.md

The functions in this module:

* Flatten map values as described below
* Convert atom keys and values to strings
* **Drop any other values not compatible with the OpenCensus protobuf definition**
* Return a strict `t:attribute_map/0`

[MUST]: https://tools.ietf.org/html/rfc2119#section-1
[MAY]: https://tools.ietf.org/html/rfc2119#section-5
[AttributeValue]: https://github.com/census-instrumentation/opencensus-proto/blob/e2601ef/src/opencensus/proto/trace/v1/trace.proto#L331

### Flattening

Map flattening uses periods (`.`) to delimit keys from nested maps. These span attributes before
flattening:

```elixir
%{
http: %{
host: "localhost",
method: "POST",
path: "/api"
}
}
```

... become these after flattening:

```elixir
%{
"http.host" => "localhost",
"http.method" => "POST",
"http.path" => "/api",
}
```
"""

@typedoc "Attribute key."
@type attribute_key :: String.t() | atom()

@typedoc "Safe attribute value."
@type attribute_value :: String.t() | atom() | boolean() | integer() | float()

@typedoc "Map of attribute keys to safe attribute values."
@type attribute_map :: %{attribute_key() => attribute_value()}

@typedoc "Map of attribute keys to safe attribute values and nested attribute maps."
@type rich_attribute_value :: attribute_value() | rich_attribute_map()

@typedoc "Map of attribute keys to safe attribute values and nested attribute maps."
@type rich_attribute_map :: %{attribute_key() => rich_attribute_value()}

@typedoc "Automatic attribute key."
@type auto_attribute_key :: :line | :module | :file | :function

@typedoc "An attribute we can flatten into an `t:attribute_map/0`."
@type rich_attribute ::
{attribute_key(), rich_attribute_value()}
| rich_attribute_map()

@doc """
Process span attributes after fetching bare atoms from default attributes.

iex> process_attributes([:default, attr: 1], %{line: 1, module: __MODULE__})
%{"line" => 1, "module" => "Opencensus.TraceTest", "attr" => 1}

iex> process_attributes([:module, attr: 1], %{line: 1, module: __MODULE__})
%{"module" => "Opencensus.TraceTest", "attr" => 1}
"""
@spec process_attributes(
attributes ::
rich_attribute_map()
| list(
:default
| auto_attribute_key()
| rich_attribute
),
default_attributes :: %{auto_attribute_key() => attribute_value()}
) :: attribute_map()
def process_attributes(attributes, default_attributes)

def process_attributes(attributes, default_attributes) when is_list(attributes) do
attributes
|> Enum.map(&replace_defaults(&1, default_attributes))
|> flatten1()
|> process_attributes()
end

def process_attributes(attributes, _) when is_map(attributes) do
attributes |> process_attributes()
end

@doc """
Produce default attributes for `process_attributes/2` given a macro's `__CALLER__`.
"""
@spec default_attributes(Macro.Env.t()) :: %{auto_attribute_key() => attribute_value()}
def default_attributes(env) when is_map(env) do
%{
line: env.line,
module: env.module,
file: env.file,
function: format_function(env.function)
}
end

defp format_function(nil), do: nil
defp format_function({name, arity}), do: "#{name}/#{arity}"

@doc """
Process span attributes.

iex> process_attributes(%{"a" => 1, "b" => %{"c" => 2}})
%{"a" => 1, "b.c" => 2}

iex> process_attributes(a: 1, b: 2.0, c: %{d: true, e: "NONCE"})
%{"a" => 1, "b" => 2.0, "c.d" => true, "c.e" => "NONCE"}

iex> process_attributes(no_pid: self(), no_list: [], no_nil: nil)
%{}
"""
@spec process_attributes(
attributes ::
rich_attribute_map() | list(rich_attribute)
) :: attribute_map()
def process_attributes(attributes)

def process_attributes(attributes) when is_list(attributes) do
attributes
|> Enum.map(&clean_pair/1)
|> flatten1()
|> Enum.into(%{})
end

def process_attributes(attributes) when is_map(attributes) do
attributes |> List.wrap() |> process_attributes()
end

defp replace_defaults(:default, default_attributes) do
default_attributes
end

defp replace_defaults(default_key, default_attributes) when is_atom(default_key) do
[{default_key, Map.fetch!(default_attributes, default_key)}]
end

defp replace_defaults(value, _) do
[value]
end

@spec clean_pair({attribute_key(), term()}) :: [{String.t(), attribute_value()}]
defp clean_pair(key_value_pair)

# If the key is an atom, convert it to a string:
defp clean_pair({k, v}) when is_atom(k), do: {Atom.to_string(k), v} |> clean_pair()

# If the key isn't a string, drop the pair.
defp clean_pair({k, _}) when not is_binary(k), do: []

# If the value is nil, drop the pair:
defp clean_pair({_, v}) when is_nil(v), do: []

# If the value is simple, keep it:
defp clean_pair({k, v})
when is_number(v) or
is_binary(v) or
is_boolean(v) or
is_float(v),
do: [{k, v}]

# If the value is an atom, convert it to a string and remove `Elixir.`:
defp clean_pair({k, v}) when is_atom(v),
do: [{k, v |> Atom.to_string() |> String.replace(~r/^Elixir\./, "")}]

# If the value is a map: flatten, nest, and clean it.
defp clean_pair({k, map}) when is_map(map) do
map
|> Map.to_list()
|> Enum.filter(fn {k, _} -> k != :__struct__ end)
|> Enum.map(&nest(&1, k))
|> Enum.map(&clean_pair/1)
|> flatten1()
end

# If the tuple is actually a map:
defp clean_pair(map) when is_map(map) do
map
|> Map.to_list()
|> Enum.map(&clean_pair/1)
|> flatten1()
end

# Give up:
defp clean_pair(_), do: []

defp nest({k, v}, prefix), do: {"#{prefix}.#{k}", v}

defp flatten1(list) when is_list(list) do
list
|> List.foldr([], fn
x, acc when is_list(x) -> x ++ acc
x, acc -> [x | acc]
end)
end
end
17 changes: 7 additions & 10 deletions lib/opencensus/logger.ex
Original file line number Diff line number Diff line change
Expand Up @@ -34,15 +34,10 @@ defmodule Opencensus.Logger do
[SHOULD]: https://tools.ietf.org/html/rfc2119#section-3
"""

require Record

Record.defrecordp(
:ctx,
Record.extract(:span_ctx, from_lib: "opencensus/include/opencensus.hrl")
)
alias Opencensus.SpanContext

@doc "Sets the Logger metadata according to the current span context."
def set_logger_metadata(), do: set_logger_metadata(:ocp.current_span_ctx())
def set_logger_metadata, do: set_logger_metadata(:ocp.current_span_ctx())

@doc "Sets the Logger metadata according to a supplied span context."
@spec set_logger_metadata(:opencensus.span_ctx() | :undefined) :: :ok
Expand All @@ -51,10 +46,12 @@ defmodule Opencensus.Logger do
def set_logger_metadata(:undefined), do: set_logger_metadata(nil, nil, nil)

def set_logger_metadata(span_ctx) do
context = SpanContext.from(span_ctx)

set_logger_metadata(
List.to_string(:io_lib.format("~32.16.0b", [ctx(span_ctx, :trace_id)])),
List.to_string(:io_lib.format("~16.16.0b", [ctx(span_ctx, :span_id)])),
ctx(span_ctx, :trace_options)
SpanContext.hex_trace_id(context.trace_id),
SpanContext.hex_span_id(context.span_id),
context.trace_options
)
end

Expand Down
37 changes: 37 additions & 0 deletions lib/opencensus/span.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
defmodule Opencensus.Span do
@moduledoc """
Elixir convenience translation of `:opencensus.span`.

Most likely to be of use while writing unit tests, or packages that deal with spans.
Less likely to be of use while writing application code.
"""

alias Opencensus.SpanContext

require Record
@fields Record.extract(:span, from_lib: "opencensus/include/opencensus.hrl")
Record.defrecordp(:span, @fields)

defstruct Keyword.keys(@fields)

@doc "Get a span struct given a record."
@spec from(:opencensus.span()) :: %__MODULE__{}
def from(record) when Record.is_record(record, :span), do: struct!(__MODULE__, span(record))

@doc "Load a span from ETS. Only works until it has been sent."
@spec load(:opencensus.span_ctx() | integer() | :undefined) :: %__MODULE__{} | nil
def load(span_id_or_ctx)

def load(:undefined), do: nil

def load(span_id) when is_integer(span_id) do
case :ets.lookup(:oc_span_tab, span_id) do
[record] -> from(record)
[] -> nil
end
end

def load(span_ctx) when is_tuple(span_ctx) do
span_ctx |> SpanContext.from() |> Map.get(:span_id) |> load()
end
end
59 changes: 59 additions & 0 deletions lib/opencensus/span_context.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
defmodule Opencensus.SpanContext do
@moduledoc """
Elixir convenience translation of `:opencensus.span_ctx`.

Most likely to be of use while writing unit tests, or packages that deal with spans.
Less likely to be of use while writing application code.
"""

require Record
@fields Record.extract(:span_ctx, from_lib: "opencensus/include/opencensus.hrl")
Record.defrecordp(:span_ctx, @fields)

defstruct Keyword.keys(@fields)

@doc """
Convert a span context.

iex> :opencensus.span_ctx()
:undefined
iex> :opencensus.span_ctx() |> Opencensus.SpanContext.from()
nil

iex> trace_id = 158162877550332985110351567058860353513
iex> span_id = 13736401818514315360
iex> span_ctx = {:span_ctx, trace_id, span_id, 1, :undefined}
iex> Opencensus.SpanContext.from(span_ctx)
%Opencensus.SpanContext{
span_id: 13736401818514315360,
trace_id: 158162877550332985110351567058860353513,
trace_options: 1,
tracestate: :undefined
}
"""
@spec from(:opencensus.span_ctx() | :undefined) :: %__MODULE__{}
def from(record)
garthk marked this conversation as resolved.
Show resolved Hide resolved

def from(record) when Record.is_record(record, :span_ctx),
do: struct!(__MODULE__, span_ctx(record))

def from(:undefined), do: nil

@doc "Return the 32-digit hex representation of a trace ID."
@spec hex_trace_id(:undefined | integer()) :: String.t()
def hex_trace_id(trace_id)

def hex_trace_id(n) when is_integer(n) and n > 0,
do: :io_lib.format("~32.16.0b", [n]) |> to_string()

def hex_trace_id(_), do: nil

@doc "Return the 16-digit hex representation of a span ID."
@spec hex_span_id(:undefined | integer()) :: String.t()
def hex_span_id(span_id)

def hex_span_id(n) when is_integer(n) and n > 0,
do: :io_lib.format("~16.16.0b", [n]) |> to_string()

def hex_span_id(_), do: nil
end
Loading