Skip to content

Introduction

callr is instrumented with OpenTelemetry, the vendor-neutral observability standard for distributed traces, metrics and logs. When tracing is enabled, callr emits spans for every R subprocess it starts and propagates trace context into those subprocesses, so spans created in the subprocess become children of the parent span.

callr only depends on the otel API package. When no SDK is loaded, all otel calls are cheap no-ops and callr behaves exactly as before. To actually record telemetry you also need an SDK such as otelsdk, which provides exporters that send spans to a local file, an OTLP collector, or memory for testing.

Enabling tracing

The simplest way to turn tracing on for an R session is to set an environment variable that picks an exporter, before loading callr. For example, to write spans to a local JSON file:

export OTEL_TRACES_EXPORTER=file
export OTEL_EXPORTER_OTLP_FILE_TRACES_PATH=/tmp/callr-traces.jsonl
R

See the otelsdk documentation for the full set of exporters (file, HTTP/OTLP, stdout/stderr, memory) and their configuration variables. Once an SDK and exporter are configured, no code changes are needed in callr-using code — the existing entry points (r(), r_session$new(), rcmd(), rscript()) start emitting spans automatically.

What callr emits

callr emits all spans under the instrumentation scope org.r-lib.callr. The span hierarchy mirrors the API:

Span name Emitted by
callr::r r()
callr::rscript rscript()
callr::rcmd rcmd()
callr::r_process r_process$new() / r_bg() (ends in $get_result)
callr::r_session r_session$new() (ends in $close() or finalizer)
r_session$initialize() wait blocking startup wait in r_session$new()
r_session$call each r_session$call() / $run()
r_session$read each r_session$read()
r_session$close r_session$close()
callr subprocess top-level span inside each subprocess (root of child)

Attributes attached to the parent-side spans include the resolved options for the subprocess (binary path, command-line args, working directory, environment, etc.), which makes it easy to correlate a trace with the exact callr invocation that produced it.

r_session$read spans additionally carry a message boolean (whether a message was read) and a status_code matching the callr communication protocol (e.g. 200 for a finished call, 301 for a subprocess message — see the Persistent R Sessions article for the full list).

If the function or script evaluated in the subprocess errors, callr records an exception event on the relevant parent span with the error message, class and traceback as attributes.

Subprocess context propagation

When tracing is active, callr injects the W3C Trace Context headers into the subprocess via environment variables:

  • TRACEPARENT — the active parent span context
  • TRACESTATE — vendor-specific trace state (if any)
  • BAGGAGE — OpenTelemetry baggage (if any)

Inside the subprocess, callr’s startup hook reads these env vars, extracts the parent span context, and opens a top-level callr subprocess span as a child of it. Any spans you create in the subprocess (with otel, or with another instrumented package) are then automatically parented to that span, producing a single connected trace that spans the process boundary.

You don’t have to do anything for this to work — it’s handled by the load hook that callr injects into every subprocess.

Tracer name

callr identifies itself as org.r-lib.callr to OpenTelemetry, set via the otel_tracer_name package symbol. SDKs and backends use this name as the instrumentation scope, so you can filter, group or route callr spans separately from other instrumented packages in your trace viewer.

Testing instrumentation

otelsdk ships a with_otel_record() helper that captures spans into an in-memory buffer. This is useful for inspecting what callr emits, or for writing tests of your own instrumentation that build on callr.

out <- otelsdk::with_otel_record({
  callr::r(function() 1 + 1)
})
out$value
#> [1] 2
vapply(out$traces, "[[", character(1), "name")
#>   callr::r 
#> "callr::r"

The recorded span has the expected name and instrumentation scope:

out$traces[[1]]$name
#> [1] "callr::r"
out$traces[[1]]$instrumentation_scope$name
#> [1] "org.r-lib.callr"
out$traces[[1]]$status
#> [1] "ok"

For an r_session, you get one span per lifecycle event, all parented to the top-level callr::r_session span:

out <- otelsdk::with_otel_record({
  rs <- callr::r_session$new()
  v <- rs$run(function() 1 + 1)
  rs$close()
  v
})
data.frame(
  name   = vapply(out$traces, "[[", character(1), "name"),
  parent = vapply(out$traces, "[[", character(1), "parent")
)
#>                          name           parent
#> 1              r_session$read 22d492829dae8e7b
#> 2 r_session$initialize() wait 22d492829dae8e7b
#> 3              r_session$call 22d492829dae8e7b
#> 4              r_session$read 22d492829dae8e7b
#> 5            callr::r_session 0000000000000000
#> 6             r_session$close 22d492829dae8e7b

A subprocess error is recorded as an exception event:

out <- otelsdk::with_otel_record({
  tryCatch(callr::r(function() stop("boom")), error = function(e) NULL)
})
vapply(out$traces[[1]]$events, "[[", character(1), "name")
#> [1] "exception"

And you can verify trace context propagation by inspecting TRACEPARENT inside the subprocess:

out <- otelsdk::with_otel_record({
  callr::r(function() Sys.getenv("TRACEPARENT"))
})
out$value
#> [1] "00-0b78dc50392aa3500dce437be1db9e65-4c76f7d817ccd9fe-01"
sprintf(
  "00-%s-%s-01",
  out$traces[[1]]$trace_id,
  out$traces[[1]]$span_id
)
#> [1] "00-0b78dc50392aa3500dce437be1db9e65-4c76f7d817ccd9fe-01"

The two strings match: the TRACEPARENT the subprocess sees encodes the trace and span IDs of the parent’s callr::r span.