diff --git a/DESCRIPTION b/DESCRIPTION index 95e79acc..49c88c51 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -1,10 +1,13 @@ Type: Package Package: httpuv Title: HTTP and WebSocket Server Library -Version: 1.6.16.9000 +Version: 1.6.16.9002 Authors@R: c( person("Joe", "Cheng", , "joe@posit.co", role = "aut"), - person("Winston", "Chang", , "winston@posit.co", role = c("aut", "cre")), + person("Winston", "Chang", , "winston@posit.co", role = c("aut", "cre"), + comment = c(ORCID = "0000-0002-1576-2126")), + person("Barret", "Schloerke", , "barret@posit.co", role = "aut", + comment = c(ORCID = "0000-0001-9986-114X")), person("Posit, PBC", role = c("cph", "fnd"), comment = c(ROR = "03wc8by49")), person("Hector", "Corrada Bravo", role = "ctb"), @@ -58,6 +61,7 @@ Depends: R (>= 2.15.1) Imports: later (>= 0.8.0), + otel, promises, R6, Rcpp (>= 1.0.7), @@ -66,11 +70,15 @@ Suggests: callr, curl, jsonlite, + otelsdk, testthat (>= 3.0.0), websocket LinkingTo: later, Rcpp +Remotes: + r-lib/otel, + r-lib/otelsdk Config/Needs/website: tidyverse/tidytemplate Config/testthat/edition: 3 Config/usethis/last-upkeep: 2025-07-01 @@ -82,6 +90,8 @@ Collate: 'RcppExports.R' 'httpuv-package.R' 'httpuv.R' + 'import-standalone-defer.R' + 'otel.R' 'random_port.R' 'server.R' 'staticServer.R' diff --git a/R/httpuv-package.R b/R/httpuv-package.R index 73d2e2c8..6c5d84a8 100644 --- a/R/httpuv-package.R +++ b/R/httpuv-package.R @@ -32,3 +32,6 @@ #' @importFrom R6 R6Class ## usethis namespace: end NULL + + +otel_tracer_name <- "io.github.rstudio/httpuv" diff --git a/R/httpuv.R b/R/httpuv.R index f753e7e5..05f04751 100644 --- a/R/httpuv.R +++ b/R/httpuv.R @@ -213,7 +213,6 @@ AppWrapper <- R6Class( if (!private$supportsOnHeaders) { return(NULL) } - rookCall(private$app$onHeaders, req) }, onBodyData = function(req, bytes) { @@ -226,6 +225,15 @@ AppWrapper <- R6Class( # The cpp_callback is an external pointer to a C++ function that writes # the response. + otel_is_tracing <- otel::is_tracing() + + if (otel_is_tracing) { + otel_active_span_for_call <- otel_start_active_call_span(req) + local_otel_active_span_promise_domain(otel_active_span_for_call) + + # promises:::local_otel_active_span_promise_domain(otel_active_span_for_call) + } + resp <- if (is.null(private$app$call)) { list( status = 404L, @@ -244,6 +252,9 @@ AppWrapper <- R6Class( if (!is.null(req$.bodyData)) { close(req$.bodyData) } + if (otel_is_tracing) { + otel_span_for_call$end() + } req$.bodyData <- NULL } diff --git a/R/import-standalone-defer.R b/R/import-standalone-defer.R new file mode 100644 index 00000000..e689e509 --- /dev/null +++ b/R/import-standalone-defer.R @@ -0,0 +1,35 @@ +# Standalone file: do not edit by hand +# Source: https://github.com/r-lib/withr/blob/HEAD/R/standalone-defer.R +# Generated by: usethis::use_standalone("r-lib/withr", "defer") +# ---------------------------------------------------------------------- +# +# --- +# repo: r-lib/withr +# file: standalone-defer.R +# last-updated: 2024-01-15 +# license: https://unlicense.org +# --- +# +# `defer()` is similar to `on.exit()` but with a better default for +# `add` (hardcoded to `TRUE`) and `after` (`FALSE` by default). +# It also supports adding handlers to other frames which is useful +# to implement `local_` functions. +# +# +# ## Changelog +# +# 2024-01-15: +# * Rewritten to be pure base R. +# +# nocov start + +defer <- function(expr, envir = parent.frame(), after = FALSE) { + thunk <- as.call(list(function() expr)) + do.call( + on.exit, + list(thunk, add = TRUE, after = after), + envir = envir + ) +} + +# nocov end diff --git a/R/otel.R b/R/otel.R new file mode 100644 index 00000000..4713bfc4 --- /dev/null +++ b/R/otel.R @@ -0,0 +1,348 @@ +# Make promise domain for active span +otel_create_active_span_promise_domain <- function( + active_span + # , tracer = otel::get_tracer() + # span_ctx = tracer$get_current_span_context() +) { + force(active_span) + + promises_env <- rlang::ns_env("promises") + + promises_env$new_promise_domain( + wrapOnFulfilled = function(onFulfilled) { + # During binding ("then()") + force(onFulfilled) + + function(value) { + # During runtime ("resolve()") + otel::with_active_span(active_span, { + onFulfilled(value) + }) + } + }, + wrapOnRejected = function(onRejected) { + force(onRejected) + + function(reason) { + otel::with_active_span(active_span, { + onRejected(reason) + }) + } + }, + wrapSync = base::force, + "_local_promise_domain_compat" = TRUE + # wrapEnter = force, + # wrapExit = force, + # wrapSync = FALSE + ) +} + + +# with_promise_domain <- function(domain, expr, replace = FALSE) { +# oldval <- current_promise_domain() +# if (replace) { +# globals$domain <- domain +# } else { +# globals$domain <- compose_domains(oldval, domain) +# } +# on.exit(globals$domain <- oldval) + +# if (!is.null(domain)) domain$wrapSync(expr) else force(expr) +# } + +# Make a promise domain for a local scope +local_promise_domain <- function( + domain, + ..., + replace = FALSE, + local_envir = parent.frame() +) { + stopifnot(length(list(...)) == 0L) + + if (!identical(domain[["_local_promise_domain_compat"]], TRUE)) { + if (!identical(domain$wrapSync, base::force)) { + # If the domain's `wrapSync` is not `base::force`, then we assume it is a custom domain + # a custom domain that has been created by the otel package. + # This is to ensure that the domain's `wrapSync` is used correctly. + stop( + "The domain's `wrapSync` must be `base::force` to be used within `local_promise_domain()`." + ) + } + stop( + "local_promise_domain() is only compatible with `otel_create_active_span_promise_domain(active_span)`.", + call. = FALSE + ) + } + + promises_env <- rlang::ns_env("promises") + promises_globals <- promises_env$globals + oldval <- promises_env$current_promise_domain() + if (replace) { + promises_globals$domain <- domain + } else { + new_domain <- promises_env$compose_domains( + promises_globals$domain, + domain + ) + + promises_globals$domain <- new_domain + } + + defer( + { + promises_globals$domain <- oldval + }, + envir = local_envir + ) +} + +# Set promise domain to local scope with active span +local_otel_active_span_promise_domain <- function( + active_span, + ..., + replace = FALSE, + .envir = parent.frame() +) { + stopifnot(length(list(...)) == 0L) + + act_span_pd <- otel_create_active_span_promise_domain(active_span) + + local_promise_domain( + act_span_pd, + replace = replace, + local_envir = .envir + ) + + invisible() +} + +with_otel_active_span_promise_domain <- function( + active_span, + expr, + ..., + replace = FALSE, + .envir = parent.frame() +) { + stopifnot(length(list(...)) == 0L) + + act_span_pd <- otel_create_active_span_promise_domain(active_span) + + with_promise_domain( + act_span_pd, + expr, + replace = replace, + local_envir = .envir + ) + + invisible() +} + + +otel_start_active_span <- function( + name, + ..., + attributes = list(), + activation_scope = parent.frame() +) { + otel::start_local_active_span( + name = name, + activation_scope = activation_scope, + attributes = otel::as_attributes(attributes), + end_on_exit = FALSE + ) +} + + +otel_start_active_call_span <- function( + req, + ..., + activation_scope = parent.frame() +) { + str(as.list(req)) + + otel_start_active_span( + paste0( + # "httpuv ", + req$METHOD, + " ", + req$PATH + ), + + activation_scope = activation_scope, + + # https://opentelemetry.io/docs/specs/semconv/http/http-spans/#http-server + attributes = list( + # HTTP request method. + # Ex: `"GET"`, `"POST"`, `"PUT"`, etc. + 'http.request.method' = req$METHOD, + + # The URI path component + # Ex: `"/users/123"` + 'url.path' = req$PATH, + + # The URI scheme component identifying the used protocol. + # Ex: `"http"`, `"https"` + # 'url.scheme' = req$HTTP_VERSION + 'url.scheme' = "http" + #, + + # # Describes a class of error the operation ended with. + # # https://opentelemetry.io/docs/specs/semconv/registry/attributes/error/ + # # 'error.type' = NULL, + + # # Original HTTP method sent by the client in the request line + # # Ex: `"GET"`, `"POST"`, `"PUT"`, etc. + # 'http.request.method_original' = req$METHOD, + + # # HTTP response status code + # # Ex: `200` + # 'http.response.status_code' = NULL, + + # # # The matched route, that is, the path template in the format used by the respective server framework. + # # # Ex: `"/users/:userID?"` + # # ## Maybe something for plumber2? + # # 'http.route' = NULL, + + # # OSI application layer or non-OSI equivalent. + # # Ex: `"http"`, `"spdy"` + # 'network.protocol.name' = "http", + + # # Port of the local HTTP server that received the request. + # # Ex: `80`, `8080`, `443` + # 'server.port' = if (is.null(req$SERVER_PORT)) { + # NULL + # } else { + # as.integer(req$SERVER_PORT) + # }, + + # 'url.scheme' = req$HTTP_SCHEME, + + # # The URI query component. + # # Ex: `"q=OpenTelemetry"` + # 'url.query' = req$QUERY_STRING, + + # # Client address + # # Ex: `"83.164.160.102"` + # # TODO: Implement `client.address` + # 'client.address' = req$REMOTE_ADDR, + + # # # Peer address of the network connection - IP address or Unix domain socket name. + # # # Ex: `"10.1.2.80"`, `"/tmp/my.sock"` + # # # TODO: Implement `network.peer.address` + # # 'network.peer.address' = req$REMOTE_ADDR, + + # # # Peer port number of the network connection. + # # # Ex: `65123` + # # # TODO: Implement `network.peer.port` + # # 'network.peer.port' = if (is.null(req$REMOTE_PORT)) NULL else as.integer(req$REMOTE_PORT), + + # # # The actual version of the protocol used for network communication. + # # # Ex: `"1.0"`, `"1.1"`, `"2"`, `"3"` + # # # TODO: Implement `network.protocol.version` + # # 'network.protocol.version' = req$HTTP_VERSION, + + # # Name of the local HTTP server that received the request. + # # Ex: `"example.com"`, `"10.1.2.80"`, `"/tmp/my.sock"` + # 'server.address' = req$SERVER_NAME, + + # # Value of the HTTP User-Agent header sent by the client. + # # Ex: `"CERN-LineMode/2.15 libwww/2.17b3"`, `"Mozilla/5.0 (iPhone; CPU iPhone OS 14_7_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.1.2 Mobile/15E148 Safari/604.1"` + # 'user_agent.original' = req$HTTP_USER_AGENT, + + # # The port of whichever client was captured in client.address. + # # Ex: `65123` + # # TODO: Implement `client.port` + # 'client.port' = if (is.null(req$REMOTE_PORT)) { + # NULL + # } else { + # as.integer(req$REMOTE_PORT) + # }, + + # # The size of the request payload body in bytes. + # # This is the number of bytes transferred excluding headers and is often, but not always, present as the Content-Length header. + # # For requests using transport encoding, this should be the compressed size. + # # TODO: Implement `http.request.body.size` + # 'http.request.body.size' = if (is.null(req$HTTP_CONTENT_LENGTH)) { + # NULL + # } else { + # as.integer(req$HTTP_CONTENT_LENGTH) + # }, + + # # HTTP request headers, being the normalized HTTP Header name (lowercase), the value being the header values. + # # Ex: `["application/json"]`, `["1.2.3.4", "1.2.3.5"]` + # # TODO: Implement `http.request.header.` + # # TODO: Expand the list! rlang::list2()? !!! the result + # 'http.request.header' = lapply(req$HEADERS, function(x) { + # if (is.null(x)) { + # return(NULL) + # } + # # Are these formats needed? + + # if (is.character(x)) { + # return(as.character(x)) + # } else if (is.raw(x)) { + # return(rawToChar(x)) + # } else { + # return(as.character(x)) + # } + # }), + + # # # The total size of the request in bytes. + # # # This should be the total number of bytes sent over the wire, including the request line (HTTP/1.1), framing (HTTP/2 and HTTP/3), headers, and request body if any. + # # # Ex: `1437` + # # TODO: Implement `http.request.size` + # # 'http.request.size' = if (is.null(req$HTTP_CONTENT_LENGTH)) NULL else as.integer(req$HTTP_CONTENT_LENGTH) + nchar(req$PATH) + nchar(req$HTTP_VERSION) + sum(nchar(names(req$HEADERS))) + sum(nchar(unlist(req$HEADERS))), + + # # # The size of the response payload body in bytes. + # # # This is the number of bytes transferred excluding headers and is often, but not always, present as the Content-Length header. + # # # For requests using transport encoding, this should be the compressed size. + # # # Ex: `3495` + # # TODO: Implement `http.response.body.size` + # # 'http.response.body.size' = if (is.null(req$HTTP_CONTENT_LENGTH)) NULL else as.integer(req$HTTP_CONTENT_LENGTH), + + # # HTTP response headers, being the normalized HTTP Header name (lowercase), the value being the header values. + # # Ex: `["application/json"]`, `["abc", "def"]` + # # TODO: Implement headers + # 'http.response.header' = lapply(res$HEADERS, function(x) { + # if (is.null(x)) { + # return(NULL) + # } + # # Are these formats needed? + + # if (is.character(x)) { + # return(as.character(x)) + # } else if (is.raw(x)) { + # return(rawToChar(x)) + # } else { + # return(as.character(x)) + # } + # }), + + # # # The total size of the response in bytes. + # # # This should be the total number of bytes sent over the wire, including the status line (HTTP/1.1), framing (HTTP/2 and HTTP/3), headers, and response body and trailers if any. + # # # Ex: `1437` + # # # TODO: Implement `http.response.size` + # # 'http.response.size' = if (is.null(req$HTTP_CONTENT_LENGTH)) NULL else as.integer(req$HTTP_CONTENT_LENGTH) + nchar(req$PATH) + nchar(req$HTTP_VERSION) + sum(nchar(names(req$HEADERS))) + sum(nchar(unlist(req$HEADERS))), + + # # # Local socket address. Useful in case of a multi-IP host. + # # # Ex: `"10.1.2.80"`, `"/tmp/my.sock"` + # # # TODO: Implement `network.local.address` + # # 'network.local.address' = req$SERVER_NAME, + + # # # Local socket port. Useful in case of a multi-port host. + # # # Ex: `65123` + # # # TODO: Implement `network.local.port`; get from host? + # # 'network.local.port' = if (is.null(req$SERVER_PORT)) NULL else as.integer(req$SERVER_PORT), + + # # # OSI transport layer or inter-process communication method. + # # # Ex: `"tcp"`, `"udp"` + # # # TODO: Implement `network.transport` + # # 'network.transport' = if (is.null(req$HTTP_TRANSPORT)) "tcp" else req$HTTP_TRANSPORT, + + # # # Specifies the category of synthetic traffic, such as tests or bots. + # # # Ex: `bot`, `test` + # # # TODO: Implement `user_agent.synthetic.type` + # # 'user_agent.synthetic.type' = if (is.null(req$HTTP_USER_AGENT_SYNTHETIC_TYPE)) NULL else req$HTTP_USER_AGENT_SYNTHETIC_TYPE, + ) + ) +}