A Datadog APM tracing library for Elixir.
Adding :dd_trace_ex as a dependency is the whole installation: it is an
OTP application that starts itself with your release, so there is no
tracer module to define and no child to add to your supervision tree.
Settings come from application config and DD_* environment variables —
see DDTrace.Config for the full list — and are resolved once at boot,
when the tracer logs them in a single line. The same facts are available
on demand from info/0.
Tracing
trace/2 is the one verb. The span it opens is a child of whatever span
the process already has open, or the root of a new trace when it has none,
and it finishes however the block ends:
require DDTrace
DDTrace.trace "job.process", resource: "ImportOrders" do
DDTrace.trace "db.insert" do
insert_all(rows)
end
endwith_trace/2 is the same thing taking a function instead of a block, for
where that fits better.
When the work does not fit a block at all, start_span/2 and
finish_span/2 open and close a span by hand. finish_span/2 closes the
span you hand it — the one you started, not whichever happens to be
innermost:
span = DDTrace.start_span("batch.window", type: "batch")
# ...
DDTrace.finish_span(span)Nothing here raises and nothing here needs a check first. While the
tracer is disabled start_span/2 returns nil, and finish_span/2
accepts nil — or anything else — without complaint.
Crossing processes
A trace belongs to the process running it, so another process joins it by
being handed one. For a Task, DDTrace.Task and DDTrace.Task.Supervisor
do the handing over: aliasing them is the whole change, and every child
spawned inside a trace opens its spans in that trace.
alias DDTrace.Task
DDTrace.trace "assets.fetch_all" do
assets
|> Task.async_stream(&fetch_asset/1, max_concurrency: 10)
|> Enum.to_list()
endEverywhere else — a cast, a message, a job row — the handoff is explicit:
current_context/0 takes a snapshot, and parent: or with_context/2
opens spans underneath it.
ctx = DDTrace.current_context()
GenServer.cast(worker, {:work, item, ctx})
# in the worker
DDTrace.trace "worker.process", parent: ctx do
process(item)
endBoth are safe to hand a nil, which simply opens a trace of its own, and
both give way to a trace the receiving process is already running.
Reading the trace
current_trace_id/0, current_span/0 and current_root_span/0 say what
the process is inside, for putting a trace id in a log line or reading
what a span has been told so far. All three are nil when there is no
span open, and none of them is a way to change anything — the mutators
below are.
Describing what happened
The mutators say more about whatever span the process has open, without being handed one:
DDTrace.set_tag("order.channel", "web")
DDTrace.set_metric("payload.size_bytes", 14_202)
DDTrace.set_measured()
DDTrace.set_error(exception, __STACKTRACE__)
DDTrace.update_span(resource: "POST /orders")meta holds strings you filter traces by; metrics holds numbers
Datadog computes over. DDTrace.Tags spells the keys the tracer itself
reads or writes, six of which are not tags at all — writing
"service.name" sets the span's service, and "manual.keep" decides the
trace.
Which traces Datadog keeps
Every trace is sent to the agent. What sampling decides is which of them Datadog stores, and each trace carries that decision to the agent with it. Four things have a say, in this order, and the first one with an answer ends it:
- a
keep_trace/0ordrop_trace/0this process already made; :sampling_rules, first rule to match the trace's root span;:sample_rate, the global rate, as a last rule matching everything — and a trace kept by a rule or the global rate then has to fit under:rate_limit, the ceiling on how many the rules may keep per second;- the agent's own rate for the service, which it sends back on every submission it accepts. With none of the settings above configured this is the whole story, and it is what settles ingestion at the agent's target the way it does for every Datadog tracer.
DDTrace.Config documents the three settings. A trace nobody configured
anything for is kept until the agent has answered once, so a development
machine with an agent that never replies sees all of its traces.
The decision is made once, and late: not when the trace opens but the
first time it has to leave the process or ship — current_context/0, a
DDTrace.Task spawn, inject/1, or the trace being sent. That is what
lets a rule on the resource see the resource Phoenix sets after the
request span opened. Once made it is frozen; nothing resamples it, and
every process the trace reaches carries the same answer.
Keeping and dropping
A caller can override all of that for the trace in front of it —
DDTrace.keep_trace() # worth ingesting whatever sampling would say
DDTrace.drop_trace() # noise: counted into the metrics, then discarded— for this process's part of the trace and every context taken from it afterwards, last decision winning. A dropped trace still travels to the agent; dropping is about storage, not about tracing less.
Nothing here raises
Every function on this module returns whether or not it could do what was
asked, and the mutators all return a bare :ok. Instrumentation that
cannot fail is instrumentation callers never have to guard, so a typo in
a tag key costs one tag on one span rather than the request that hit it.
Values are converted at the moment they are set: atoms, booleans and
numbers by their obvious spelling, anything implementing String.Chars
through to_string/1, and metrics to floats. A value with no such form —
a pid, a list — is skipped, and so is a string with bytes that are not
valid UTF-8, except in name, resource and service, which have to
ship and so are repaired instead.
Each distinct problem is logged once, keyed by what went wrong and the key it went wrong for, so a mistake in a hot path is one line in the log rather than one per call.
Reaching the agent
A trace is sent when it is complete — when the process closes the last span it had open — and never span by span, since the agent receives a trace whole. Completed traces are buffered and submitted together every two seconds, or sooner when a burst has filled the buffer.
Submission never holds up the code being traced: finishing a trace hands it to the buffer and returns. An agent that cannot be reached costs one log line and the traces it did not take; it never costs the caller.
A VM that stops has its buffer flushed on the way out, within a bounded
shutdown budget — see DDTrace.Config — so a release exports what it was
holding. A VM too short-lived to wait for the next flush at all, such as a
Mix task, calls flush/1.
DDTrace.Test asserts on the spans that result, with no agent running.
Watching the tracer
Every span reports itself as a :telemetry event, so tracing is measured
the way the rest of an application is:
:telemetry.attach_many(
"tracer-health",
[
[:dd_trace, :span, :start],
[:dd_trace, :span, :stop],
[:dd_trace, :span, :exception]
],
&MyApp.Metrics.handle/4,
nil
)They follow the :telemetry.span/3 convention — :start measures
system_time, :stop and :exception measure duration, and all three
measure monotonic_time, in native units — so a stock Telemetry.Metrics
recipe such as summary("dd_trace.span.stop.duration") works with no
knowledge of this library. Metadata is name, trace_id and span_id;
:exception adds the kind, reason and stacktrace the block left
with, unchanged.
A span's duration is exactly the gap between the monotonic_time of its
start and the monotonic_time of its close, as the convention promises,
so the two events pair arithmetically even for a span backfilled with
start_time: and finish_time:.
A span emits exactly one of :stop or :exception, never both. A span
closed because the span enclosing it closed emits :stop, like any other.
Correlating logs with traces
While a span is open, the process's Logger metadata carries the ids
Datadog's UI correlates on, so a log line links to the trace it happened
in and back:
[
"dd.trace_id": "68d1f4a300000000453f7c31b09a2e04",
"dd.span_id": "5013951231073820500",
"dd.service": "storefront",
"dd.env": "prod",
"dd.version": "1.4.0"
]Any JSON logger that emits metadata gets this for free, and nothing has
to be called for it. dd.service, dd.env and dd.version are there
when the tracer was told them; the ids are always there.
The keys are quoted atoms, because dd.trace_id is the spelling the
UI reads — so a metadata: allowlist has to spell them the same way:
config :logger, :default_formatter,
metadata: [:request_id, :"dd.trace_id", :"dd.span_id", :"dd.service"]Whatever the metadata held before the span is put back when it closes, so
a process that logs after its trace has ended carries no stale ids, and
finishing a child reveals its parent's. DDTrace.Config's
:logs_injection turns the whole thing off, and off means the tracer
never touches Logger metadata at all.
Summary
Functions
Takes the trace this process is inside, to hand to another one.
Returns the first span this process opened in the trace, or nil.
Returns the span this process has open, or nil when it has none.
Returns the trace this process is inside, as its 128-bit id.
Asks Datadog to drop this trace, whatever it would have decided.
Reads an inbound request's x-datadog-* headers into a context.
Closes the span you opened, and every span still open inside it.
Submits everything buffered to the agent now, and waits for it.
Returns the config the tracer resolved when it started.
Writes this trace's x-datadog-* headers into carrier.
Writes a specific context's headers into carrier.
Asks Datadog to keep this trace, whatever it would have decided.
Marks the current span an error, describing what went wrong.
Opts the current span into Datadog's trace metrics.
Adds a numeric tag to the current span, which Datadog can aggregate.
Adds a string tag to the current span.
Adds several string tags to the current span at once.
Opens a span, and returns it so finish_span/2 can close it.
Traces the block, as a child of the current span or as a new trace root.
Traces the block with options, the three-argument shape of trace/2.
Corrects the current span's fields, after it was opened.
Runs the function with a handed-off trace attached, and returns its value.
Traces the function, as a child of the current span or as a new trace root.
Functions
@spec current_context() :: DDTrace.SpanContext.t() | nil
Takes the trace this process is inside, to hand to another one.
Returns a DDTrace.SpanContext naming the current span, or nil when
this process has no trace and none was handed to it. It is plain data, so
it travels anywhere a term does — a GenServer.cast, a message to another
node, a job row:
ctx = DDTrace.current_context()
GenServer.cast(worker, {:work, item, ctx})
# in the worker
DDTrace.trace "worker.process", parent: ctx do
process(item)
endFor a Task, reach for DDTrace.Task instead, which does this for you.
What comes back is a photograph, not a handle: a change to this trace after the snapshot was taken does not reach the process holding it. That is the same deal a downstream service gets from a set of headers.
Examples
DDTrace.trace "job.process" do
ctx = DDTrace.current_context()
# ...
end
@spec current_root_span() :: DDTrace.Span.t() | nil
Returns the first span this process opened in the trace, or nil.
The root of this process's part of the trace, which is the root of the
trace itself only when this process started it. A process that joined a
handed-off trace gets the span that joined — the one whose parent is
another process's span, so its parent_id is not 0. Datadog assembles
the whole trace from the parts each process sends.
For tagging the unit of work rather than the step inside it:
DDTrace.trace "job.process" do
DDTrace.trace "db.insert" do
DDTrace.current_root_span().name == "job.process"
end
endReturns nil on the same terms as current_span/0: no span open here,
a with_context/2 scope that has not opened one, a disabled tracer.
Examples
root = DDTrace.current_root_span()
@spec current_span() :: DDTrace.Span.t() | nil
Returns the span this process has open, or nil when it has none.
For reading what the span is — its ids, its name, the tags set on it so
far. What comes back is a copy: changing it changes a struct of your own
and nothing else. Mutation here is ambient, so set_tag/2 and the rest of
the mutators are how the open span changes.
Returns nil inside a with_context/2 scope that has not opened a span
yet, where current_context/0 still answers.
Examples
DDTrace.trace "job.process" do
span = DDTrace.current_span()
span.name
end
@spec current_trace_id() :: non_neg_integer() | nil
Returns the trace this process is inside, as its 128-bit id.
The whole id, as one integer: the wire carries its lower half as
trace_id and its upper half as a tag, and that split is the exporter's
business rather than yours. Format it with Integer.to_string(id, 16) to
get the shape Datadog's UI shows.
For correlating something outside Datadog with a trace inside it — an
error report, a response header, a row in another system. Log lines need
nothing: the tracer already puts the ids in Logger metadata under the
keys Datadog reads.
Returns nil when this process has no span open, including inside a
with_context/2 scope that has not opened one yet: a trace is attached
there, but no span of this process's is in it.
Examples
Plug.Conn.put_resp_header(conn, "x-trace-id", trace_id_header())
defp trace_id_header do
case DDTrace.current_trace_id() do
nil -> ""
id -> Integer.to_string(id, 16)
end
end
@spec drop_trace() :: :ok
Asks Datadog to drop this trace, whatever it would have decided.
The trace's sampling priority becomes DDTrace.Priority.user_reject/0.
For the traces that are noise however interesting the code producing them
is — a health check, a poller that runs every second.
The trace is still sent to the agent: Datadog counts it into the metrics it computes over all traffic and then discards it, so dropping costs nothing in the numbers and everything in the storage. Dropping is not a way to stop tracing something — for that, don't trace it.
Everything keep_trace/0 says about reach, order, retroactivity and
winning over sampling applies here: a rule that would have kept this
trace, and the agent's rate behind it, do not get a say once this has
been called. A chunk this trace already shipped keeps the decision it
left with; the drop reaches what has not shipped yet.
DDTrace.Tags.manual_drop/0 is the tag form, and any value but false
or nil applies the decision.
Always returns :ok, including with no trace running.
Examples
DDTrace.trace "health.check" do
DDTrace.drop_trace()
check()
end
@spec extract(term()) :: DDTrace.SpanContext.t() | nil
Reads an inbound request's x-datadog-* headers into a context.
The other end of inject/1: what an upstream service wrote is what
puts this service's spans in its trace.
ctx = DDTrace.extract(conn)
DDTrace.trace "web.request", parent: ctx do
handle(conn)
endA %Plug.Conn{} works because it has req_headers — matched
structurally, so the library has no Plug dependency — and so does any
list or map of header pairs. Names are read case-insensitively.
Returns nil when the headers name no trace, and never raises: parent:
and with_context/2 are both nil-safe, so a malformed inbound request
simply starts a fresh trace instead of failing one.
Examples
DDTrace.extract([{"x-datadog-trace-id", "12345"}, {"x-datadog-parent-id", "678"}])
@spec finish_span( DDTrace.Span.t() | nil, keyword() ) :: :ok
Closes the span you opened, and every span still open inside it.
Spans opened inside this one and never finished were abandoned: they are
closed here too, tagged dd_trace_ex.abandoned, so the trace is complete
rather than truncated.
Always returns :ok. A nil span — or a span this process never opened,
or anything at all — closes nothing.
Options
:finish_time— when the work really ended, in nanoseconds since the epoch; with:start_timeit makes the duration exactly their difference
Examples
span = DDTrace.start_span("batch.window", start_time: t0)
DDTrace.finish_span(span, finish_time: t1)
@spec flush(timeout()) :: :ok | {:error, :timeout}
Submits everything buffered to the agent now, and waits for it.
For a VM that will not live long enough for the next flush: a Mix task, a
script, a Mix.install snippet. Everything else is served by the exporter's
own cadence and should not call this.
Returns :ok once the flush has run, or {:error, :timeout} when it did
not finish in timeout milliseconds — the one place in this API worth
branching on, so a script knows whether to exit. A flush that timed out was
given up on, not cancelled: the payload is still in flight and may well
reach the agent afterwards.
:ok says the flush ran to completion, not that Datadog accepted it. An
agent that refuses a payload or cannot be reached costs those traces and
one log line, here exactly as on the timer path.
Unlike a flush on the timer, this one ignores any backoff window an unreachable agent has opened: asking by name is asking to try now.
Nothing here raises — including with no tracer running, which flushes
nothing and returns :ok.
Examples
# ... traced work ...
DDTrace.flush()
DDTrace.flush(1_000)
@spec info() :: %{ enabled: boolean(), logs_injection: boolean(), service: String.t() | nil, env: String.t() | nil, version: String.t() | nil, agent_url: String.t(), shutdown_timeout: pos_integer(), x_datadog_tags_max_length: non_neg_integer(), http_server_error_statuses: String.t(), http_client_error_statuses: String.t(), sample_rate: float() | nil, sampling_rules: [map()], rate_limit: non_neg_integer() }
Returns the config the tracer resolved when it started.
The first thing to reach for when tracing does not behave as expected: it reports the settings actually in effect, whichever layer they came from.
Examples
iex> DDTrace.info().agent_url
"http://localhost:8126"
Writes this trace's x-datadog-* headers into carrier.
What a downstream service needs to put its spans in the same trace as yours. The carrier goes in first, so it pipes:
headers = DDTrace.inject([{"content-type", "application/json"}])
Req.post!(url, headers: headers, json: body)Anything shaped like header pairs is a carrier — a list, a map, a
keyword-ish list of {atom, binary} pairs — and what comes back is
always a [{binary, binary}] list, whichever went in.
With no trace running, the carrier comes back with its own pairs and nothing added, so an instrumented HTTP client needs no conditional.
Reusing a carrier
A header this trace writes replaces the one already there, in place, and
a propagation header this injection does not write is removed — a
carrier that arrived with x-datadog-origin and is injected into again
cannot carry two traces at once. Headers the tracer does not own are
never touched.
What comes back is a photograph, like current_context/0: a later
keep_trace/0 changes what the next injection writes, never headers
already returned.
Examples
DDTrace.trace "http.request" do
headers = DDTrace.inject([])
end
Writes a specific context's headers into carrier.
inject/1 with the trace to serialize named explicitly, for code that
is holding a snapshot rather than running inside the trace:
DDTrace.inject(headers, context: ctx)context: nil returns the carrier's own pairs unchanged, which is what
makes a snapshot that may not exist safe to pass along.
Examples
DDTrace.inject([], context: DDTrace.current_context())
@spec keep_trace() :: :ok
Asks Datadog to keep this trace, whatever it would have decided.
The trace's sampling priority becomes DDTrace.Priority.user_keep/0, the
value that says a person chose. For the trace worth having whether or not
sampling would have spared it — the failed payment, the request that took
eleven seconds.
The decision is about the trace, not the span, so it can be made from any
span in it and the span it was made from is not marked in any way.
DDTrace.Tags.manual_keep/0 is the tag form, for instrumentation that
reaches Datadog's well-known keys: set_tag("manual.keep", value) makes
this same decision for any value but false or nil, and the key never
ships as a tag either way.
Its reach is this process's part of the trace, and every context taken from here afterwards. It does not travel backwards to the process that handed this one the trace, any more than a decision made downstream reaches the service that called it.
It wins over sampling, in both directions. Called before the sampler has had its turn, it is the decision and no rule, global rate, rate limit or agent rate runs at all; called after, it overwrites what they decided.
Between themselves, keep_trace/0 and drop_trace/0 are last-writer-wins:
what the trace is carrying when it is sent is what ships. Neither is
retroactive — a chunk already exported and a context already taken keep
the decision they left with. A trace that has already shipped a chunk —
one with many finished spans in one process ships in chunks while it
runs — keeps that chunk's decision, and this reaches the chunks that
have not shipped yet.
Always returns :ok, including with no trace running, which decides
nothing.
Examples
DDTrace.trace "payment.charge" do
case charge(order) do
{:error, reason} ->
DDTrace.keep_trace()
{:error, reason}
ok ->
ok
end
end
@spec set_error(Exception.t() | term(), Exception.stacktrace() | []) :: :ok
Marks the current span an error, describing what went wrong.
Tags the span error.type, error.message and error.stack, and sets
its error flag. The first error wins: a later one leaves the span as
it is, since the first is usually the failure and the rest its
consequences.
A trace block does this for you on every way out that is not a return,
so this is for the errors your code handles itself and still wants
recorded.
Always returns :ok.
Examples
try do
charge(order)
rescue
exception ->
DDTrace.set_error(exception, __STACKTRACE__)
{:error, exception}
end
@spec set_measured() :: :ok
Opts the current span into Datadog's trace metrics.
Datadog computes hit counts, latency and error rates for spans marked this way, whether or not the trace is ingested — worth setting on the spans that represent a meaningful unit of work, and not on every span.
Always returns :ok.
Examples
DDTrace.set_measured()
Adds a numeric tag to the current span, which Datadog can aggregate.
This is the difference between a tag you can filter by and a measure you
can graph: metrics holds numbers Datadog computes over, meta holds
strings it facets on.
The value is stored as a float, so 1 and 1.0 are the same measurement
and reach the agent identically. A value that is not a number is reported
once and skipped — set_tag/2 takes everything else.
Always returns :ok.
Examples
DDTrace.set_metric("payload.size_bytes", 14_202)
DDTrace.set_metric("import.rows_written", count)
Adds a string tag to the current span.
Values that are not already strings are converted: atoms, booleans and
numbers by their obvious spelling, and anything implementing
String.Chars — Date, DateTime, URI, Decimal — through
to_string/1. A value with no string form, such as a pid or a list, is
reported once and skipped: the span still ships, without that tag.
Six keys are not tags at all, and none of them reaches meta.
"service.name", "resource.name" and "span.type" set the span field
of the same meaning, and "error" sets the error flag. "manual.keep"
and "manual.drop" decide the whole trace's fate, exactly as
keep_trace/0 and drop_trace/0 do — any value but false or nil
applies the decision. DDTrace.Tags spells all six.
Always returns :ok — including with no span open, which tags nothing.
Examples
DDTrace.set_tag("order.channel", "web")
DDTrace.set_tag("order.placed_on", ~D[2024-01-15])
DDTrace.set_tag(DDTrace.Tags.service_name(), "billing")
DDTrace.set_tag(DDTrace.Tags.manual_keep(), true)
Adds several string tags to the current span at once.
Each pair is treated exactly as set_tag/2 treats it — the six
intercepted keys included — so one unusable value costs its own tag and no
other. Tags are open, string-keyed data, so they travel as a map — a
keyword list is accepted too, and anything else is reported once and
ignored.
Always returns :ok.
Examples
DDTrace.set_tags(%{"order.id" => id, "api.version" => "4.0"})
@spec start_span( String.t(), keyword() ) :: DDTrace.Span.t() | nil
Opens a span, and returns it so finish_span/2 can close it.
The span is a child of the process's current span, or the root of a new
trace when the process has none. Returns nil while the tracer is
disabled, which finish_span/2 accepts.
Options
:resource— what the operation acted on; defaults toname:service— the service to attribute the work to:type— the Datadog span type, e.g."web"or"sql":start_time— when the work really began, in nanoseconds since the epoch, for backfilling work that was measured elsewhere:parent— aDDTrace.SpanContextanother process handed over, which the span joins as a child. Ignored, with a word in the log, when this process already has a trace running: the trace a process is inside wins.nilis no handoff at all.
Examples
span = DDTrace.start_span("db.insert", resource: "INSERT INTO orders")
DDTrace.finish_span(span)
span = DDTrace.start_span("worker.process", parent: ctx)
Traces the block, as a child of the current span or as a new trace root.
The span finishes however the block ends. A raised exception, a catchable
exit, and a throw that escapes each mark the span an error — tagged
error.type, error.message and error.stack — and are then propagated
unchanged, so tracing a block never changes what the caller sees. An error
the block handles itself leaves the span clean.
Nesting never errors: a trace inside a trace is always a child span.
The block's value is the expression's value, and while the tracer is
disabled the block simply runs untraced.
Takes the same options as start_span/2.
The macro owns the try that finishes the span, so a rescue, catch,
after or else clause written against trace itself is a compile error
rather than a clause silently dropped — put your own try inside the
block, or use with_trace/2, which an ordinary try composes around.
Like with_trace/2, the block is scoped as a function body: it reads the
bindings around it, and a rebinding inside it does not escape.
Examples
require DDTrace
DDTrace.trace "job.process", resource: "ImportOrders" do
DDTrace.trace "parse.csv" do
parse(file)
end
DDTrace.trace "db.insert" do
insert_all(rows)
end
end
Traces the block with options, the three-argument shape of trace/2.
trace "job.process", resource: "ImportOrders" do ... end parses as this
arity: the options and the block arrive as separate arguments. Everything
trace/2 says applies.
@spec update_span(keyword()) :: :ok
Corrects the current span's fields, after it was opened.
For what is only known once the work has begun — the route a request
matched, the service a job turned out to belong to. Takes :name,
:resource, :service and :type; a field name it does not know is
reported once and ignored, as is an argument that is not a keyword list.
Values are converted the way set_tag/2 converts them, with one
difference: these fields have to ship, so bytes that are not valid UTF-8
are replaced rather than dropped.
Always returns :ok.
Examples
DDTrace.update_span(resource: "POST /orders")
@spec with_context(DDTrace.SpanContext.t() | nil, (-> result)) :: result when result: var
Runs the function with a handed-off trace attached, and returns its value.
Every trace opened inside the function joins context — including traces
opened by code you did not write — and nothing after it does. For handing
one span a parent, parent: on trace/2 or start_span/2 says it more
directly; this is for a whole scope:
DDTrace.with_context(ctx, fn ->
DDTrace.trace "worker.process" do
process(prepared)
end
end)The previous context comes back on every way out — a return, a raise, an exit, a throw — so a scope cannot leak a trace into the work after it.
with_context(nil, fun) runs the function untouched, which is what makes
a snapshot that may not exist safe to pass along. Attaching a context to a
process that already has a trace running is a no-op with a word in the
log: the function runs, and the trace this process is in wins.
Each trace in the scope joins the snapshot as the snapshot was. A trace that changes the sampling decision changes its own chunk; the next trace in the scope starts again from what was handed over — this process knows nothing more about the trace than the snapshot it was given.
Examples
DDTrace.with_context(ctx, fn -> handle(message) end)
Traces the function, as a child of the current span or as a new trace root.
The trace/2 macro's twin, for where a function value fits better than a
block: it needs no require, is itself a first-class value, and composes
with an ordinary surrounding try. Every other word of trace/2 applies,
error tagging included, and the fun's value is the call's value.
The two cannot share a name — both forms occupy arities 2 and 3, and a
def and a defmacro cannot share a name and arity — so this is the
Logger.info/2 and Logger.bare_log/3 split.
Examples
DDTrace.with_trace("db.insert", fn -> insert_all(rows) end)
DDTrace.with_trace("job.process", [resource: "ImportOrders"], fn ->
run(job)
end)