diff --git a/DESCRIPTION b/DESCRIPTION index e8760526c6..8466100295 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -135,6 +135,8 @@ Suggests: magick, litedown, markdown (>= 1.3), + otel, + otelsdk, png, ragg, reticulate (>= 1.4), @@ -180,6 +182,7 @@ Collate: 'hooks-rst.R' 'hooks-textile.R' 'hooks.R' + 'otel.R' 'output.R' 'package.R' 'pandoc.R' diff --git a/NEWS.md b/NEWS.md index 86b206eea1..98263d38cb 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,5 +1,13 @@ # CHANGES IN knitr VERSION 1.51 +## NEW FEATURES + +- Added support for [OpenTelemetry](https://opentelemetry.io/) observability. When the **otel** and **otelsdk** packages are installed and tracing is enabled, spans are automatically created for all knit operations. See [`otelsdk`'s Collecting Telemetry Data](https://otelsdk.r-lib.org/reference/collecting.html) for more details on configuring OpenTelemetry (thanks, @shikokuchuo, #2422). + - `knit()` produces 'knitr processing' and 'knitr output' spans when starting and finishing an operation + - `knit()` produces 'knit' spans for each chunk, recording attributes such as the label and knit engine + +## BUG FIXES + - Fix issue with error traceback not correctly showing when **rlang** is available. - Improve error traceback when **rlang** is available and **evaluate** > 1.0.3 is used (thanks, @cderv, #2388). diff --git a/R/block.R b/R/block.R index 28ea201183..5b95403ab6 100644 --- a/R/block.R +++ b/R/block.R @@ -145,6 +145,13 @@ cache2.opts = c('fig.keep', 'fig.path', 'fig.ext', 'dev', 'dpi', 'dev.args', 'fi cache0.opts = c('include', 'out.width.px', 'out.height.px', 'cache.rebuild') block_exec = function(options) { + otel_active_span( + name = 'knit', + label = options$label, + attributes = make_chunk_attributes(options), + scope = environment() + ) + if (options$engine == 'R') return(eng_r(options)) # when code is not R language diff --git a/R/otel.R b/R/otel.R new file mode 100644 index 0000000000..7a97053503 --- /dev/null +++ b/R/otel.R @@ -0,0 +1,71 @@ +otel_tracer_name = 'org.yihui.knitr' +otel_tracer = NULL +otel_is_tracing = FALSE + +# generic otel helpers: + +# - without specifying `scope`, the span ends when this function returns; +# to make this a local span (last as long as the function it is called from), +# specify `scope = environment()` +# - arguments remain unevaluated on early return +otel_active_span = function( + name, + label, + attributes = list(), + scope = environment() +) { + otel_is_tracing || return() + otel::start_local_active_span( + name = sprintf('%s %s', name, label), + attributes = otel::as_attributes(attributes), + tracer = otel_tracer, + activation_scope = scope + ) +} + +otel_cache_tracer = function() { + requireNamespace('otel', quietly = TRUE) || return() + otel_tracer <<- otel::get_tracer(otel_tracer_name) + otel_is_tracing <<- tracer_enabled(otel_tracer) +} + +tracer_enabled = function(tracer) { + .subset2(tracer, 'is_enabled')() +} + +otel_refresh_tracer = function(pkgname) { + requireNamespace('otel', quietly = TRUE) || return() + ns = getNamespace(pkgname) + do.call(unlockBinding, list('otel_is_tracing', ns)) # do.call for R CMD Check + do.call(unlockBinding, list('otel_tracer', ns)) + otel_tracer = otel::get_tracer() + ns[['otel_is_tracing']] = tracer_enabled(otel_tracer) + ns[['otel_tracer']] = otel_tracer + lockBinding('otel_is_tracing', ns) + lockBinding('otel_tracer', ns) +} + +# knitr-specific helpers: + +make_chunk_attributes = function(options) { + list( + knitr.chunk.device = options$dev, + knitr.chunk.echo = options$echo, + knitr.chunk.engine = options$engine, + knitr.chunk.eval = options$eval, + knitr.chunk.label = options$label + ) +} + +make_knitr_attributes = function() { + list( + knitr.format = out_format(), + knitr.input = get_knitr_concord('infile'), + knitr.output = get_knitr_concord('outfile') + ) +} + +# safe version that always returns a string +get_knitr_concord = function(name) { + knit_concord$get(name) %n% '' +} diff --git a/R/output.R b/R/output.R index 9f926a3eff..88965c4e69 100644 --- a/R/output.R +++ b/R/output.R @@ -227,6 +227,12 @@ knit = function( if (is.null(out_format())) auto_format(ext) + otel_active_span( + name = 'knitr processing', + label = get_knitr_concord('infile'), + attributes = make_knitr_attributes() + ) + params = NULL # the params field from YAML if (out_format('markdown')) { if (child_mode()) { @@ -264,6 +270,12 @@ knit = function( if (!quiet) message('output file: ', output, ifelse(progress, '\n', '')) } + otel_active_span( + name = 'knitr output', + label = get_knitr_concord('outfile'), + attributes = make_knitr_attributes() + ) + output %n% res } #' @rdname knit diff --git a/R/zzz.R b/R/zzz.R index 120a5e281e..02436d6938 100644 --- a/R/zzz.R +++ b/R/zzz.R @@ -2,9 +2,11 @@ has_rlang = FALSE .onLoad = function(lib, pkg) { register_vignette_engines(pkg) - + default_handlers <<- evaluate::new_output_handler() + otel_cache_tracer() + has_rlang <<- requireNamespace("rlang", quietly = TRUE) if (has_rlang) diff --git a/tests/testit/test-otel.R b/tests/testit/test-otel.R new file mode 100644 index 0000000000..cc89a2cf3c --- /dev/null +++ b/tests/testit/test-otel.R @@ -0,0 +1,70 @@ +library(testit) + +if (requireNamespace('otelsdk', quietly = TRUE)) { + + record = otelsdk::with_otel_record({ + # refresh tracer within the `with_otel_record()` scope + otel_refresh_tracer('knitr') + + knit( + text = c('<>=', '1', '1+', '1', '1', '@'), + quiet = TRUE + ) + }) + + traces = record$traces + + assert('otel tracing works on text input', { + (length(traces) %==% 3L) + (startsWith(traces[[1L]]$name, 'knitr processing')) + (traces[[1L]]$attributes$knitr.format %==% 'latex') + (traces[[1L]]$attributes$knitr.input %==% '') + (traces[[1L]]$attributes$knitr.output %==% '') + (traces[[2L]]$name %==% 'knit unnamed-chunk-1') + (traces[[2L]]$attributes$knitr.chunk.device %==% 'pdf') + (traces[[2L]]$attributes$knitr.chunk.echo %==% FALSE) + (traces[[2L]]$attributes$knitr.chunk.engine %==% 'R') + (traces[[2L]]$attributes$knitr.chunk.eval %==% c(1, 2)) + (traces[[2L]]$attributes$knitr.chunk.label %==% 'unnamed-chunk-1') + (startsWith(traces[[3L]]$name, 'knitr output')) + (traces[[3L]]$attributes$knitr.format %==% 'latex') + (traces[[3L]]$attributes$knitr.input %==% '') + (traces[[3L]]$attributes$knitr.output %==% '') + }) + + record = otelsdk::with_otel_record({ + otel_refresh_tracer('knitr') + + local({ + env = new.env() + env$y = 1:3 + z = 5 + on.exit(file.remove('knit-envir.md')) + knit('knit-envir.Rmd', envir = env, quiet = TRUE) + }) + }) + + traces = record$traces + + assert('otel tracing works when knitting files', { + (length(traces) %==% 3L) + (traces[[1L]]$name %==% 'knitr processing knit-envir.Rmd') + (traces[[1L]]$attributes$knitr.format %==% 'markdown') + (traces[[1L]]$attributes$knitr.input %==% 'knit-envir.Rmd') + (traces[[1L]]$attributes$knitr.output %==% 'knit-envir.md') + (traces[[2L]]$name %==% 'knit test') + (traces[[2L]]$attributes$knitr.chunk.device %==% 'png') + (traces[[2L]]$attributes$knitr.chunk.echo %==% TRUE) + (traces[[2L]]$attributes$knitr.chunk.engine %==% 'R') + (traces[[2L]]$attributes$knitr.chunk.eval %==% TRUE) + (traces[[2L]]$attributes$knitr.chunk.label %==% 'test') + (traces[[3L]]$name %==% 'knitr output knit-envir.md') + (traces[[3L]]$attributes$knitr.format %==% 'markdown') + (traces[[3L]]$attributes$knitr.input %==% 'knit-envir.Rmd') + (traces[[3L]]$attributes$knitr.output %==% 'knit-envir.md') + }) + + # reset tracer after tests + otel_refresh_tracer('knitr') + +}