DDTrace (dd_trace_ex v0.1.0)

Copy Markdown View Source

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
end

with_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()
end

Everywhere 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)
end

Both 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:

  1. a keep_trace/0 or drop_trace/0 this process already made;
  2. :sampling_rules, first rule to match the trace's root span;
  3. :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;
  4. 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

current_context()

@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)
end

For 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

current_root_span()

@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
end

Returns 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()

current_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

current_trace_id()

@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

drop_trace()

@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

extract(carrier)

@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)
end

A %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"}])

finish_span(span, opts \\ [])

@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_time it makes the duration exactly their difference

Examples

span = DDTrace.start_span("batch.window", start_time: t0)
DDTrace.finish_span(span, finish_time: t1)

flush(timeout \\ 5000)

@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)

info()

@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"

inject(carrier)

@spec inject(term()) :: [{binary(), binary()}]

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

inject(carrier, opts)

@spec inject(
  term(),
  keyword()
) :: [{binary(), binary()}]

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())

keep_trace()

@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

set_error(reason, stacktrace \\ [])

@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

set_measured()

@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()

set_metric(key, value)

@spec set_metric(String.t() | atom(), number()) :: :ok

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)

set_tag(key, value)

@spec set_tag(String.t() | atom(), term()) :: :ok

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.CharsDate, 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)

set_tags(tags)

@spec set_tags(%{optional(String.t()) => term()} | keyword()) :: :ok

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"})

start_span(name, opts \\ [])

@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 to name
  • :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 — a DDTrace.SpanContext another 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. nil is 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)

trace(name, clauses)

(macro)
@spec trace(
  String.t(),
  keyword()
) :: Macro.t()

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

trace(name, opts, clauses)

(macro)
@spec trace(String.t(), keyword(), keyword()) :: Macro.t()

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.

update_span(fields)

@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")

with_context(context, fun)

@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)

with_trace(name, opts \\ [], fun)

@spec with_trace(String.t(), keyword(), (-> result)) :: result when result: var

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)