| 
 | 1 | +#' OpenTelemetry reporter: traces for test results  | 
 | 2 | +#'  | 
 | 3 | +#' A variant of the Check reporter that also emits OpenTelemetry traces for  | 
 | 4 | +#' tests. Span attributes are drawn from [the semantic conventions for  | 
 | 5 | +#' tests](https://opentelemetry.io/docs/specs/semconv/registry/attributes/test/).  | 
 | 6 | +#'  | 
 | 7 | +#' @export  | 
 | 8 | +#' @family reporters  | 
 | 9 | +OpenTelemetryReporter <- R6::R6Class(  | 
 | 10 | +  "OpenTelemetryReporter",  | 
 | 11 | +  inherit = CheckReporter,  | 
 | 12 | +  public = list(  | 
 | 13 | +    tracer = NULL,  | 
 | 14 | +    sessions = NULL,  | 
 | 15 | +    suite_spans = NULL,  | 
 | 16 | +    test_spans = NULL,  | 
 | 17 | +    current_file = NULL,  | 
 | 18 | + | 
 | 19 | +    #' @param pkg A path to an R package, by default the one in the current  | 
 | 20 | +    #'   directory.  | 
 | 21 | +    #' @param tracer An \pkg{otel} tracer, or `NULL` to use the default tracer.  | 
 | 22 | +    initialize = function(pkg = ".", tracer = NULL, ..., call = caller_env()) {  | 
 | 23 | +      check_installed("otel", "for emitting Open Telemetry traces", call = call)  | 
 | 24 | +      set_pkg_resource_attributes(pkg)  | 
 | 25 | +      self$tracer <- tracer %||% otel::get_tracer("testthat")  | 
 | 26 | +      self$sessions <- new_environment()  | 
 | 27 | +      self$suite_spans <- new_environment()  | 
 | 28 | +      self$test_spans <- new_environment()  | 
 | 29 | +      super$initialize(...)  | 
 | 30 | +    },  | 
 | 31 | + | 
 | 32 | +    start_file = function(file) {  | 
 | 33 | +      # Track the current file so we can set it as an attribute on spans.  | 
 | 34 | +      self$current_file <- file.path("tests/testthat", file)  | 
 | 35 | +      context_start_file(file)  | 
 | 36 | +    },  | 
 | 37 | + | 
 | 38 | +    end_file = function(file) {  | 
 | 39 | +      self$current_file <- NULL  | 
 | 40 | +    },  | 
 | 41 | + | 
 | 42 | +    start_context = function(context) {  | 
 | 43 | +      # In order to handle concurrency issues with parallel tests, we maintain  | 
 | 44 | +      # an otel session for each context and switch in and out of it as needed.  | 
 | 45 | +      session <- self$tracer$start_session()  | 
 | 46 | +      env_poke(self$sessions, context, session)  | 
 | 47 | +      on.exit(self$tracer$deactivate_session())  | 
 | 48 | + | 
 | 49 | +      span <- self$tracer$start_span(  | 
 | 50 | +        name = "test_suite",  | 
 | 51 | +        attributes = compact(list(  | 
 | 52 | +          "test.suite.name" = context,  | 
 | 53 | +          "code.filepath" = self$current_file  | 
 | 54 | +        )),  | 
 | 55 | +        scope = NULL  | 
 | 56 | +      )  | 
 | 57 | +      env_poke(self$suite_spans, context, span)  | 
 | 58 | +    },  | 
 | 59 | + | 
 | 60 | +    end_context = function(context) {  | 
 | 61 | +      span <- env_get(self$suite_spans, context)  | 
 | 62 | +      span$end()  | 
 | 63 | +      env_unbind(self$suite_spans, context)  | 
 | 64 | + | 
 | 65 | +      # Clean up the session.  | 
 | 66 | +      session <- env_get(self$sessions, context)  | 
 | 67 | +      self$tracer$finish_session(session)  | 
 | 68 | +      env_unbind(self$sessions, context)  | 
 | 69 | +    },  | 
 | 70 | + | 
 | 71 | +    start_test = function(context, test) {  | 
 | 72 | +      if (is.null(context)) {  | 
 | 73 | +        # It seems like this can happen when running tests with a filter.  | 
 | 74 | +        context <- names(self$sessions)[1]  | 
 | 75 | +      }  | 
 | 76 | + | 
 | 77 | +      # Ensure we start test spans (and any spans started by functions within  | 
 | 78 | +      # that test) in the context's session.  | 
 | 79 | +      session <- env_get(self$sessions, context)  | 
 | 80 | +      self$tracer$activate_session(session)  | 
 | 81 | + | 
 | 82 | +      key <- paste(context, test, sep = "|")  | 
 | 83 | +      parent <- env_get(self$suite_spans, context)  | 
 | 84 | +      span <- self$tracer$start_span(  | 
 | 85 | +        name = "test_case",  | 
 | 86 | +        attributes = list("test.case.name" = test),  | 
 | 87 | +        options = list(parent = parent),  | 
 | 88 | +        scope = NULL  | 
 | 89 | +      )  | 
 | 90 | +      env_poke(self$test_spans, key, span)  | 
 | 91 | +    },  | 
 | 92 | + | 
 | 93 | +    end_test = function(context, test) {  | 
 | 94 | +      # Deactivate the context's session before starting the next test (which  | 
 | 95 | +      # might have a different one).  | 
 | 96 | +      on.exit(self$tracer$deactivate_session())  | 
 | 97 | + | 
 | 98 | +      context <- context %||% names(self$sessions)[1]  | 
 | 99 | +      key <- paste(context, test, sep = "|")  | 
 | 100 | +      span <- env_get(self$test_spans, key)  | 
 | 101 | +      if (span$is_recording() && !span$status_set) {  | 
 | 102 | +        # If the span's status hasn't been set, we assume the test passed.  | 
 | 103 | +        span$set_status("ok")  | 
 | 104 | +        span$set_attribute("test.case.result.status", "pass")  | 
 | 105 | +      }  | 
 | 106 | +      span$end()  | 
 | 107 | +      env_unbind(self$test_spans, key)  | 
 | 108 | +    },  | 
 | 109 | + | 
 | 110 | +    add_result = function(context, test, result) {  | 
 | 111 | +      if (expectation_broken(result) || expectation_skip(result)) {  | 
 | 112 | +        context <- context %||% names(self$sessions)[1]  | 
 | 113 | +        key <- paste(context, test, sep = "|")  | 
 | 114 | +        span <- env_get(self$test_spans, key)  | 
 | 115 | +        if (!span$is_recording()) {  | 
 | 116 | +          return(super$add_result(context, test, result))  | 
 | 117 | +        }  | 
 | 118 | + | 
 | 119 | +        # Extract source references, if possible.  | 
 | 120 | +        filename <- NULL  | 
 | 121 | +        line <- NULL  | 
 | 122 | +        column <- NULL  | 
 | 123 | +        if (inherits(result$srcref, "srcref")) {  | 
 | 124 | +          filename <- attr(result$srcref, "srcfile")$filename  | 
 | 125 | +          line <- result$srcref[1]  | 
 | 126 | +          column <- result$srcref[2]  | 
 | 127 | +        }  | 
 | 128 | +        attributes <- compact(list(  | 
 | 129 | +          "code.filepath" = file.path("tests/testthat", filename),  | 
 | 130 | +          "code.lineno" = line,  | 
 | 131 | +          "code.column" = column  | 
 | 132 | +        ))  | 
 | 133 | + | 
 | 134 | +        if (expectation_broken(result)) {  | 
 | 135 | +          # Record error or failure expectations as exceptions on the test span.  | 
 | 136 | +          span$record_exception(result, attributes = attributes)  | 
 | 137 | +          # Mark the span as having errored. This is also what  | 
 | 138 | +          # pytest-opentelemetry does.  | 
 | 139 | +          span$set_status("error")  | 
 | 140 | +          span$set_attribute("test.case.result.status", "fail")  | 
 | 141 | +        } else if (expectation_skip(result)) {  | 
 | 142 | +          # Record a special "skipped" event for skip expectations.  | 
 | 143 | +          span$add_event("test_skipped", attributes = attributes)  | 
 | 144 | +          span$set_status("unset")  | 
 | 145 | +          span$set_attribute("test.case.result.status", "skipped")  | 
 | 146 | +        }  | 
 | 147 | +      }  | 
 | 148 | +      super$add_result(context, test, result)  | 
 | 149 | +    }  | 
 | 150 | +  )  | 
 | 151 | +)  | 
 | 152 | + | 
 | 153 | +set_pkg_resource_attributes <- function(pkg = ".") {  | 
 | 154 | +  attributes <- get_pkg_resource_attributes(pkg)  | 
 | 155 | +  if (is.null(attributes)) {  | 
 | 156 | +    return()  | 
 | 157 | +  }  | 
 | 158 | +  set_resource_attributes(.attributes = attributes)  | 
 | 159 | +}  | 
 | 160 | + | 
 | 161 | +get_pkg_resource_attributes <- function(pkg = ".") {  | 
 | 162 | +  # Try to detect when we are testing a package.  | 
 | 163 | +  if (!env_var_is_true("NOT_CRAN")) {  | 
 | 164 | +    return(NULL)  | 
 | 165 | +  }  | 
 | 166 | +  # Use what we know about the package to set some resource attributes.  | 
 | 167 | +  desc <- pkgload::pkg_desc(pkg)  | 
 | 168 | +  attributes <- list(  | 
 | 169 | +    "service.name" = desc$get_field("Package"),  | 
 | 170 | +    "service.version" = desc$get_version(),  | 
 | 171 | +    "vcs.repository.url.full" = get_repo_url(),  | 
 | 172 | +    "vcs.repository.ref.revision" = get_git_revision()  | 
 | 173 | +  )  | 
 | 174 | +  # Existing environment variables take precedence.  | 
 | 175 | +  from_env <- get_resource_attributes()  | 
 | 176 | +  utils::modifyList(attributes, from_env)  | 
 | 177 | +}  | 
 | 178 | + | 
 | 179 | +get_repo_url <- function(pkg = ".") {  | 
 | 180 | +  # Default to using the Github Actions context, if available.  | 
 | 181 | +  if (nchar(repo <- Sys.getenv("GITHUB_REPOSITORY")) != 0) {  | 
 | 182 | +    return(paste0(Sys.getenv("GITHUB_SERVER_URL"), "/", repo))  | 
 | 183 | +  }  | 
 | 184 | +  # Otherwise check if the package has a GitHub URL in its DESCRIPTION file.  | 
 | 185 | +  desc <- pkgload::pkg_desc(pkg)  | 
 | 186 | +  github_urls <- startsWith(desc$get_urls(), "https://github.com")  | 
 | 187 | +  if (any(github_urls)) {  | 
 | 188 | +    return(desc$get_urls()[github_urls][1])  | 
 | 189 | +  }  | 
 | 190 | +  NULL  | 
 | 191 | +}  | 
 | 192 | + | 
 | 193 | +get_git_revision <- function() {  | 
 | 194 | +  # Default to using the Github Actions context, if available.  | 
 | 195 | +  if (nchar(revision <- Sys.getenv("GITHUB_SHA")) != 0) {  | 
 | 196 | +    return(revision)  | 
 | 197 | +  }  | 
 | 198 | +  tryCatch(  | 
 | 199 | +    system2(  | 
 | 200 | +      "git",  | 
 | 201 | +      c("rev-parse", "HEAD"),  | 
 | 202 | +      stdout = TRUE,  | 
 | 203 | +      stderr = TRUE  | 
 | 204 | +    )[1],  | 
 | 205 | +    error = function(e) NULL  | 
 | 206 | +  )  | 
 | 207 | +}  | 
 | 208 | + | 
 | 209 | +get_resource_attributes <- function(  | 
 | 210 | +  env = Sys.getenv("OTEL_RESOURCE_ATTRIBUTES")  | 
 | 211 | +) {  | 
 | 212 | +  if (nchar(env) == 0) {  | 
 | 213 | +    return(list())  | 
 | 214 | +  }  | 
 | 215 | +  # Split the attributes by comma and then by equals sign.  | 
 | 216 | +  attrs <- strsplit(env, ",", fixed = TRUE)[[1]]  | 
 | 217 | +  split <- strsplit(attrs, "=", fixed = TRUE)  | 
 | 218 | +  out <- structure(  | 
 | 219 | +    vector("list", length(split)),  | 
 | 220 | +    .Names = character(length(split))  | 
 | 221 | +  )  | 
 | 222 | +  for (i in seq_along(split)) {  | 
 | 223 | +    x <- split[[i]]  | 
 | 224 | +    if (length(x) != 2) {  | 
 | 225 | +      cli::cli_abort(  | 
 | 226 | +        "Invalid {.env OTEL_RESOURCE_ATTRIBUTES} format: {.str {env}}",  | 
 | 227 | +        .internal = TRUE  | 
 | 228 | +      )  | 
 | 229 | +    }  | 
 | 230 | +    out[[i]] <- x[2]  | 
 | 231 | +    names(out)[i] <- x[1]  | 
 | 232 | +  }  | 
 | 233 | +  out  | 
 | 234 | +}  | 
 | 235 | + | 
 | 236 | +set_resource_attributes <- function(..., .attributes = list()) {  | 
 | 237 | +  attrs <- utils::modifyList(list(...), .attributes)  | 
 | 238 | +  # Special handling for service.name, which isn't picked up by the SDK unless  | 
 | 239 | +  # it's set in the dedicated environment variable.  | 
 | 240 | +  if (!is.null(attrs["service.name"])) {  | 
 | 241 | +    Sys.setenv(OTEL_SERVICE_NAME = attrs[["service.name"]])  | 
 | 242 | +  }  | 
 | 243 | +  attrs <- vapply(attrs, format, character(1L))  | 
 | 244 | +  formatted <- paste(names(attrs), attrs, sep = "=", collapse = ",")  | 
 | 245 | +  Sys.setenv(OTEL_RESOURCE_ATTRIBUTES = formatted)  | 
 | 246 | +}  | 
0 commit comments