From c532e421c41cb643308d0c08cd5976144b6fc6c7 Mon Sep 17 00:00:00 2001 From: Vladimir Mihailenco Date: Sun, 1 Nov 2026 16:07:54 +0200 Subject: [PATCH 1/4] fix: don't crash in uptrace.shutdown() when Uptrace is disabled Without a DSN the global providers are no-op proxies without shutdown(), so example/basic crashed with AttributeError. --- src/uptrace/uptrace.py | 12 +++++++++--- test/test_uptrace.py | 6 ++++++ 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/src/uptrace/uptrace.py b/src/uptrace/uptrace.py index 3b2ee98..1e0a489 100644 --- a/src/uptrace/uptrace.py +++ b/src/uptrace/uptrace.py @@ -76,9 +76,15 @@ def configure_opentelemetry( def shutdown(): - trace.get_tracer_provider().shutdown() - metrics.get_meter_provider().shutdown() - _logs.get_logger_provider().shutdown() + # When Uptrace is disabled (e.g. no DSN), the global providers are no-op + # proxies that don't have shutdown(). + for provider in ( + trace.get_tracer_provider(), + metrics.get_meter_provider(), + _logs.get_logger_provider(), + ): + if hasattr(provider, "shutdown"): + provider.shutdown() def force_flush(): diff --git a/test/test_uptrace.py b/test/test_uptrace.py index fadb668..26e3506 100644 --- a/test/test_uptrace.py +++ b/test/test_uptrace.py @@ -26,6 +26,12 @@ def test_span_processor_invalid_dsn(caplog): assert "can't parse DSN=invalid" in caplog.text +def test_shutdown_when_disabled(): + trace._TRACER_PROVIDER = None + uptrace.configure_opentelemetry() + uptrace.shutdown() + + def test_trace_url(): tracer = trace.get_tracer("tracer_name") span = tracer.start_span("main span") From c3e104161be3d089c9b0f55955aa38deb8225201 Mon Sep 17 00:00:00 2001 From: Vladimir Mihailenco Date: Sun, 1 Nov 2026 16:07:55 +0200 Subject: [PATCH 2/4] fix(example): make fastapi, pyramid, django-postgresql and otlp-traces-http run - fastapi, pyramid: pin opentelemetry-instrumentation-logging, otherwise pip picks 0.66b2 and fails to resolve against the 0.66b0 instrumentations - pyramid: bump to 2.1; 2.0.2 imports pkg_resources removed from setuptools - django-postgresql: use psycopg2-binary so no pg_config is needed - otlp-traces-http: the HTTP exporter takes headers as a dict --- example/django-postgresql/requirements.txt | 2 +- example/fastapi/requirements.txt | 1 + example/otlp-traces-http/main.py | 2 +- example/pyramid/requirements.txt | 3 ++- 4 files changed, 5 insertions(+), 3 deletions(-) diff --git a/example/django-postgresql/requirements.txt b/example/django-postgresql/requirements.txt index b973aaa..6a12f35 100644 --- a/example/django-postgresql/requirements.txt +++ b/example/django-postgresql/requirements.txt @@ -1,6 +1,6 @@ uptrace==1.45.0 django==5.2.17 -psycopg2==2.9.5 +psycopg2-binary==2.9.13 opentelemetry-instrumentation-logging==0.66b0 opentelemetry-instrumentation-django==0.66b0 opentelemetry-instrumentation-psycopg2==0.66b0 diff --git a/example/fastapi/requirements.txt b/example/fastapi/requirements.txt index 3d546b7..ae4a145 100644 --- a/example/fastapi/requirements.txt +++ b/example/fastapi/requirements.txt @@ -4,3 +4,4 @@ requests opentelemetry-instrumentation-fastapi==0.66b0 opentelemetry-instrumentation-requests==0.66b0 uptrace==1.45.0 +opentelemetry-instrumentation-logging==0.66b0 diff --git a/example/otlp-traces-http/main.py b/example/otlp-traces-http/main.py index 2f36270..dde3aeb 100755 --- a/example/otlp-traces-http/main.py +++ b/example/otlp-traces-http/main.py @@ -27,7 +27,7 @@ exporter = OTLPSpanExporter( endpoint="https://otlp.uptrace.dev/v1/traces", # Set the Uptrace dsn here or use UPTRACE_DSN env var. - headers=(("uptrace-dsn", dsn),), + headers={"uptrace-dsn": dsn}, timeout=10, compression=Compression.Gzip, ) diff --git a/example/pyramid/requirements.txt b/example/pyramid/requirements.txt index 187c8da..a618e2b 100644 --- a/example/pyramid/requirements.txt +++ b/example/pyramid/requirements.txt @@ -1,3 +1,4 @@ uptrace==1.45.0 -pyramid==2.0.2 +pyramid==2.1 opentelemetry-instrumentation-pyramid==0.66b0 +opentelemetry-instrumentation-logging==0.66b0 From fd8bd009dca02d5869623fe38a3e2443c95a68f5 Mon Sep 17 00:00:00 2001 From: Vladimir Mihailenco Date: Sun, 1 Nov 2026 16:07:57 +0200 Subject: [PATCH 3/4] ci: smoke-test examples and keep their versions in sync - scripts/run_example.py installs and runs each example (script, daemon or HTTP server checks) against the local uptrace source - scripts/sync_examples.py syncs uptrace/OpenTelemetry pins from pyproject.toml; CI runs it with --check - examples workflow runs on PRs, master and weekly - dependabot updates third-party example deps --- .github/dependabot.yml | 14 ++ .github/workflows/examples.yml | 71 +++++++++ .gitignore | 1 + Makefile | 11 +- RELEASING.md | 13 +- scripts/run_example.py | 284 +++++++++++++++++++++++++++++++++ scripts/sync_examples.py | 81 ++++++++++ 7 files changed, 473 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/examples.yml create mode 100644 scripts/run_example.py create mode 100644 scripts/sync_examples.py diff --git a/.github/dependabot.yml b/.github/dependabot.yml index b8bbf59..0d7d338 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -9,3 +9,17 @@ updates: directory: / schedule: interval: weekly + - package-ecosystem: pip + directories: + - /example/* + schedule: + interval: weekly + open-pull-requests-limit: 10 + # uptrace and OpenTelemetry pins are bumped on release by scripts/sync_examples.py. + ignore: + - dependency-name: uptrace + - dependency-name: opentelemetry-* + groups: + examples: + patterns: + - '*' diff --git a/.github/workflows/examples.yml b/.github/workflows/examples.yml new file mode 100644 index 0000000..92fe282 --- /dev/null +++ b/.github/workflows/examples.yml @@ -0,0 +1,71 @@ +name: examples + +on: + push: + branches: [master] + pull_request: + branches: [master] + schedule: + # Weekly, to catch breakage from new releases of unpinned dependencies. + - cron: '0 6 * * 1' + workflow_dispatch: + +jobs: + sync: + name: versions in sync + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-python@v7 + with: + python-version: '3.12' + - run: python scripts/sync_examples.py --check + + list: + name: list examples + runs-on: ubuntu-latest + outputs: + examples: ${{ steps.list.outputs.examples }} + steps: + - uses: actions/checkout@v7 + - id: list + run: | + echo "examples=$(python3 scripts/run_example.py --list | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read().split()))')" >> "$GITHUB_OUTPUT" + + run: + name: ${{ matrix.example }} + needs: list + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + example: ${{ fromJSON(needs.list.outputs.examples) }} + + services: + # Only django-postgresql uses it; starting it for every job is cheap. + postgres: + image: postgres:16 + env: + POSTGRES_USER: uptrace + POSTGRES_PASSWORD: uptrace + POSTGRES_DB: uptrace + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 5s + --health-timeout 5s + --health-retries 10 + + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-python@v7 + with: + python-version: '3.12' + + - name: Install dependencies + run: python scripts/run_example.py --install ${{ matrix.example }} + + - name: Run example + run: python scripts/run_example.py ${{ matrix.example }} diff --git a/.gitignore b/.gitignore index a754da6..e07345e 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ __pycache__ *.pyc *.egg-info db.sqlite3 +.venv-examples/ diff --git a/Makefile b/Makefile index 3d9408d..aa857b4 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: all test +.PHONY: all test examples install: pip install -r dev-requirements.txt @@ -14,3 +14,12 @@ publish: test rm -rf build dist python -m build twine upload --skip-existing --verbose dist/* + +# Smoke-test all examples, each in its own virtualenv under .venv-examples/. +examples: + python3 scripts/sync_examples.py --check + for ex in $$(python3 scripts/run_example.py --list); do \ + python3 -m venv .venv-examples/$$ex \ + && .venv-examples/$$ex/bin/python scripts/run_example.py --install $$ex \ + && .venv-examples/$$ex/bin/python scripts/run_example.py $$ex || exit 1; \ + done diff --git a/RELEASING.md b/RELEASING.md index 83873a7..6298727 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -43,7 +43,18 @@ Also check `requires-python` against the upstream packages (e.g. OpenTelemetry 1 Python >= 3.10) and update the classifiers accordingly. Update `requirements.txt` in each `example/` directory to use the new versions (`uptrace`, -`opentelemetry-sdk`, `opentelemetry-exporter-otlp`, and `opentelemetry-instrumentation-*`). +`opentelemetry-sdk`, `opentelemetry-exporter-otlp`, and `opentelemetry-instrumentation-*`): + +```shell +python scripts/sync_examples.py +``` + +CI runs `python scripts/sync_examples.py --check` and smoke-tests every example (see +`.github/workflows/examples.yml`). To run one example locally: + +```shell +python scripts/run_example.py --install flask && python scripts/run_example.py flask +``` Then reinstall dependencies and check that nothing is left on the old versions: diff --git a/scripts/run_example.py b/scripts/run_example.py new file mode 100644 index 0000000..4876d51 --- /dev/null +++ b/scripts/run_example.py @@ -0,0 +1,284 @@ +#!/usr/bin/env python3 +"""Smoke-test an example from example/. + +Usage: + python scripts/run_example.py --list # print example names + python scripts/run_example.py --install NAME # install NAME's dependencies + python scripts/run_example.py NAME [NAME...] # run examples + +Dependencies are installed into the current Python environment. The local +uptrace source is installed instead of the `uptrace` pin from requirements.txt, +so examples are tested against the code in this repo. + +UPTRACE_DSN is not required: without it uptrace is disabled and nothing is sent. +""" + +import os +import re +import signal +import socket +import subprocess +import sys +import tempfile +import time +import urllib.error +import urllib.parse +import urllib.request +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +PY = sys.executable + +# kind: +# script - must exit 0 within the timeout +# daemon - runs forever; must still be alive after a few seconds +# server - must answer HTTP 200 on every URL, then `client` (if any) must exit 0 +EXAMPLES = { + "basic": {"kind": "script", "cmd": [PY, "main.py"]}, + "otel-api": {"kind": "script", "cmd": [PY, "main.py"]}, + "metrics": {"kind": "daemon", "cmd": [PY, "main.py"]}, + "otlp-logs": {"kind": "script", "cmd": [PY, "main.py"]}, + "otlp-metrics": {"kind": "daemon", "cmd": [PY, "main.py"]}, + "otlp-traces": {"kind": "script", "cmd": [PY, "main.py"]}, + "otlp-traces-http": {"kind": "script", "cmd": [PY, "main.py"]}, + "flask": { + "kind": "server", + "cmd": [PY, "main.py"], + "urls": ["http://127.0.0.1:8000/", "http://127.0.0.1:8000/hello/world"], + }, + "flask-auto-instrumentation": { + "kind": "server", + "cmd": ["opentelemetry-instrument", PY, "main.py"], + "urls": ["http://127.0.0.1:8000/", "http://127.0.0.1:8000/hello/world"], + }, + "flask-gunicorn": { + "kind": "server", + "cmd": ["gunicorn", "main", "-c", "gunicorn.config.py"], + "urls": ["http://127.0.0.1:8000/", "http://127.0.0.1:8000/hello/world"], + }, + "flask-uwsgi": { + "kind": "server", + "cmd": [ + "uwsgi", + "--http=127.0.0.1:8000", + "--wsgi-file=main.py", + "--callable=application", + "--master", + "--enable-threads", + "--die-on-term", + ], + "urls": ["http://127.0.0.1:8000/", "http://127.0.0.1:8000/hello/world"], + }, + "fastapi": { + "kind": "server", + "cmd": ["uvicorn", "main:app", "--host", "127.0.0.1", "--port", "8000"], + "urls": ["http://127.0.0.1:8000/", "http://127.0.0.1:8000/items/5?q=x"], + "client": [PY, "client.py"], + }, + "pyramid": { + "kind": "server", + "cmd": [PY, "main.py"], + "urls": ["http://127.0.0.1:6543/", "http://127.0.0.1:6543/hello/world"], + }, + "django": { + "kind": "server", + "setup": [PY, "manage.py", "migrate", "--noinput"], + "cmd": [PY, "manage.py", "runserver", "127.0.0.1:8000", "--noreload"], + "urls": ["http://127.0.0.1:8000/", "http://127.0.0.1:8000/hello/world"], + }, + "django-postgresql": { + "kind": "server", + "setup": [PY, "manage.py", "migrate", "--noinput"], + "cmd": [PY, "manage.py", "runserver", "127.0.0.1:8000", "--noreload"], + "urls": ["http://127.0.0.1:8000/", "http://127.0.0.1:8000/hello/world"], + }, +} + +SCRIPT_TIMEOUT = 60 +DAEMON_ALIVE = 10 +SERVER_READY_TIMEOUT = 60 + + +class Failure(Exception): + pass + + +def example_dir(name): + return ROOT / "example" / name + + +def install(name): + reqs = example_dir(name) / "requirements.txt" + text = reqs.read_text() if reqs.exists() else "" + lines = [l.strip() for l in text.splitlines() if l.strip()[:1] not in ("", "#")] + other = [l for l in lines if not re.match(r"uptrace\b(?!-)", l)] + # Examples without requirements.txt rely on uptrace alone. + uses_uptrace = not reqs.exists() or len(other) < len(lines) + + args = [PY, "-m", "pip", "install"] + if uses_uptrace: + args.append(str(ROOT)) + if other: + with tempfile.NamedTemporaryFile("w", suffix=".txt", delete=False) as f: + f.write("\n".join(other) + "\n") + args += ["-r", f.name] + print("+", " ".join(args), flush=True) + subprocess.run(args, check=True) + + +def start(cmd, cwd, log): + print("+", " ".join(cmd), flush=True) + # Find console scripts (gunicorn, uvicorn, ...) installed next to this Python + # even when its virtualenv is not activated. + env = dict(os.environ) + env["PATH"] = os.pathsep.join([str(Path(PY).parent), env.get("PATH", "")]) + return subprocess.Popen( + cmd, + cwd=cwd, + env=env, + stdout=log, + stderr=subprocess.STDOUT, + stdin=subprocess.DEVNULL, + start_new_session=True, + ) + + +def stop(proc): + if proc.poll() is not None: + return + for sig, wait in ((signal.SIGINT, 10), (signal.SIGKILL, 5)): + try: + os.killpg(proc.pid, sig) + except ProcessLookupError: + return + try: + proc.wait(wait) + return + except subprocess.TimeoutExpired: + pass + + +def run_to_completion(cmd, cwd, log, timeout=SCRIPT_TIMEOUT): + proc = start(cmd, cwd, log) + try: + code = proc.wait(timeout) + except subprocess.TimeoutExpired: + stop(proc) + raise Failure(f"{cmd[-1]} did not exit within {timeout}s") + finally: + stop(proc) + if code != 0: + raise Failure(f"{' '.join(cmd)} exited with code {code}") + + +def http_get(url): + with urllib.request.urlopen(url, timeout=10) as resp: + resp.read() + return resp.status + + +def check_port_free(url): + parts = urllib.parse.urlsplit(url) + with socket.socket() as sock: + if sock.connect_ex((parts.hostname, parts.port)) == 0: + raise Failure( + f"port {parts.port} is already in use; stop whatever is listening there" + ) + + +def wait_ready(proc, url): + deadline = time.monotonic() + SERVER_READY_TIMEOUT + while time.monotonic() < deadline: + if proc.poll() is not None: + raise Failure(f"server exited with code {proc.returncode}") + try: + http_get(url) + return + except (urllib.error.URLError, ConnectionError, OSError): + time.sleep(0.5) + raise Failure(f"server is not ready after {SERVER_READY_TIMEOUT}s") + + +def run(name): + spec = EXAMPLES[name] + cwd = example_dir(name) + env_note = "set" if os.getenv("UPTRACE_DSN") else "unset" + print(f"=== {name} ({spec['kind']}, UPTRACE_DSN {env_note})", flush=True) + + with tempfile.TemporaryFile("w+") as log: + try: + if "setup" in spec: + run_to_completion(spec["setup"], cwd, log) + + if spec["kind"] == "script": + run_to_completion(spec["cmd"], cwd, log) + + elif spec["kind"] == "daemon": + proc = start(spec["cmd"], cwd, log) + try: + time.sleep(DAEMON_ALIVE) + if proc.poll() is not None: + raise Failure(f"exited early with code {proc.returncode}") + finally: + stop(proc) + + elif spec["kind"] == "server": + check_port_free(spec["urls"][0]) + proc = start(spec["cmd"], cwd, log) + try: + wait_ready(proc, spec["urls"][0]) + for url in spec["urls"]: + try: + status = http_get(url) + except urllib.error.HTTPError as exc: + status = exc.code + print(f"GET {url} -> {status}", flush=True) + if status != 200: + raise Failure(f"GET {url} returned {status}") + if "client" in spec: + run_to_completion(spec["client"], cwd, log) + finally: + stop(proc) + + except Failure as exc: + log.seek(0) + print(f"--- output of {name} ---\n{log.read()}--- end ---") + print(f"FAIL {name}: {exc}", flush=True) + return False + + print(f"PASS {name}", flush=True) + return True + + +def main(): + args = sys.argv[1:] + if not args or args == ["--help"]: + print(__doc__) + sys.exit(2) + if args == ["--list"]: + print("\n".join(EXAMPLES)) + return + + do_install = "--install" in args + names = [a for a in args if a != "--install"] + unknown = [n for n in names if n not in EXAMPLES] + if unknown: + sys.exit(f"unknown example(s): {', '.join(unknown)}") + + on_disk = {p.name for p in (ROOT / "example").iterdir() if p.is_dir()} + missing = sorted(on_disk - EXAMPLES.keys()) + if missing: + sys.exit(f"examples without a smoke test in {__file__}: {', '.join(missing)}") + + if do_install: + for name in names: + install(name) + return + + failed = [n for n in names if not run(n)] + if failed: + sys.exit(f"failed: {', '.join(failed)}") + + +if __name__ == "__main__": + main() diff --git a/scripts/sync_examples.py b/scripts/sync_examples.py new file mode 100644 index 0000000..56f0fce --- /dev/null +++ b/scripts/sync_examples.py @@ -0,0 +1,81 @@ +#!/usr/bin/env python3 +"""Sync uptrace/OpenTelemetry pins in example/*/requirements.txt. + +Versions are taken from src/uptrace/version.py and pyproject.toml. + +Usage: + python scripts/sync_examples.py # rewrite requirements.txt files + python scripts/sync_examples.py --check # exit 1 if anything is out of sync +""" + +import difflib +import re +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent + + +def read_versions(): + version_py = (ROOT / "src/uptrace/version.py").read_text() + uptrace = re.search(r'__version__\s*=\s*"([^"]+)"', version_py).group(1) + + pyproject = (ROOT / "pyproject.toml").read_text() + sdk = re.search(r'"opentelemetry-sdk\s*~=\s*([^"]+)"', pyproject).group(1) + contrib = re.search( + r'"opentelemetry-instrumentation\s*~=\s*([^"]+)"', pyproject + ).group(1) + + return uptrace, sdk.strip(), contrib.strip() + + +def rules(uptrace, sdk, contrib): + return [ + (re.compile(r"^(uptrace)==\S+", re.M), uptrace), + (re.compile(r"^(opentelemetry-sdk)==\S+", re.M), sdk), + (re.compile(r"^(opentelemetry-exporter-otlp)==\S+", re.M), sdk), + ( + re.compile(r"^(opentelemetry-instrumentation(?:-[\w-]+)?)==\S+", re.M), + contrib, + ), + ] + + +def main(): + check = "--check" in sys.argv[1:] + uptrace, sdk, contrib = read_versions() + print(f"uptrace={uptrace} opentelemetry={sdk} contrib={contrib}") + + out_of_sync = [] + for path in sorted(ROOT.glob("example/*/requirements.txt")): + old = path.read_text() + new = old + for pattern, version in rules(uptrace, sdk, contrib): + new = pattern.sub(lambda m, v=version: f"{m.group(1)}=={v}", new) + if new == old: + continue + + rel = str(path.relative_to(ROOT)) + out_of_sync.append(rel) + if check: + sys.stdout.writelines( + difflib.unified_diff( + old.splitlines(keepends=True), + new.splitlines(keepends=True), + fromfile=rel, + tofile=rel, + ) + ) + else: + path.write_text(new) + print(f"updated {rel}") + + if check and out_of_sync: + print("\nexamples are out of sync; run: python scripts/sync_examples.py") + sys.exit(1) + if not out_of_sync: + print("examples are in sync") + + +if __name__ == "__main__": + main() From 87d9d1f586cd73e9341506d830108a6934a5370e Mon Sep 17 00:00:00 2001 From: Vladimir Mihailenco Date: Sun, 1 Nov 2026 16:44:25 +0200 Subject: [PATCH 4/4] fix: don't crash in uptrace.force_flush() when Uptrace is disabled; harden example runner - force_flush() had the same no-op proxy issue as shutdown() - run_example.py: report a missing command or a connection error as a failure with output, always kill the whole process group, don't leak the temporary requirements file - examples workflow: read-only token --- .github/workflows/examples.yml | 3 ++ scripts/run_example.py | 61 +++++++++++++++++++--------------- src/uptrace/uptrace.py | 20 ++++++----- test/test_uptrace.py | 1 + 4 files changed, 51 insertions(+), 34 deletions(-) diff --git a/.github/workflows/examples.yml b/.github/workflows/examples.yml index 92fe282..edb91b6 100644 --- a/.github/workflows/examples.yml +++ b/.github/workflows/examples.yml @@ -10,6 +10,9 @@ on: - cron: '0 6 * * 1' workflow_dispatch: +permissions: + contents: read + jobs: sync: name: versions in sync diff --git a/scripts/run_example.py b/scripts/run_example.py index 4876d51..74bc1be 100644 --- a/scripts/run_example.py +++ b/scripts/run_example.py @@ -118,12 +118,13 @@ def install(name): args = [PY, "-m", "pip", "install"] if uses_uptrace: args.append(str(ROOT)) - if other: - with tempfile.NamedTemporaryFile("w", suffix=".txt", delete=False) as f: + with tempfile.NamedTemporaryFile("w", suffix=".txt") as f: + if other: f.write("\n".join(other) + "\n") - args += ["-r", f.name] - print("+", " ".join(args), flush=True) - subprocess.run(args, check=True) + f.flush() + args += ["-r", f.name] + print("+", " ".join(args), flush=True) + subprocess.run(args, check=True) def start(cmd, cwd, log): @@ -132,30 +133,37 @@ def start(cmd, cwd, log): # even when its virtualenv is not activated. env = dict(os.environ) env["PATH"] = os.pathsep.join([str(Path(PY).parent), env.get("PATH", "")]) - return subprocess.Popen( - cmd, - cwd=cwd, - env=env, - stdout=log, - stderr=subprocess.STDOUT, - stdin=subprocess.DEVNULL, - start_new_session=True, - ) + try: + return subprocess.Popen( + cmd, + cwd=cwd, + env=env, + stdout=log, + stderr=subprocess.STDOUT, + stdin=subprocess.DEVNULL, + start_new_session=True, + ) + except OSError as exc: + raise Failure(f"can't start {cmd[0]}: {exc}") from exc def stop(proc): - if proc.poll() is not None: + # Signal the whole process group: the leader may already be gone while its + # children (reloaders, workers) are still running. + try: + os.killpg(proc.pid, signal.SIGINT) + except ProcessLookupError: return - for sig, wait in ((signal.SIGINT, 10), (signal.SIGKILL, 5)): - try: - os.killpg(proc.pid, sig) - except ProcessLookupError: - return - try: - proc.wait(wait) - return - except subprocess.TimeoutExpired: - pass + try: + proc.wait(10) + except subprocess.TimeoutExpired: + pass + # Kill whatever survived SIGINT, e.g. workers of an already exited leader. + try: + os.killpg(proc.pid, signal.SIGKILL) + except ProcessLookupError: + pass + proc.wait() def run_to_completion(cmd, cwd, log, timeout=SCRIPT_TIMEOUT): @@ -163,7 +171,6 @@ def run_to_completion(cmd, cwd, log, timeout=SCRIPT_TIMEOUT): try: code = proc.wait(timeout) except subprocess.TimeoutExpired: - stop(proc) raise Failure(f"{cmd[-1]} did not exit within {timeout}s") finally: stop(proc) @@ -232,6 +239,8 @@ def run(name): status = http_get(url) except urllib.error.HTTPError as exc: status = exc.code + except (urllib.error.URLError, OSError) as exc: + raise Failure(f"GET {url} failed: {exc}") from exc print(f"GET {url} -> {status}", flush=True) if status != 200: raise Failure(f"GET {url} returned {status}") diff --git a/src/uptrace/uptrace.py b/src/uptrace/uptrace.py index 1e0a489..21840e1 100644 --- a/src/uptrace/uptrace.py +++ b/src/uptrace/uptrace.py @@ -75,22 +75,26 @@ def configure_opentelemetry( configure_logs(dsn, resource=resource, level=logging_level) -def shutdown(): - # When Uptrace is disabled (e.g. no DSN), the global providers are no-op - # proxies that don't have shutdown(). - for provider in ( +def _providers(): + return ( trace.get_tracer_provider(), metrics.get_meter_provider(), _logs.get_logger_provider(), - ): + ) + + +def shutdown(): + # When Uptrace is disabled (e.g. no DSN), the global providers are no-op + # proxies that don't have shutdown() or force_flush(). + for provider in _providers(): if hasattr(provider, "shutdown"): provider.shutdown() def force_flush(): - trace.get_tracer_provider().force_flush() - metrics.get_meter_provider().force_flush() - _logs.get_logger_provider().force_flush() + for provider in _providers(): + if hasattr(provider, "force_flush"): + provider.force_flush() def trace_url(span: Optional[trace.Span] = None) -> str: diff --git a/test/test_uptrace.py b/test/test_uptrace.py index 26e3506..d260a07 100644 --- a/test/test_uptrace.py +++ b/test/test_uptrace.py @@ -29,6 +29,7 @@ def test_span_processor_invalid_dsn(caplog): def test_shutdown_when_disabled(): trace._TRACER_PROVIDER = None uptrace.configure_opentelemetry() + uptrace.force_flush() uptrace.shutdown()