diff --git a/README.md b/README.md index 2613967..5ba75f9 100644 --- a/README.md +++ b/README.md @@ -7,40 +7,71 @@ A function decorator for OpenTelemetry traces. +## Installation + +Add `open_telemetry_decorator` to your list of dependencies in `mix.exs`. We include the `opentelemetry_api` package, but you'll need to add `opentelemetry` yourself in order to report spans and traces. + +```elixir +def deps do + [ + {:open_telemetry_decorator, "~> 0.4.0"}, + {:opentelemetry, "~> 0.4.0"} + ] +end +``` + +Then follow the directions for the exporter of your choice to send traces to to zipkin, honeycomb, etc. + +https://github.com/garthk/opentelemetry_honeycomb + +https://github.com/opentelemetry-beam/opentelemetry_zipkin + ## Usage -The span name can be any string. +Add `use OpenTelemetryDecorator` to the module, and decorate any methods you want to trace with `@decorate trace("span name")` or `@decorate simple_trace("span name")`. - defmodule MyApp.Worker do - use OpenTelemetryDecorator +The `simple_trace` decorator will automatically add your input parameters and the function result to the span attributes. If you omit the span name, one will be generated based on the module, function, and arity. Specifying a name is helpful for `handle_info` type functions where the name/arity would be ambiguous. - @decorate trace("my_app.worker.do_work") - def do_work(arg1, arg2) do - ...doing work - do_more_work(arg1) - end +```elixir +defmodule MyApp.Worker do + use OpenTelemetryDecorator + + @decorate simple_trace() # Generates span name "MyApp.Worker.do_work/2". or... + @decorate simple_trace("worker.do_work") + def do_work(arg1, arg2) do + ...doing work + end +end +``` - @decorate trace("MyApp::Worker::do_more_work") - def do_more_work(arg1) do - ...doing more work - end - end +The `trace` decorator allows you to specify an `includes` option which gives you more flexibility with what you can include in the span attributes. Omitting the `includes` option with `trace` means no attributes will be added to the span. -This decorator inserts all of code to add a span to the registered tracer into your method at compile time. In the example above, the `do_work` method would become something like this: +```elixir +defmodule MyApp.Worker do + use OpenTelemetryDecorator - def do_work(arg1, arg2) do - require OpenTelemetry.Span - require OpenTelemetry.Tracer + @decorate trace("worker.do_work", include: [:arg1, :arg2]) + def do_work(arg1, arg2) do + ...doing work + end +end +``` - parent_ctx = OpenTelemetry.Tracer.current_span_ctx() +The decorator uses a macro to insert code into your function at compile time to wrap the body in a new span and link it to the currently active span. In the example above, the `do_work` method would become something like this: - OpenTelemetry.Tracer.with_span "my_app.worker.do_work", %{parent: parent_ctx} do - ...doing work - do_more_work(arg1) - end - end +```elixir +def do_work(arg1, arg2) do + require OpenTelemetry.Span + require OpenTelemetry.Tracer -We use `OpenTelemetry.Tracer.current_span_ctx()` to automatically link new spans to the current trace (if it exists and is in the same process). So the above example will link the `do_work` and `do_more_work` spans for you by default. + parent_ctx = OpenTelemetry.Tracer.current_span_ctx() + + OpenTelemetry.Tracer.with_span "my_app.worker.do_work", %{parent: parent_ctx} do + OpenTelemetry.Span.set_attributes(arg1: arg1, arg2: arg2) + ...doing work + end +end +``` You can provide span attributes by specifying a list of variable names as atoms. @@ -48,51 +79,43 @@ This list can include... Any variables (in the top level closure) available when the function exits: - defmodule MyApp.Math do - use OpenTelemetryDecorator - - @decorate trace("my_app.math.add", include: [:a, :b, :sum]) - def add(a, b) do - sum = a + b - {:ok, thing1} - end - end - - -The result of the function by including the atom `:result`: - - defmodule MyApp.Math do - use OpenTelemetryDecorator - - @decorate trace("my_app.math.add", include: [:result]) - def add(a, b) do - sum = a + b - {:ok, thing1} - end - end - - -Map/struct properties using nested lists of atoms: - - defmodule MyApp.Worker do - use OpenTelemetryDecorator +```elixir +defmodule MyApp.Math do + use OpenTelemetryDecorator + + @decorate trace("my_app.math.add", include: [:a, :b, :sum]) + def add(a, b) do + sum = a + b + {:ok, thing1} + end +end +``` - @decorate trace("my_app.worker.do_work", include: [[:arg1, :count], [:arg2, :count], :total]) - def do_work(arg1, arg2) do - total = arg1.count + arg2.count - {:ok, total} - end - end +The result of the function by including the atom `:result`: -## Installation +```elixir +defmodule MyApp.Math do + use OpenTelemetryDecorator + + @decorate trace("my_app.math.add", include: [:result]) + def add(a, b) do + sum = a + b + {:ok, thing1} + end +end +``` -Add `open_telemetry_decorator` to your list of dependencies in `mix.exs` and do a `mix deps.get`: +Map/struct properties using nested lists of atoms: ```elixir -def deps do - [ - {:open_telemetry_decorator, "~> 0.3.0"} - ] +defmodule MyApp.Worker do + use OpenTelemetryDecorator + + @decorate trace("my_app.worker.do_work", include: [[:arg1, :count], [:arg2, :count], :total]) + def do_work(arg1, arg2) do + total = arg1.count + arg2.count + {:ok, total} + end end ``` diff --git a/lib/attributes.ex b/lib/attributes.ex index 7515812..8e3550d 100644 --- a/lib/attributes.ex +++ b/lib/attributes.ex @@ -2,10 +2,6 @@ defmodule Attributes do @moduledoc false def get(bound_variables, reportable_attr_keys, result \\ nil) do - get_reportable_attrs(bound_variables, reportable_attr_keys, result) - end - - defp get_reportable_attrs(bound_variables, reportable_attr_keys, result) do bound_variables |> take_attrs(reportable_attr_keys) |> maybe_add_result(reportable_attr_keys, result) diff --git a/lib/open_telemetry_decorator.ex b/lib/open_telemetry_decorator.ex index 756fb2a..1fc59ec 100644 --- a/lib/open_telemetry_decorator.ex +++ b/lib/open_telemetry_decorator.ex @@ -9,18 +9,18 @@ defmodule OpenTelemetryDecorator do # compensate for anchor id differences between ExDoc and GitHub |> (&Regex.replace(~R{\(\#\K(?=[a-z][a-z0-9-]+\))}, &1, "module-")).() - use Decorator.Define, trace: 1, trace: 2 + use Decorator.Define, trace: 1, trace: 2, simple_trace: 0, simple_trace: 1 @doc """ - Decorate a function to add an OpenTelemetry trace with a named span. + Decorate a function to add an OpenTelemetry trace with a named span. You can provide span attributes by specifying a list of variable names as atoms. - You can provide span attributes by specifying a list of variable names as atoms. This list can include: + This list can include: - any variables (in the top level closure) available when the function exits, - the result of the function by including the atom `:result`, - map/struct properties using nested lists of atoms. - ``` + ```elixir defmodule MyApp.Worker do use OpenTelemetryDecorator @@ -57,4 +57,54 @@ defmodule OpenTelemetryDecorator do target = "#{inspect(context.module)}.#{context.name}/#{context.arity} @decorate telemetry" reraise %ArgumentError{message: "#{target} #{e.message}"}, __STACKTRACE__ end + + @doc """ + Decorate a function to add an OpenTelemetry trace with a named span. The input parameters and result are automatically added to the span attributes. + You can specify a span name or one will be generated based on the module name, function name, and arity. + + ```elixir + defmodule MyApp.Worker do + use OpenTelemetryDecorator + + @decorate simple_trace() + def do_work(arg1, arg2) do + total = arg1.count + arg2.count + {:ok, total} + end + + @decorate simple_trace("worker.do_more_work") + def handle_call({:do_more_work, args}, _from, state) do + {:reply, {:ok, args}, state} + end + end + ``` + """ + def simple_trace(body, context) do + context + |> SpanName.from_context() + |> simple_trace(body, context) + end + + def simple_trace(span_name, body, context) do + quote location: :keep do + require OpenTelemetry.Span + require OpenTelemetry.Tracer + + parent_ctx = OpenTelemetry.Tracer.current_span_ctx() + + OpenTelemetry.Tracer.with_span unquote(span_name), %{parent: parent_ctx} do + OpenTelemetry.Span.set_attributes(Kernel.binding()) + + result = unquote(body) + + OpenTelemetry.Span.set_attributes(result: result) + + result + end + end + rescue + e in ArgumentError -> + target = "#{inspect(context.module)}.#{context.name}/#{context.arity} @decorate telemetry" + reraise %ArgumentError{message: "#{target} #{e.message}"}, __STACKTRACE__ + end end diff --git a/lib/span_name.ex b/lib/span_name.ex new file mode 100644 index 0000000..dd1e720 --- /dev/null +++ b/lib/span_name.ex @@ -0,0 +1,8 @@ +defmodule SpanName do + @moduledoc false + + def from_context(%{module: m, name: f, arity: a}), do: "#{trim(m)}.#{f}/#{a}" + + # "Elixir module names are just atoms prefixed with 'Elixir.'" + defp trim(m), do: m |> Atom.to_string() |> String.trim_leading("Elixir.") +end diff --git a/mix.exs b/mix.exs index e178a5b..eff24d7 100644 --- a/mix.exs +++ b/mix.exs @@ -1,7 +1,7 @@ defmodule OpenTelemetryDecorator.MixProject do use Mix.Project - @version "0.3.0" + @version "0.4.0" @github_page "https://github.com/marcdel/open_telemetry_decorator" def project do diff --git a/test/open_telemetry_decorator_test.exs b/test/open_telemetry_decorator_test.exs index fe3f87b..f5a838d 100644 --- a/test/open_telemetry_decorator_test.exs +++ b/test/open_telemetry_decorator_test.exs @@ -5,46 +5,46 @@ defmodule OpenTelemetryDecoratorTest do require OpenTelemetry.Tracer require OpenTelemetry.Span - # Make span methods available require Record + # Make span methods available for {name, spec} <- Record.extract_all(from_lib: "opentelemetry/include/ot_span.hrl") do Record.defrecord(name, spec) end setup [:telemetry_pid_reporter] - defmodule Example do - use OpenTelemetryDecorator + describe "trace" do + defmodule Example do + use OpenTelemetryDecorator - @decorate trace("Example.step", include: [:id, :result]) - def step(id), do: {:ok, id} + @decorate trace("Example.step", include: [:id, :result]) + def step(id), do: {:ok, id} - @decorate trace("Example.workflow", include: [:count, :result]) - def workflow(count), do: Enum.map(1..count, fn id -> step(id) end) + @decorate trace("Example.workflow", include: [:count, :result]) + def workflow(count), do: Enum.map(1..count, fn id -> step(id) end) - @decorate trace("Example.numbers", include: [:up_to]) - def numbers(up_to), do: [1..up_to] + @decorate trace("Example.numbers", include: [:up_to]) + def numbers(up_to), do: [1..up_to] - @decorate trace("Example.find", include: [:id, [:user, :name], :error, :_even, :result]) - def find(id) do - _even = rem(id, 2) == 0 - user = %{id: id, name: "my user"} + @decorate trace("Example.find", include: [:id, [:user, :name], :error, :_even, :result]) + def find(id) do + _even = rem(id, 2) == 0 + user = %{id: id, name: "my user"} - case id do - 1 -> - {:ok, user} + case id do + 1 -> + {:ok, user} - error -> - {:error, error} + error -> + {:error, error} + end end - end - @decorate trace("Example.no_include") - def no_include(opts), do: {:ok, opts} - end + @decorate trace("Example.no_include") + def no_include(opts), do: {:ok, opts} + end - describe "trace" do test "does not modify inputs or function result" do assert Example.step(1) == {:ok, 1} end @@ -110,6 +110,38 @@ defmodule OpenTelemetryDecoratorTest do end end + describe "simple_trace" do + defmodule Math do + use OpenTelemetryDecorator + + @decorate simple_trace() + def add(a, b), do: a + b + + @decorate simple_trace("math.subtraction") + def subtract(a, b), do: a - b + end + + test "automatically adds inputs, outputs, and generates span name" do + Math.add(2, 3) + + assert_receive {:span, + span( + name: "OpenTelemetryDecoratorTest.Math.add/2", + attributes: [a: 2, b: 3, result: 5] + )} + end + + test "span name can be specified" do + Math.subtract(3, 2) + + assert_receive {:span, + span( + name: "math.subtraction", + attributes: [a: 3, b: 2, result: 1] + )} + end + end + def telemetry_pid_reporter(_) do ExUnit.CaptureLog.capture_log(fn -> :application.stop(:opentelemetry) end)