From c34b3d67a9bc06d99a7a1d9f7fe13967dc6fbf72 Mon Sep 17 00:00:00 2001 From: shikokuchuo <53399081+shikokuchuo@users.noreply.github.com> Date: Thu, 6 Nov 2025 13:31:25 +0000 Subject: [PATCH 1/5] OpenTelemetry instrumentation concept --- DESCRIPTION | 2 ++ R/otel.R | 53 ++++++++++++++++++++++++++++++++++++++ R/test-that.R | 1 + R/testthat-package.R | 8 ++++++ tests/testthat/test-otel.R | 18 +++++++++++++ 5 files changed, 82 insertions(+) create mode 100644 R/otel.R create mode 100644 tests/testthat/test-otel.R diff --git a/DESCRIPTION b/DESCRIPTION index dac7aaf67..348378e2d 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -42,6 +42,8 @@ Suggests: digest (>= 0.6.33), gh, knitr, + otel, + otelsdk, rmarkdown, rstudioapi, S7, diff --git a/R/otel.R b/R/otel.R new file mode 100644 index 000000000..bb85c88fd --- /dev/null +++ b/R/otel.R @@ -0,0 +1,53 @@ +otel_tracer_name <- "org.r-lib.testthat" +otel_is_tracing <- FALSE +otel_tracer <- NULL + +# generic otel helpers --------------------------------------------------------- + +# nocov start + +otel_cache_tracer <- function() { + requireNamespace("otel", quietly = TRUE) || return() + otel_tracer <<- otel::get_tracer(otel_tracer_name) + otel_is_tracing <<- tracer_enabled(otel_tracer) +} + +# nocov end + +tracer_enabled <- function(tracer) { + .subset2(tracer, "is_enabled")() +} + +otel_refresh_tracer <- function() { + requireNamespace("otel", quietly = TRUE) || return() + tracer <- otel::get_tracer() + modify_binding( + topenv(), + list(otel_tracer = tracer, otel_is_tracing = tracer_enabled(tracer)) + ) +} + +modify_binding <- function(env, lst) { + lapply(names(lst), unlockBinding, env) + list2env(lst, envir = env) + lapply(names(lst), lockBinding, env) +} + +otel_local_active_span <- function( + name, + label, + attributes = list(), + links = NULL, + options = NULL, + scope = parent.frame() +) { + otel_is_tracing || return() + spn <- otel::start_local_active_span( + sprintf("%s %s", name, label), + attributes = otel::as_attributes(attributes), + links = links, + options = options, + tracer = otel_tracer, + activation_scope = scope + ) +} diff --git a/R/test-that.R b/R/test-that.R index 4e7fbae92..c5650f3f8 100644 --- a/R/test-that.R +++ b/R/test-that.R @@ -35,6 +35,7 @@ #' } test_that <- function(desc, code) { local_description_push(desc) + otel_local_active_span("test that", desc) code <- substitute(code) test_code(code, parent.frame()) diff --git a/R/testthat-package.R b/R/testthat-package.R index 61fa9b316..cd37f9674 100644 --- a/R/testthat-package.R +++ b/R/testthat-package.R @@ -30,3 +30,11 @@ the$in_check_reporter <- FALSE #' @importFrom lifecycle deprecated ## usethis namespace: end NULL + +# nocov start + +.onLoad <- function(libname, pkgname) { + otel_cache_tracer() +} + +# nocov end diff --git a/tests/testthat/test-otel.R b/tests/testthat/test-otel.R new file mode 100644 index 000000000..1954347a1 --- /dev/null +++ b/tests/testthat/test-otel.R @@ -0,0 +1,18 @@ +test_that("otel instrumentation works", { + skip_if_not_installed("otelsdk") + + record <- otelsdk::with_otel_record({ + otel_refresh_tracer() + test_that("otel testing", { + expect_equal(1, 1) + expect_error(stop("otel error")) + }) + }) + # reset tracer after tests + otel_refresh_tracer() + + traces <- record$traces + expect_length(traces, 1L) + expect_equal(traces[[1L]]$name, "test that otel testing") + expect_equal(traces[[1L]]$instrumentation_scope$name , "org.r-lib.testthat") +}) From 0a2deba7f877bd458a3963795d0270be9322b623 Mon Sep 17 00:00:00 2001 From: shikokuchuo <53399081+shikokuchuo@users.noreply.github.com> Date: Thu, 6 Nov 2025 13:44:55 +0000 Subject: [PATCH 2/5] CI: skip otelsdk installation on older platforms --- .github/workflows/R-CMD-check.yaml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/R-CMD-check.yaml b/.github/workflows/R-CMD-check.yaml index a003f2e78..2f7950c55 100644 --- a/.github/workflows/R-CMD-check.yaml +++ b/.github/workflows/R-CMD-check.yaml @@ -58,7 +58,9 @@ jobs: - uses: r-lib/actions/setup-r-dependencies@v2 with: - extra-packages: any::rcmdcheck + extra-packages: | + any::rcmdcheck + otelsdk=?ignore-before-r=4.3.0 needs: check - uses: r-lib/actions/check-r-package@v2 From efbf81965666eda050770f6a1b043f0f603b0bc3 Mon Sep 17 00:00:00 2001 From: shikokuchuo <53399081+shikokuchuo@users.noreply.github.com> Date: Sat, 8 Nov 2025 12:48:35 +0000 Subject: [PATCH 3/5] Update otel tracer caching implementation --- R/otel.R | 81 ++++++++++++++++++-------------------- tests/testthat/test-otel.R | 5 +-- 2 files changed, 39 insertions(+), 47 deletions(-) diff --git a/R/otel.R b/R/otel.R index bb85c88fd..5c7d27d21 100644 --- a/R/otel.R +++ b/R/otel.R @@ -1,53 +1,48 @@ otel_tracer_name <- "org.r-lib.testthat" -otel_is_tracing <- FALSE -otel_tracer <- NULL # generic otel helpers --------------------------------------------------------- -# nocov start - -otel_cache_tracer <- function() { - requireNamespace("otel", quietly = TRUE) || return() - otel_tracer <<- otel::get_tracer(otel_tracer_name) - otel_is_tracing <<- tracer_enabled(otel_tracer) -} - -# nocov end +otel_cache_tracer <- NULL +otel_local_active_span <- NULL + +local({ + otel_is_tracing <- FALSE + otel_tracer <- NULL + + otel_cache_tracer <<- function() { + requireNamespace("otel", quietly = TRUE) || return() + otel_tracer <<- otel::get_tracer(otel_tracer_name) + otel_is_tracing <<- tracer_enabled(otel_tracer) + } + + otel_local_active_span <<- function( + name, + label, + attributes = list(), + links = NULL, + options = NULL, + scope = parent.frame() + ) { + otel_is_tracing || return() + spn <- otel::start_local_active_span( + sprintf("%s %s", name, label), + attributes = otel::as_attributes(attributes), + links = links, + options = options, + tracer = otel_tracer, + activation_scope = scope + ) + } +}) tracer_enabled <- function(tracer) { .subset2(tracer, "is_enabled")() } -otel_refresh_tracer <- function() { - requireNamespace("otel", quietly = TRUE) || return() - tracer <- otel::get_tracer() - modify_binding( - topenv(), - list(otel_tracer = tracer, otel_is_tracing = tracer_enabled(tracer)) - ) -} - -modify_binding <- function(env, lst) { - lapply(names(lst), unlockBinding, env) - list2env(lst, envir = env) - lapply(names(lst), lockBinding, env) -} - -otel_local_active_span <- function( - name, - label, - attributes = list(), - links = NULL, - options = NULL, - scope = parent.frame() -) { - otel_is_tracing || return() - spn <- otel::start_local_active_span( - sprintf("%s %s", name, label), - attributes = otel::as_attributes(attributes), - links = links, - options = options, - tracer = otel_tracer, - activation_scope = scope - ) +with_otel_record <- function(expr) { + on.exit(otel_cache_tracer()) + otelsdk::with_otel_record({ + otel_cache_tracer() + expr + }) } diff --git a/tests/testthat/test-otel.R b/tests/testthat/test-otel.R index 1954347a1..5f0c4d759 100644 --- a/tests/testthat/test-otel.R +++ b/tests/testthat/test-otel.R @@ -1,15 +1,12 @@ test_that("otel instrumentation works", { skip_if_not_installed("otelsdk") - record <- otelsdk::with_otel_record({ - otel_refresh_tracer() + record <- with_otel_record({ test_that("otel testing", { expect_equal(1, 1) expect_error(stop("otel error")) }) }) - # reset tracer after tests - otel_refresh_tracer() traces <- record$traces expect_length(traces, 1L) From c2917afe291538704adeffd4c672067447ddc4d0 Mon Sep 17 00:00:00 2001 From: shikokuchuo <53399081+shikokuchuo@users.noreply.github.com> Date: Sat, 8 Nov 2025 23:29:00 +0000 Subject: [PATCH 4/5] Move instrumentation to within `test_code()` --- R/otel.R | 8 +------- R/test-that.R | 2 +- tests/testthat/test-otel.R | 2 +- 3 files changed, 3 insertions(+), 9 deletions(-) diff --git a/R/otel.R b/R/otel.R index 5c7d27d21..192446310 100644 --- a/R/otel.R +++ b/R/otel.R @@ -18,17 +18,11 @@ local({ otel_local_active_span <<- function( name, label, - attributes = list(), - links = NULL, - options = NULL, scope = parent.frame() ) { otel_is_tracing || return() - spn <- otel::start_local_active_span( + otel::start_local_active_span( sprintf("%s %s", name, label), - attributes = otel::as_attributes(attributes), - links = links, - options = options, tracer = otel_tracer, activation_scope = scope ) diff --git a/R/test-that.R b/R/test-that.R index c5650f3f8..638edb0e6 100644 --- a/R/test-that.R +++ b/R/test-that.R @@ -35,7 +35,6 @@ #' } test_that <- function(desc, code) { local_description_push(desc) - otel_local_active_span("test that", desc) code <- substitute(code) test_code(code, parent.frame()) @@ -52,6 +51,7 @@ test_code <- function(code, env, reporter = NULL, skip_on_empty = TRUE) { test <- test_description() if (!is.null(test)) { + otel_local_active_span("test that", test, scope = frame) reporter$start_test(context = reporter$.context, test = test) withr::defer(reporter$end_test(context = reporter$.context, test = test)) } diff --git a/tests/testthat/test-otel.R b/tests/testthat/test-otel.R index 5f0c4d759..f60ab321a 100644 --- a/tests/testthat/test-otel.R +++ b/tests/testthat/test-otel.R @@ -10,6 +10,6 @@ test_that("otel instrumentation works", { traces <- record$traces expect_length(traces, 1L) - expect_equal(traces[[1L]]$name, "test that otel testing") + expect_equal(traces[[1L]]$name, "test that otel instrumentation works / otel testing") expect_equal(traces[[1L]]$instrumentation_scope$name , "org.r-lib.testthat") }) From d4cde370f3196627930d73979b5674cf05a46875 Mon Sep 17 00:00:00 2001 From: shikokuchuo <53399081+shikokuchuo@users.noreply.github.com> Date: Fri, 28 Nov 2025 10:25:39 +0000 Subject: [PATCH 5/5] Also set test results as attributes --- R/otel.R | 46 ++++++++++++++++++++++++++++++++------ R/test-that.R | 31 +++++++++++++++++++++++-- tests/testthat/test-otel.R | 30 ++++++++++++++++++++----- 3 files changed, 93 insertions(+), 14 deletions(-) diff --git a/R/otel.R b/R/otel.R index 192446310..d66a3b1ff 100644 --- a/R/otel.R +++ b/R/otel.R @@ -3,7 +3,8 @@ otel_tracer_name <- "org.r-lib.testthat" # generic otel helpers --------------------------------------------------------- otel_cache_tracer <- NULL -otel_local_active_span <- NULL +otel_local_test_span <- NULL +otel_update_span <- NULL local({ otel_is_tracing <- FALSE @@ -15,18 +16,49 @@ local({ otel_is_tracing <<- tracer_enabled(otel_tracer) } - otel_local_active_span <<- function( - name, - label, - scope = parent.frame() - ) { + otel_local_test_span <<- function(name, scope = parent.frame()) { otel_is_tracing || return() otel::start_local_active_span( - sprintf("%s %s", name, label), + sprintf("test that %s", name), tracer = otel_tracer, activation_scope = scope ) } + + otel_update_span <<- function( + span, + n_success, + n_failure, + n_error, + n_skip, + n_warning + ) { + otel_is_tracing || return() + + total <- n_success + n_failure + n_error + n_skip + n_warning + test_status <- if (n_error > 0) { + "error" + } else if (n_failure > 0) { + "fail" + } else if (total == 0 || n_skip == total) { + "skip" + } else { + "pass" + } + span$set_attribute("test.expectations.total", total) + span$set_attribute("test.expectations.passed", n_success) + span$set_attribute("test.expectations.failed", n_failure) + span$set_attribute("test.expectations.error", n_error) + span$set_attribute("test.expectations.skipped", n_skip) + span$set_attribute("test.expectations.warning", n_warning) + span$set_attribute("test.status", test_status) + + if (test_status %in% c("pass", "skip")) { + span$set_status("ok") + } else { + span$set_status("error", paste("Test", test_status)) + } + } }) tracer_enabled <- function(tracer) { diff --git a/R/test-that.R b/R/test-that.R index 638edb0e6..8bb17cf35 100644 --- a/R/test-that.R +++ b/R/test-that.R @@ -49,11 +49,27 @@ test_code <- function(code, env, reporter = NULL, skip_on_empty = TRUE) { frame <- caller_env() + otel_n_success <- 0L + otel_n_failure <- 0L + otel_n_error <- 0L + otel_n_skip <- 0L + otel_n_warning <- 0L + test <- test_description() if (!is.null(test)) { - otel_local_active_span("test that", test, scope = frame) + span <- otel_local_test_span(test, scope = frame) reporter$start_test(context = reporter$.context, test = test) - withr::defer(reporter$end_test(context = reporter$.context, test = test)) + withr::defer({ + otel_update_span( + span, + otel_n_success, + otel_n_failure, + otel_n_error, + otel_n_skip, + otel_n_warning + ) + reporter$end_test(context = reporter$.context, test = test) + }) } if (the$top_level_test) { @@ -83,6 +99,17 @@ test_code <- function(code, env, reporter = NULL, skip_on_empty = TRUE) { e$test <- test %||% "(code run outside of `test_that()`)" + # record keeping for otel + switch( + expectation_type(e), + success = otel_n_success <<- otel_n_success + 1L, + failure = otel_n_failure <<- otel_n_failure + 1L, + error = otel_n_error <<- otel_n_error + 1L, + skip = otel_n_skip <<- otel_n_skip + 1L, + warning = otel_n_warning <<- otel_n_warning + 1L, + NULL + ) + ok <<- ok && expectation_ok(e) reporter$add_result(context = reporter$.context, test = test, result = e) } diff --git a/tests/testthat/test-otel.R b/tests/testthat/test-otel.R index f60ab321a..4faf89d4c 100644 --- a/tests/testthat/test-otel.R +++ b/tests/testthat/test-otel.R @@ -2,14 +2,34 @@ test_that("otel instrumentation works", { skip_if_not_installed("otelsdk") record <- with_otel_record({ - test_that("otel testing", { + test_that("testing is traced", { expect_equal(1, 1) - expect_error(stop("otel error")) + expect_error(stop("expected error")) + }) + test_that("all expectations are recorded", { + expect_equal(1, 1) + expect_true(TRUE) + expect_length(1:3, 3) + expect_warning(warning("expected warning")) + expect_error(stop("expected error")) }) }) traces <- record$traces - expect_length(traces, 1L) - expect_equal(traces[[1L]]$name, "test that otel instrumentation works / otel testing") - expect_equal(traces[[1L]]$instrumentation_scope$name , "org.r-lib.testthat") + expect_length(traces, 2L) + span <- traces[[1L]] + expect_equal( + span$name, + "test that otel instrumentation works / testing is traced" + ) + expect_equal(span$instrumentation_scope$name, "org.r-lib.testthat") + span <- traces[[2L]] + expect_equal(span$attributes[["test.status"]], "pass") + expect_equal(span$attributes[["test.expectations.total"]], 5) + expect_equal(span$attributes[["test.expectations.passed"]], 5) + expect_equal(span$attributes[["test.expectations.failed"]], 0) + expect_equal(span$attributes[["test.expectations.error"]], 0) + expect_equal(span$attributes[["test.expectations.skipped"]], 0) + expect_equal(span$attributes[["test.expectations.warning"]], 0) + expect_equal(span$status, "ok") })