Packages

Helps you to create OpenTelemetry spans with the least effort

Current section

Files

Jump to
hatch_tracing lib hatch_tracing.ex
Raw

lib/hatch_tracing.ex

defmodule HatchTracing do
@moduledoc """
Tracing features for elixir apps.
Just call `use HatchTracing` in a module and you're good to go.
You'll probably just need to use the `span` functions, as the other functions
are here just for compatibility. Check the `Usage` section below.
All other functions in this module have the same name and arity from
`OpenTelemetry.Tracer`. Whenever possible, these functions will actually be
`defdelegate`. When not possible, this means that some quality-of-life was
added before calling the corresponding `OpenTelemetry.Tracer` function.
## Usage
When you `use HatchTracing` it will include all required modules to start
using tracing, as well as importing the `span/1`, `span/2` and `span/3`
functions.
```elixir
defmodule MyModule do
use HatchTracing
def my_function do
span do
# ... some code here
end
end
end
```
## On Span Names
By default, if no name is specified, the generated span name will include the
module, function name and function arity.
```
Hatch.Portal.UseCases.Xablau.call/1
```
When adding custom names, keep in mind the official guideline for span names.
The span name concisely identifies the work represented by the Span, for
example, an RPC method name, a function name, or the name of a subtask or
stage within a larger computation. The span name SHOULD be the most general
string that identifies a (statistically) interesting class of Spans, rather
than individual Span instances while still being human-readable. That is,
“get_user” is a reasonable name, while “get_user/314159”, where “314159” is a
user ID, is not a good name due to its high cardinality. Generality SHOULD be
prioritized over human-readability, like “get_account/{accountId}”.
"""
require OpenTelemetry.Tracer
defguard is_ast(value) when is_tuple(value) and tuple_size(value) == 3
defguard valid_opts(opts) when is_map(opts) or is_ast(opts)
defguard valid_name(name) when is_binary(name) or is_nil(name) or is_ast(name)
@doc """
Setup the target module to use tracing tools.
It will also import the `span` function for a quickstart.
"""
defmacro __using__(_opts) do
quote do
require OpenTelemetry.Tracer
require unquote(__MODULE__)
import unquote(__MODULE__), only: [span: 1, span: 2, span: 3]
end
end
@doc """
This is a custom and simpler version of `with_span`.
The order of parameters is slightly different in order to favour automatic
name generation.
- start_opts: a map with starting options. Check the `start_otps/1` function
for more information
- name: a string with the name of the span (default to nil). When nil, the
name will be automatically generated based on the caller context (module +
function)
Creates a new span which is set to the currently active Span in the Context of
the block. The Span is ended automatically when the `block` completes and the
Context is what it was before the block.
If the name is `nil`, a span name is automatically generated based on the
module and function that started the span, like
`Hatch.Portal.UseCases.Xablau.call/1`.
"""
defmacro span(start_opts \\ quote(do: %{}), name \\ nil, do: block)
when valid_opts(start_opts) and valid_name(name) do
name = name || gen_span_name(__CALLER__)
quote do
__MODULE__
|> :opentelemetry.get_application_tracer()
|> :otel_tracer.with_span(
unquote(name),
unquote(start_opts),
fn ctx ->
try do
unquote(block)
rescue
e ->
type =
e
|> Map.get(:__struct__)
|> to_string()
|> String.replace("Elixir.", "")
attrs = %{
"type" => type,
"message" => Exception.message(e),
"stacktrace" => Exception.format_stacktrace(__STACKTRACE__)
}
HatchTracing.set_attributes("exception", attrs)
OpenTelemetry.Tracer.set_status(:error, "exception")
:otel_span.end_span(ctx)
reraise e, __STACKTRACE__
end
end
)
end
end
defp gen_span_name(caller) do
{function, arity} = caller.function
base_name =
caller.module
|> Atom.to_string()
|> String.replace("Elixir.", "")
|> String.replace("/", ".")
base_name <> ".#{function}/#{arity}"
end
@doc """
Generate a valid start_opts map to start a trace.
When overriding, make sure that the value is correct, as this function does
not perform any checks.
Accepted keys: :kind, :attributes, :links, :start_time, and :is_recording.
"""
@spec build_span_opts(Keyword.t()) :: map()
def build_span_opts(opts \\ []) do
opts
|> Keyword.take([:kind, :attributes, :links, :start_time, :is_recording])
|> Map.new()
end
# Simple guard to detect if a variable is a set of values or not
defguardp is_set(value) when is_map(value) or is_list(value) or is_tuple(value)
@doc """
End the currently active Span and sets its end timestamp.
This has no effect on any child Spans that may exist of this Span.
To end a specific span, see `OpenTelemetry.Span.end_span/1`.
The Span in the current Context has its `is_recording` set to `false`.
"""
@spec end_span(:opentelemetry.timestamp() | :undefined) ::
:opentelemetry.span_ctx() | :undefined
defdelegate end_span(timestamp \\ :undefined), to: OpenTelemetry.Tracer
@doc """
Starts a new span and does not make it the current active span of the current
process.
The current active Span is used as the parent of the created Span.
A Span represents a single operation within a trace. Spans can be nested to
form a trace tree. Each trace contains a root span, which typically describes
the entire operation and, optionally, one or more sub-spans for its
sub-operations.
Referece:
https://opentelemetry.io/docs/reference/specification/trace/api/#span
"""
defmacro start_span(name, opts) do
opts =
opts
|> enumerable_to_attrs()
|> Macro.escape()
quote bind_quoted: [name: name, start_opts: opts] do
__MODULE__
|> :opentelemetry.get_application_tracer()
|> :otel_tracer.start_span(
name,
Map.new(start_opts)
)
end
end
@doc """
Starts a new span and does not make it the current active span of the current
process.
The current active Span is used as the parent of the created Span.
A Span represents a single operation within a trace. Spans can be nested to
form a trace tree. Each trace contains a root span, which typically describes
the entire operation and, optionally, one or more sub-spans for its
sub-operations.
Referece:
https://opentelemetry.io/docs/reference/specification/trace/api/#span
"""
defmacro start_span(ctx, name, opts) do
opts =
opts
|> enumerable_to_attrs()
|> Macro.escape()
quote bind_quoted: [ctx: ctx, name: name, start_opts: opts] do
:otel_tracer.start_span(
ctx,
:opentelemetry.get_application_tracer(__MODULE__),
name,
Map.new(start_opts)
)
end
end
@doc """
Takes a `t:OpenTelemetry.span_ctx/0` and the Tracer sets it to the currently
active Span.
"""
@spec set_current_span(:opentelemetry.span_ctx() | :undefined) ::
:opentelemetry.span_ctx() | :undefined
defdelegate set_current_span(span_context), to: OpenTelemetry.Tracer
@doc """
Takes a `t:OpenTelemetry.Ctx.t/0` and the `t:OpenTelemetry.span_ctx/0` and the
Tracer sets it to the current span in the pass Context.
"""
@spec set_current_span(:otel_ctx.t(), :opentelemetry.span_ctx() | :undefined) :: :otel_ctx.t()
defdelegate set_current_span(context, span_context), to: OpenTelemetry.Tracer
@doc """
Creates and sets the Status of the currently active Span.
If used, this will override the default Span Status, which is `:unset`.
Reference:
https://opentelemetry.io/docs/reference/specification/trace/api/#set-status
"""
@spec set_status(OpenTelemetry.status() | OpenTelemetry.status_code(), String.t()) :: boolean()
def set_status(status_or_status_code, description \\ "")
def set_status(status, _description) when is_tuple(status) do
OpenTelemetry.Tracer.set_status(status)
end
def set_status(status_code, description) do
OpenTelemetry.Tracer.set_status(status_code, description)
end
# @doc """
# Creates a new span which is set to the currently active Span in the Context of
# the block. The Span is ended automatically when the `block` completes and the
# Context is what it was before the block.
# See `start_span/2` and `end_span/0`.
# """
defmacro with_span(name, start_opts \\ quote(do: %{}), do: block) do
quote do
__MODULE__
|> :opentelemetry.get_application_tracer()
|> :otel_tracer.with_span(
unquote(name),
Map.new(unquote(start_opts)),
fn _arg -> unquote(block) end
)
end
end
@doc """
Creates a new span which is set to the currently active Span in the Context of
the block. The Span is ended automatically when the `block` completes and the
Context is what it was before the block.
See `start_span/2` and `end_span/0`.
"""
defmacro with_span(ctx, name, start_opts, do: block) do
start_opts =
start_opts
|> enumerable_to_attrs()
|> Macro.escape()
quote do
:otel_tracer.with_span(
unquote(ctx),
:opentelemetry.get_application_tracer(__MODULE__),
unquote(name),
Map.new(unquote(start_opts)),
fn _arg -> unquote(block) end
)
end
end
@doc """
Returns the currently active `t:OpenTelemetry.span_ctx/0`.
"""
@spec current_span_ctx() :: :opentelemetry.span_ctx() | :undefined
defdelegate current_span_ctx, to: OpenTelemetry.Tracer
@doc """
Returns the `t:OpenTelemetry.span_ctx/0` active in Context `ctx`.
"""
@spec current_span_ctx(:otel_ctx.t()) :: :opentelemetry.span_ctx() | :undefined
defdelegate current_span_ctx(context), to: OpenTelemetry.Tracer
@doc """
Set an attribute with key and value on the currently active Span.
Attributes are keys and values that are applied as metadata to your spans and
are useful for aggregating, filtering, and grouping traces. Attributes can be
added at span creation, or at any other time during the lifecycle of a span
before it has completed.
If the value is a set (map, list or tuple), its value is converted to a format
that is supported by OpenTelemetryApi.
Reference:
https://opentelemetry.io/docs/reference/specification/common/attribute-naming/
"""
@spec set_attribute(OpenTelemetry.attribute_key(), OpenTelemetry.attribute_value()) :: boolean()
def set_attribute(key, value)
def set_attribute(key, value) when is_set(value) do
set_attributes(key, value)
end
def set_attribute(key, value) do
OpenTelemetry.Tracer.set_attribute(key, value)
end
@doc """
Add a list of attributes to the currently active Span.
The list of attributes can be a plain list, keyword lists, maps or tuples.
Reference:
https://opentelemetry.io/docs/reference/specification/common/attribute-naming/
"""
@spec set_attributes(OpenTelemetry.attribute_key(), map() | list() | tuple()) :: boolean()
def set_attributes(key, values) do
key
|> enumerable_to_attrs(values)
|> OpenTelemetry.Tracer.set_attributes()
end
@doc """
Add an event to the currently active Span.
An event is a human-readable message on a span that represents “something
happening” during it’s lifetime. For example, imagine a function that requires
exclusive access to a resource that is under a mutex. An event could be
created at two points - once, when we try to gain access to the resource, and
another when we acquire the mutex.
A useful characteristic of events is that their timestamps are displayed as
offsets from the beginning of the span, allowing you to easily see how much
time elapsed between them.
Events can also have attributes of their own.
Reference:
https://opentelemetry.io/docs/reference/specification/trace/api/#add-events
"""
@spec add_event(OpenTelemetry.event_name(), OpenTelemetry.attributes_map()) :: boolean
def add_event(name, attributes \\ %{}) do
:otel_span.add_event(
:otel_tracer.current_span_ctx(),
name,
attributes
)
end
@doc """
Add a list of events to the currently active Span.
See also `add_event/2`.
Reference:
https://opentelemetry.io/docs/reference/specification/trace/api/#add-events
"""
@spec add_events([OpenTelemetry.event()]) :: boolean()
defdelegate add_events(events), to: OpenTelemetry.Tracer
@doc """
Record an exception as an event, following the semantics convetions for exceptions.
If trace is not provided, the stacktrace is retrieved from `Process.info/2`
"""
@spec record_exception(Exception.t(), any(), any()) :: boolean
defdelegate record_exception(exception, trace \\ nil, attributes \\ []),
to: OpenTelemetry.Tracer
@doc """
Updates the Span name.
It is highly discouraged to update the name of a Span after its creation. Span
name is often used to group, filter and identify the logical groups of spans.
And often, filtering logic will be implemented before the Span creation for
performance reasons. Thus the name update may interfere with this logic.
The function name is called UpdateName to differentiate this function from the
regular property setter. It emphasizes that this operation signifies a major
change for a Span and may lead to re-calculation of sampling or filtering
decisions made previously depending on the implementation.
"""
@spec update_name(String.t()) :: boolean()
defdelegate update_name(name), to: OpenTelemetry.Tracer
# Transforms an enumerable into a map of attributes that can be used by
# OpenTelemetry.
# Simple enumerables
defp enumerable_to_attrs(enumerable)
defp enumerable_to_attrs(enumerable) when is_struct(enumerable) do
enumerable
|> Map.from_struct()
|> enumerable_to_attrs()
end
defp enumerable_to_attrs(enumerable) when is_map(enumerable) or is_list(enumerable) do
enumerable
|> Enum.with_index()
|> Map.new(fn
{{key, value}, _index} ->
{key, stringify_value(value)}
{value, index} ->
{index, stringify_value(value)}
end)
end
defp enumerable_to_attrs(enumerable) when is_tuple(enumerable) do
enumerable_to_attrs(Tuple.to_list(enumerable))
end
# Named enumerables
defp enumerable_to_attrs(name, enumerable)
defp enumerable_to_attrs(name, s) when is_struct(s) do
enumerable_to_attrs(name, Map.from_struct(s))
end
defp enumerable_to_attrs(name, enumerable) when is_map(enumerable) or is_list(enumerable) do
enumerable
|> Enum.with_index()
|> Map.new(fn
{{key, _value} = item, index} when is_set(key) ->
{"#{name}.#{index}", stringify_value(item)}
{{key, value}, _index} ->
{"#{name}.#{key}", stringify_value(value)}
{value, index} ->
{"#{name}.#{index}", stringify_value(value)}
end)
end
defp enumerable_to_attrs(name, enumerable) when is_tuple(enumerable) do
enumerable_to_attrs(name, Tuple.to_list(enumerable))
end
defp stringify_value(value) when is_binary(value), do: value
defp stringify_value(value), do: inspect(value)
end