diff --git a/.gitignore b/.gitignore index ee11419a..16117da4 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,6 @@ # scratch directory for preparing releases /.release/ + +# Python +__pycache__/ diff --git a/doc/API.md b/doc/API.md index 747d8a59..f8e8c253 100644 --- a/doc/API.md +++ b/doc/API.md @@ -358,8 +358,8 @@ If there is no location associated with the current request, then ### `datadog_proxy_directive` `$datadog_proxy_directive` expands to the name of the configuration directive -used to proxy the current request, i.e. one of `proxy_pass`, `grpc_pass`, or -`fastcgi_pass`. +used to proxy the current request, i.e. one of `proxy_pass`, `grpc_pass`, +`fastcgi_pass`, or `uwsgi_pass`. If the request was not configured by one of those directives, then `$datadog_proxy_directive` expands to "`location`". diff --git a/example/README.md b/example/README.md index 619b35a9..5f79363b 100644 --- a/example/README.md +++ b/example/README.md @@ -12,6 +12,7 @@ services: - `http` runs a NodeJS HTTP server that listens on port 8080. - `fastcgi` runs a NodeJS FastCGI server that listens on port 8080. - `grpc` runs a NodeJS gRPC server that listens on port 1337. +- `uwsgi` runs a Python uWSGI server that listens on port 8080. - `client` contains the command line tools `curl` and `grpcurl`. Because client command line tools are available within the `client` service, diff --git a/example/docker-compose.yml b/example/docker-compose.yml index 4072a8bc..91591752 100644 --- a/example/docker-compose.yml +++ b/example/docker-compose.yml @@ -14,6 +14,7 @@ services: - http - fastcgi - grpc + - uwsgi - agent # `agent` is the Datadog Agent to which traces are sent. @@ -69,6 +70,18 @@ services: depends_on: - agent + # `uwsgi` is a uWSGI server that is reverse proxied by `nginx`. + uwsgi: + build: + context: ./services/uwsgi + dockerfile: ./Dockerfile + environment: + - DD_ENV=prod + - DD_AGENT_HOST=agent + - DD_SERVICE=uwsgi + depends_on: + - agent + # `client` is a container that exists solely so that the host can # `docker-compose exec` into it to run command line tools such as `curl` and # `grpcurl`. This way, the host's network doesn't need access to the network diff --git a/example/services/nginx/nginx.conf b/example/services/nginx/nginx.conf index 40e278bb..e9823798 100644 --- a/example/services/nginx/nginx.conf +++ b/example/services/nginx/nginx.conf @@ -42,6 +42,11 @@ http { location /fastcgi { fastcgi_pass fastcgi:8080; } + + location /uwsgi { + include uwsgi_params; + uwsgi_pass uwsgi:8080; + } } server { diff --git a/example/services/uwsgi/Dockerfile b/example/services/uwsgi/Dockerfile new file mode 100644 index 00000000..1cc42e50 --- /dev/null +++ b/example/services/uwsgi/Dockerfile @@ -0,0 +1,13 @@ +FROM alpine:3.15 + +RUN mkdir /opt/app +WORKDIR /opt/app + +RUN apk update && apk add python3 py3-pip python3-dev build-base linux-headers pcre-dev +RUN pip3 install --no-cache-dir uwsgi flask ddtrace + +COPY ./uwsgi.ini /opt/app/uwsgi.ini +COPY ./wsgi.py /opt/app/wsgi.py +COPY ./app.py /opt/app/app.py + +CMD ["uwsgi", "--ini", "uwsgi.ini"] diff --git a/example/services/uwsgi/app.py b/example/services/uwsgi/app.py new file mode 100644 index 00000000..dc43f6ed --- /dev/null +++ b/example/services/uwsgi/app.py @@ -0,0 +1,14 @@ +from flask import Flask, request, jsonify + +app = Flask(__name__) + +@app.route("/", defaults={"path": ""}) +@app.route("/") +@app.route("/") +def main(path): + response = { + "service": "uwsgi", + "headers": dict(request.headers) + } + print(response) + return jsonify(response) diff --git a/example/services/uwsgi/uwsgi.ini b/example/services/uwsgi/uwsgi.ini new file mode 100644 index 00000000..df676532 --- /dev/null +++ b/example/services/uwsgi/uwsgi.ini @@ -0,0 +1,11 @@ +[uwsgi] +module = wsgi:app +master = true +socket = :8080 + +;; dd-trace-py is used to trace the Flask application. +;; It requires these options to supports uWSGI. +;; See . +enable-threads = 1 +lazy-apps = 1 +import=ddtrace.bootstrap.sitecustomize diff --git a/example/services/uwsgi/wsgi.py b/example/services/uwsgi/wsgi.py new file mode 100644 index 00000000..6026b0fa --- /dev/null +++ b/example/services/uwsgi/wsgi.py @@ -0,0 +1,4 @@ +from app import app + +if __name__ == "__main__": + app.run() diff --git a/module/config b/module/config index 70eb1d96..fcf45e9f 100644 --- a/module/config +++ b/module/config @@ -6,6 +6,6 @@ ngx_module_name="$ngx_addon_name" # configuration directives we override. This way, our module can define # handlers for those directives that do some processing and then forward # to the "real" handler in the other module. -ngx_module_order="$ngx_addon_name ngx_http_log_module ngx_http_fastcgi_module ngx_http_grpc_module ngx_http_proxy_module ngx_http_api_module" +ngx_module_order="$ngx_addon_name ngx_http_log_module ngx_http_fastcgi_module ngx_http_grpc_module ngx_http_proxy_module ngx_http_api_module ngx_http_uwsgi_module" . auto/module diff --git a/src/datadog_directive.cpp b/src/datadog_directive.cpp index 8149bb3d..b63f21f9 100644 --- a/src/datadog_directive.cpp +++ b/src/datadog_directive.cpp @@ -363,6 +363,57 @@ char *hijack_grpc_pass(ngx_conf_t *cf, ngx_command_t *command, void *conf) noexc return hijack_pass_directive(&propagate_grpc_datadog_context, cf, command, conf); } +char *propagate_uwsgi_datadog_context(ngx_conf_t *cf, ngx_command_t *command, void * /*conf*/) noexcept try { + auto main_conf = static_cast( + ngx_http_conf_get_module_main_conf(cf, ngx_http_datadog_module)); + + // The only way that `main_conf` could be `nullptr` is if there's no `http` + // block in the nginx configuration. In that case, this function would never + // get called, because it's called only from configuration directives that + // live inside the `http` block. + assert(main_conf != nullptr); + + if (!main_conf->are_propagation_styles_locked) { + if (auto rcode = lock_propagation_styles(command, cf)) { + return rcode; + } + } + // For each propagation header (from `span_context_keys`), add a + // "uwsgi_param ...;" directive to the configuration, and then process the + // injected directive by calling `datadog_conf_handler`. + const auto &keys = main_conf->span_context_keys; + + auto old_args = cf->args; + + ngx_str_t args[] = {ngx_string("uwsgi_param"), ngx_str_t(), ngx_str_t(), + ngx_string("if_not_empty")}; + ngx_array_t args_array; + args_array.elts = static_cast(&args); + args_array.nelts = 4; + + cf->args = &args_array; + const auto guard = defer([&]() { cf->args = old_args; }); + + for (const std::string_view key : keys) { + // NOTE(@dmehala): uWSGI uses the same key header convention as fastcgi + args[1] = make_fastcgi_span_context_key(cf->pool, key); + args[2] = make_propagation_header_variable(cf->pool, key); + auto rcode = datadog_conf_handler({.conf = cf, .skip_this_module = true}); + if (rcode != NGX_OK) { + return static_cast(NGX_CONF_ERROR); + } + } + + return static_cast(NGX_CONF_OK); +} catch (const std::exception &e) { + ngx_log_error(NGX_LOG_ERR, cf->log, 0, "propagate_uwsgi_datadog_context failed: %s", e.what()); + return static_cast(NGX_CONF_ERROR); +} + +char *hijack_uwsgi_pass(ngx_conf_t *cf, ngx_command_t *command, void *conf) noexcept { + return hijack_pass_directive(&propagate_uwsgi_datadog_context, cf, command, conf); +} + char *hijack_access_log(ngx_conf_t *cf, ngx_command_t *command, void *conf) noexcept try { // In case we need to change the `access_log` command's format to a // Datadog-specific default, first make sure that those formats are defined. diff --git a/src/datadog_directive.h b/src/datadog_directive.h index 572aecd2..34fbc610 100644 --- a/src/datadog_directive.h +++ b/src/datadog_directive.h @@ -26,6 +26,8 @@ char *propagate_grpc_datadog_context(ngx_conf_t *cf, ngx_command_t *command, voi char *hijack_grpc_pass(ngx_conf_t *cf, ngx_command_t *command, void *conf) noexcept; +char *hijack_uwsgi_pass(ngx_conf_t *cf, ngx_command_t *command, void *conf) noexcept; + char *hijack_access_log(ngx_conf_t *cf, ngx_command_t *command, void *conf) noexcept; char *add_datadog_tag(ngx_conf_t *cf, ngx_array_t *tags, ngx_str_t key, ngx_str_t value) noexcept; diff --git a/src/ngx_http_datadog_module.cpp b/src/ngx_http_datadog_module.cpp index 40cda940..88d8a75f 100644 --- a/src/ngx_http_datadog_module.cpp +++ b/src/ngx_http_datadog_module.cpp @@ -133,6 +133,13 @@ static ngx_command_t datadog_commands[] = { 0, nullptr}, + { ngx_string("uwsgi_pass"), + anywhere_but_main | NGX_CONF_TAKE1, + hijack_uwsgi_pass, + NGX_HTTP_LOC_CONF_OFFSET, + 0, + nullptr}, + // The configuration of this directive was copied from // ../nginx/src/http/modules/ngx_http_log_module.c. { ngx_string("access_log"), diff --git a/test/cases/auto_propagation/conf/uwsgi_auto.conf b/test/cases/auto_propagation/conf/uwsgi_auto.conf new file mode 100644 index 00000000..d9c67557 --- /dev/null +++ b/test/cases/auto_propagation/conf/uwsgi_auto.conf @@ -0,0 +1,16 @@ +load_module modules/ngx_http_datadog_module.so; + +events { + worker_connections 1024; +} + +http { + server { + listen 80; + + location / { + include /etc/nginx/uwsgi_params; + uwsgi_pass uwsgi:8080; + } + } +} diff --git a/test/cases/auto_propagation/conf/uwsgi_disabled_at_http.conf b/test/cases/auto_propagation/conf/uwsgi_disabled_at_http.conf new file mode 100644 index 00000000..6155aa9c --- /dev/null +++ b/test/cases/auto_propagation/conf/uwsgi_disabled_at_http.conf @@ -0,0 +1,17 @@ +load_module modules/ngx_http_datadog_module.so; + +events { + worker_connections 1024; +} + +http { + datadog_disable; + server { + listen 80; + + location / { + include /etc/nginx/uwsgi_params; + uwsgi_pass uwsgi:8080; + } + } +} diff --git a/test/cases/auto_propagation/conf/uwsgi_disabled_at_location.conf b/test/cases/auto_propagation/conf/uwsgi_disabled_at_location.conf new file mode 100644 index 00000000..e163ae89 --- /dev/null +++ b/test/cases/auto_propagation/conf/uwsgi_disabled_at_location.conf @@ -0,0 +1,17 @@ +load_module modules/ngx_http_datadog_module.so; + +events { + worker_connections 1024; +} + +http { + server { + listen 80; + + location / { + datadog_disable; + include /etc/nginx/uwsgi_params; + uwsgi_pass uwsgi:8080; + } + } +} diff --git a/test/cases/auto_propagation/conf/uwsgi_disabled_at_server.conf b/test/cases/auto_propagation/conf/uwsgi_disabled_at_server.conf new file mode 100644 index 00000000..02fc6ce8 --- /dev/null +++ b/test/cases/auto_propagation/conf/uwsgi_disabled_at_server.conf @@ -0,0 +1,17 @@ +load_module modules/ngx_http_datadog_module.so; + +events { + worker_connections 1024; +} + +http { + server { + listen 80; + datadog_disable; + + location / { + include /etc/nginx/uwsgi_params; + uwsgi_pass uwsgi:8080; + } + } +} diff --git a/test/cases/auto_propagation/conf/uwsgi_without_module.conf b/test/cases/auto_propagation/conf/uwsgi_without_module.conf new file mode 100644 index 00000000..f72fdf21 --- /dev/null +++ b/test/cases/auto_propagation/conf/uwsgi_without_module.conf @@ -0,0 +1,15 @@ + +events { + worker_connections 1024; +} + +http { + server { + listen 80; + + location / { + include /etc/nginx/uwsgi_params; + uwsgi_pass uwsgi:8080; + } + } +} diff --git a/test/cases/auto_propagation/test_uwsgi.py b/test/cases/auto_propagation/test_uwsgi.py new file mode 100644 index 00000000..770bd64e --- /dev/null +++ b/test/cases/auto_propagation/test_uwsgi.py @@ -0,0 +1,45 @@ +from .. import case + +import json +from pathlib import Path + + +class TestUWSGI(case.TestCase): + + def test_auto_propagation(self): + return self.run_test('./conf/uwsgi_auto.conf', should_propagate=True) + + def test_disabled_at_location(self): + return self.run_test('./conf/uwsgi_disabled_at_location.conf', + should_propagate=False) + + def test_disabled_at_server(self): + return self.run_test('./conf/uwsgi_disabled_at_server.conf', + should_propagate=False) + + def test_disabled_at_http(self): + return self.run_test('./conf/uwsgi_disabled_at_http.conf', + should_propagate=False) + + def test_without_module(self): + return self.run_test('./conf/uwsgi_without_module.conf', + should_propagate=False) + + def run_test(self, conf_relative_path, should_propagate): + conf_path = Path(__file__).parent / conf_relative_path + conf_text = conf_path.read_text() + status, log_lines = self.orch.nginx_replace_config( + conf_text, conf_path.name) + self.assertEqual(status, 0, log_lines) + + status, body = self.orch.send_nginx_http_request('/') + self.assertEqual(status, 200) + response = json.loads(body) + self.assertEqual(response['service'], 'uwsgi') + headers = response['headers'] + if should_propagate: + self.assertIn('X-Datadog-Sampling-Priority', headers) + priority = headers['X-Datadog-Sampling-Priority'] + priority = int(priority) + else: + self.assertNotIn('X-Datadog-Sampling-Priority', headers) diff --git a/test/docker-compose.yml b/test/docker-compose.yml index 4cd27440..b946e787 100644 --- a/test/docker-compose.yml +++ b/test/docker-compose.yml @@ -19,6 +19,7 @@ services: - fastcgi - grpc - agent + - uwsgi # `agent` is a mock trace agent. It listens on port 8126, accepts msgpack, # decodes the resulting traces, and prints them to standard output as JSON. @@ -63,6 +64,22 @@ services: depends_on: - agent + # `uwsgi` is a uWSGI server that is reverse proxied by `nginx`. It + # listens on port 8080 and responds with a JSON object containing the name of + # the service ("uwsgi") and the request's headers. This way, tests can see + # which trace context, if any, was propagated to the reverse proxied server. + uwsgi: + image: nginx-datadog-test-services-uwsgi + build: + context: ./services/uwsgi + dockerfile: ./Dockerfile + environment: + - DD_ENV=prod + - DD_AGENT_HOST=agent + - DD_SERVICE=uwsgi + depends_on: + - agent + # `grpc` is a gRPC server that is reverse proxied by `nginx`. It # listens on port 1337 and responds with a message containing the metadata # sent with the request. This way, tests can see diff --git a/test/services/uwsgi/Dockerfile b/test/services/uwsgi/Dockerfile new file mode 100644 index 00000000..1cc42e50 --- /dev/null +++ b/test/services/uwsgi/Dockerfile @@ -0,0 +1,13 @@ +FROM alpine:3.15 + +RUN mkdir /opt/app +WORKDIR /opt/app + +RUN apk update && apk add python3 py3-pip python3-dev build-base linux-headers pcre-dev +RUN pip3 install --no-cache-dir uwsgi flask ddtrace + +COPY ./uwsgi.ini /opt/app/uwsgi.ini +COPY ./wsgi.py /opt/app/wsgi.py +COPY ./app.py /opt/app/app.py + +CMD ["uwsgi", "--ini", "uwsgi.ini"] diff --git a/test/services/uwsgi/app.py b/test/services/uwsgi/app.py new file mode 100644 index 00000000..737f6653 --- /dev/null +++ b/test/services/uwsgi/app.py @@ -0,0 +1,12 @@ +from flask import Flask, request, jsonify + +app = Flask(__name__) + +@app.route("/") +def main(): + response = { + "service": "uwsgi", + "headers": dict(request.headers) + } + print(response) + return jsonify(response) diff --git a/test/services/uwsgi/uwsgi.ini b/test/services/uwsgi/uwsgi.ini new file mode 100644 index 00000000..df676532 --- /dev/null +++ b/test/services/uwsgi/uwsgi.ini @@ -0,0 +1,11 @@ +[uwsgi] +module = wsgi:app +master = true +socket = :8080 + +;; dd-trace-py is used to trace the Flask application. +;; It requires these options to supports uWSGI. +;; See . +enable-threads = 1 +lazy-apps = 1 +import=ddtrace.bootstrap.sitecustomize diff --git a/test/services/uwsgi/wsgi.py b/test/services/uwsgi/wsgi.py new file mode 100644 index 00000000..6026b0fa --- /dev/null +++ b/test/services/uwsgi/wsgi.py @@ -0,0 +1,4 @@ +from app import app + +if __name__ == "__main__": + app.run()