From b6437d3984a3ea83dcba1945b773f59f1a54ae30 Mon Sep 17 00:00:00 2001 From: cirilla-zmh Date: Wed, 24 Dec 2025 17:36:12 +0800 Subject: [PATCH 01/16] Add loongsuite python agent distro Change-Id: Ibfdc9acd823041ade4ed9b4e75c37da0ded23b60 Co-developed-by: Cursor --- loongsuite-distro/README.rst | 49 +++ loongsuite-distro/pyproject.toml | 65 +++ loongsuite-distro/src/loongsuite/__init__.py | 15 + .../src/loongsuite/distro/__init__.py | 47 +++ .../src/loongsuite/distro/bootstrap.py | 384 ++++++++++++++++++ .../src/loongsuite/distro/py.typed | 0 .../src/loongsuite/distro/version.py | 17 + pkg-requirements.txt | 3 + scripts/build_loongsuite_package.py | 278 +++++++++++++ scripts/loongsuite-build-config.json | 11 + 10 files changed, 869 insertions(+) create mode 100644 loongsuite-distro/README.rst create mode 100644 loongsuite-distro/pyproject.toml create mode 100644 loongsuite-distro/src/loongsuite/__init__.py create mode 100644 loongsuite-distro/src/loongsuite/distro/__init__.py create mode 100644 loongsuite-distro/src/loongsuite/distro/bootstrap.py create mode 100644 loongsuite-distro/src/loongsuite/distro/py.typed create mode 100644 loongsuite-distro/src/loongsuite/distro/version.py create mode 100644 pkg-requirements.txt create mode 100755 scripts/build_loongsuite_package.py create mode 100644 scripts/loongsuite-build-config.json diff --git a/loongsuite-distro/README.rst b/loongsuite-distro/README.rst new file mode 100644 index 000000000..b5d33f474 --- /dev/null +++ b/loongsuite-distro/README.rst @@ -0,0 +1,49 @@ +LoongSuite Distro +================= + +LoongSuite Python Agent's Distro package, providing LoongSuite-specific configuration and tools. + +Installation +------------ + +:: + + pip install loongsuite-distro + +Features +-------- + +1. **LoongSuite Distro**: Provides LoongSuite-specific OpenTelemetry configuration +2. **LoongSuite Bootstrap**: Install all LoongSuite components from tar package + +Usage +----- + +### Configure LoongSuite Distro + +Specify using LoongSuite Distro via environment variable:: + + export OTEL_PYTHON_DISTRO=loongsuite + +### Use LoongSuite Bootstrap + +Install all components from tar package:: + + loongsuite-bootstrap -t loongsuite-python-agent-1.0.0.tar.gz + +Install from GitHub Releases:: + + loongsuite-bootstrap -v 1.0.0 + +Install latest version:: + + loongsuite-bootstrap --latest + +For more usage, please refer to `LOONGSUITE_BOOTSTRAP_README.md`. + +References +---------- + +* `LoongSuite Python Agent `_ + + diff --git a/loongsuite-distro/pyproject.toml b/loongsuite-distro/pyproject.toml new file mode 100644 index 000000000..23d0e0723 --- /dev/null +++ b/loongsuite-distro/pyproject.toml @@ -0,0 +1,65 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "loongsuite-distro" +dynamic = ["version"] +description = "LoongSuite Python Agent Distro" +readme = "README.rst" +license = "Apache-2.0" +requires-python = ">=3.9" +authors = [ + { name = "LoongSuite Python Agent Authors", email = "qp467389@alibaba-inc.com" }, +] +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "License :: OSI Approved :: Apache Software License", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Typing :: Typed", +] +dependencies = [ + "opentelemetry-api ~= 1.12", + "opentelemetry-instrumentation ~= 0.58b0", + "opentelemetry-sdk ~= 1.13", +] + +[project.optional-dependencies] +otlp = [ + "opentelemetry-exporter-otlp ~= 1.40", +] + +[project.entry-points.opentelemetry_configurator] +loongsuite = "loongsuite.distro:LoongSuiteConfigurator" + +[project.entry-points.opentelemetry_distro] +loongsuite = "loongsuite.distro:LoongSuiteDistro" + +[project.scripts] +loongsuite-bootstrap = "loongsuite.distro.bootstrap:main" +loongsuite-instrument = "opentelemetry.instrumentation.auto_instrumentation:run" + +[project.urls] +Homepage = "https://github.com/alibaba/loongsuite-python-agent" +Repository = "https://github.com/alibaba/loongsuite-python-agent" + +[tool.hatch.version] +path = "src/loongsuite/distro/version.py" + +[tool.hatch.build.targets.sdist] +include = [ + "/src", + "/tests", +] + +[tool.hatch.build.targets.wheel] +packages = ["src/loongsuite"] + + diff --git a/loongsuite-distro/src/loongsuite/__init__.py b/loongsuite-distro/src/loongsuite/__init__.py new file mode 100644 index 000000000..476fb73aa --- /dev/null +++ b/loongsuite-distro/src/loongsuite/__init__.py @@ -0,0 +1,15 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + diff --git a/loongsuite-distro/src/loongsuite/distro/__init__.py b/loongsuite-distro/src/loongsuite/distro/__init__.py new file mode 100644 index 000000000..3b6b4590d --- /dev/null +++ b/loongsuite-distro/src/loongsuite/distro/__init__.py @@ -0,0 +1,47 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import os +from typing import Any + +from opentelemetry.environment_variables import ( + OTEL_LOGS_EXPORTER, + OTEL_METRICS_EXPORTER, + OTEL_TRACES_EXPORTER, +) +from opentelemetry.instrumentation.distro import BaseDistro +from opentelemetry.sdk._configuration import _OTelSDKConfigurator +from opentelemetry.sdk.environment_variables import OTEL_EXPORTER_OTLP_PROTOCOL + + +class LoongSuiteConfigurator(_OTelSDKConfigurator): + """LoongSuite configurator, inherits from OpenTelemetry SDK configurator""" + pass + + +class LoongSuiteDistro(BaseDistro): + """ + LoongSuite Distro configures default OpenTelemetry settings. + + This is the Distro provided by LoongSuite, which configures default exporters and protocols. + """ + + # pylint: disable=no-self-use + def _configure(self, **kwargs: Any) -> None: + os.environ.setdefault(OTEL_TRACES_EXPORTER, "otlp") + os.environ.setdefault(OTEL_METRICS_EXPORTER, "otlp") + os.environ.setdefault(OTEL_LOGS_EXPORTER, "otlp") + os.environ.setdefault(OTEL_EXPORTER_OTLP_PROTOCOL, "grpc") + + diff --git a/loongsuite-distro/src/loongsuite/distro/bootstrap.py b/loongsuite-distro/src/loongsuite/distro/bootstrap.py new file mode 100644 index 000000000..37a8b30b1 --- /dev/null +++ b/loongsuite-distro/src/loongsuite/distro/bootstrap.py @@ -0,0 +1,384 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +LoongSuite Bootstrap Tool + +Install all components of loongsuite Python Agent from tar.gz package. +Supports blacklist/whitelist to control which instrumentations to install. +""" + +import argparse +import json +import logging +import shutil +import subprocess +import sys +import tarfile +import tempfile +import urllib.request +from pathlib import Path +from typing import Optional, Set, List, Tuple + +from packaging.requirements import Requirement + +logger = logging.getLogger(__name__) + +# Base dependency packages (must be installed) +BASE_DEPENDENCIES = { + "opentelemetry-api", + "opentelemetry-sdk", + "opentelemetry-instrumentation", + "opentelemetry-util-genai", + "opentelemetry-semantic-conventions", +} + + +def load_list_file(file_path: Path) -> Set[str]: + """Load list from file (one package name per line)""" + if not file_path.exists(): + return set() + + packages = set() + with open(file_path, "r", encoding="utf-8") as f: + for line in f: + line = line.strip() + if line and not line.startswith("#"): + packages.add(line) + + return packages + + +def get_package_name_from_whl(whl_path: Path) -> str: + """Extract package name from whl filename""" + name = whl_path.stem + parts = name.split("-") + if len(parts) >= 2: + package_parts = [] + for i, part in enumerate(parts): + if any(c.isdigit() for c in part) or part in ("dev", "b0", "b1", "rc0", "rc1"): + break + package_parts.append(part) + return "-".join(package_parts) + return name + + +def download_file(url: str, dest: Path) -> Path: + """Download file to specified path""" + logger.info(f"Downloading file: {url}") + urllib.request.urlretrieve(url, dest) + logger.info(f"Download completed: {dest}") + return dest + + +def extract_tar(tar_path: Path, extract_dir: Path) -> List[Path]: + """Extract tar.gz file, return all whl file paths""" + logger.info(f"Extracting tar file: {tar_path} -> {extract_dir}") + + whl_files = [] + with tarfile.open(tar_path, "r:gz") as tar: + tar.extractall(extract_dir) + + # Find all whl files + for member in tar.getmembers(): + if member.name.endswith(".whl"): + whl_path = extract_dir / member.name + if whl_path.exists(): + whl_files.append(whl_path) + + logger.info(f"Extraction completed, found {len(whl_files)} whl files") + return sorted(whl_files) + + +def filter_packages( + whl_files: List[Path], + blacklist: Optional[Set[str]] = None, + whitelist: Optional[Set[str]] = None, +) -> Tuple[List[Path], List[Path]]: + """ + Filter packages based on blacklist/whitelist + + Returns: + (base dependency packages list, instrumentation packages list) + """ + base_packages = [] + instrumentation_packages = [] + + blacklist = blacklist or set() + whitelist = whitelist or set() + + for whl_file in whl_files: + package_name = get_package_name_from_whl(whl_file) + + # Check blacklist + if blacklist and package_name in blacklist: + logger.debug(f"Skipping package (blacklist): {package_name}") + continue + + # Check whitelist + if whitelist and package_name not in whitelist: + logger.debug(f"Skipping package (not in whitelist): {package_name}") + continue + + # Classify: base dependencies vs instrumentation + if package_name in BASE_DEPENDENCIES: + base_packages.append(whl_file) + else: + instrumentation_packages.append(whl_file) + + return base_packages, instrumentation_packages + + +def install_packages(whl_files: List[Path], find_links_dir: Path, upgrade: bool = False): + """Install packages using pip""" + if not whl_files: + logger.warning("No packages to install") + return + + cmd = [ + sys.executable, + "-m", + "pip", + "install", + "--find-links", + str(find_links_dir), + ] + + if upgrade: + cmd.append("--upgrade") + + # Add all whl files + cmd.extend([str(whl) for whl in whl_files]) + + logger.info(f"Executing install command: {' '.join(cmd)}") + try: + subprocess.run(cmd, check=True) + logger.info("Installation completed") + except subprocess.CalledProcessError as e: + logger.error(f"Installation failed: {e}") + raise + + +def install_from_tar( + tar_path: Path, + blacklist: Optional[Set[str]] = None, + whitelist: Optional[Set[str]] = None, + upgrade: bool = False, + keep_temp: bool = False, +): + """ + Install loongsuite packages from tar package + + Args: + tar_path: tar file path or URL (can be Path or str) + blacklist: blacklist (do not install these packages) + whitelist: whitelist (only install these packages if specified) + upgrade: whether to upgrade already installed packages + keep_temp: whether to keep temporary directory + """ + # If it's a URL, download first + tar_path_str = str(tar_path) + if tar_path_str.startswith(("http://", "https://")): + temp_tar = Path(tempfile.mkdtemp()) / "loongsuite.tar.gz" + download_file(tar_path_str, temp_tar) + tar_path = temp_tar + else: + tar_path = Path(tar_path) + + if not tar_path.exists(): + raise FileNotFoundError(f"Tar file does not exist: {tar_path}") + + # Create temporary directory + temp_dir = Path(tempfile.mkdtemp(prefix="loongsuite-")) + + try: + # Extract tar file + whl_files = extract_tar(tar_path, temp_dir) + + if not whl_files: + raise ValueError("No whl files found in tar file") + + # Filter packages + base_packages, instrumentation_packages = filter_packages( + whl_files, blacklist, whitelist + ) + + # Ensure base dependencies must be installed + if not base_packages: + logger.warning("Warning: No base dependency packages found, this may cause installation to fail") + + # Merge all packages to install + all_packages = base_packages + instrumentation_packages + + logger.info(f"Will install {len(base_packages)} base dependency packages") + logger.info(f"Will install {len(instrumentation_packages)} instrumentation packages") + + # Install + install_packages(all_packages, temp_dir, upgrade) + + finally: + if not keep_temp: + shutil.rmtree(temp_dir, ignore_errors=True) + else: + logger.info(f"Temporary directory kept at: {temp_dir}") + + +def get_latest_release_url(repo: str = "alibaba/loongsuite-python-agent") -> str: + """Get latest release tar.gz URL from GitHub API""" + import urllib.request + import json as json_lib + + api_url = f"https://api.github.com/repos/{repo}/releases/latest" + logger.info(f"Fetching latest release: {api_url}") + + try: + with urllib.request.urlopen(api_url) as response: + data = json_lib.loads(response.read()) + for asset in data.get("assets", []): + if asset["name"].endswith(".tar.gz"): + return asset["browser_download_url"] + + # If no asset found, try to build URL from tag + tag = data.get("tag_name", "").lstrip("v") + return f"https://github.com/{repo}/releases/download/{data.get('tag_name')}/loongsuite-python-agent-{tag}.tar.gz" + except Exception as e: + logger.error(f"Failed to fetch latest release: {e}") + raise + + +def main(): + parser = argparse.ArgumentParser( + description=""" + LoongSuite Bootstrap - Install loongsuite Python Agent from tar package + + This tool installs all loongsuite components from tar.gz file. + Supports blacklist/whitelist to control which instrumentations to install. + """ + ) + + parser.add_argument( + "-t", + "--tar", + type=Path, + help="tar package path or GitHub Releases URL", + ) + parser.add_argument( + "-v", + "--version", + type=str, + help="version number, download from GitHub Releases (e.g., 1.0.0)", + ) + parser.add_argument( + "--latest", + action="store_true", + help="install latest version (from GitHub Releases)", + ) + parser.add_argument( + "--blacklist", + type=Path, + help="blacklist file path (one package name per line, do not install these packages)", + ) + parser.add_argument( + "--whitelist", + type=Path, + help="whitelist file path (one package name per line, only install these packages)", + ) + parser.add_argument( + "--upgrade", + action="store_true", + help="upgrade already installed packages", + ) + parser.add_argument( + "--keep-temp", + action="store_true", + help="keep temporary directory (for debugging)", + ) + parser.add_argument( + "-a", + "--action", + choices=["install", "requirements"], + default="install", + help="action type: install to install packages, requirements to output package list", + ) + + args = parser.parse_args() + + # Determine tar file path + tar_path = None + if args.tar: + tar_path = args.tar + elif args.version: + tar_path = f"https://github.com/alibaba/loongsuite-python-agent/releases/download/v{args.version}/loongsuite-python-agent-{args.version}.tar.gz" + elif args.latest: + tar_path = get_latest_release_url() + else: + parser.error("Must specify one of --tar, --version, or --latest") + + # Load blacklist/whitelist + blacklist = load_list_file(args.blacklist) if args.blacklist else None + whitelist = load_list_file(args.whitelist) if args.whitelist else None + + if blacklist: + logger.info(f"Blacklist: {len(blacklist)} packages") + if whitelist: + logger.info(f"Whitelist: {len(whitelist)} packages") + + if args.action == "requirements": + # Output package list + tar_path_str = str(tar_path) + if tar_path_str.startswith(("http://", "https://")): + temp_tar = Path(tempfile.mkdtemp()) / "loongsuite.tar.gz" + download_file(tar_path_str, temp_tar) + tar_path = temp_tar + else: + tar_path = Path(tar_path) + + temp_dir = Path(tempfile.mkdtemp(prefix="loongsuite-")) + try: + whl_files = extract_tar(tar_path, temp_dir) + base_packages, instrumentation_packages = filter_packages( + whl_files, blacklist, whitelist + ) + + print("# LoongSuite Python Agent Package List") + print("# Base dependency packages (must be installed):") + for whl in base_packages: + package_name = get_package_name_from_whl(whl) + print(f"{package_name}") + + print("\n# Instrumentation packages:") + for whl in instrumentation_packages: + package_name = get_package_name_from_whl(whl) + print(f"{package_name}") + finally: + shutil.rmtree(temp_dir, ignore_errors=True) + else: + # Install + install_from_tar( + tar_path, + blacklist=blacklist, + whitelist=whitelist, + upgrade=args.upgrade, + keep_temp=args.keep_temp, + ) + + +if __name__ == "__main__": + logging.basicConfig( + level=logging.INFO, + format="%(levelname)s: %(message)s", + ) + main() + + diff --git a/loongsuite-distro/src/loongsuite/distro/py.typed b/loongsuite-distro/src/loongsuite/distro/py.typed new file mode 100644 index 000000000..e69de29bb diff --git a/loongsuite-distro/src/loongsuite/distro/version.py b/loongsuite-distro/src/loongsuite/distro/version.py new file mode 100644 index 000000000..51848a23d --- /dev/null +++ b/loongsuite-distro/src/loongsuite/distro/version.py @@ -0,0 +1,17 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +__version__ = "0.1.0.dev" + + diff --git a/pkg-requirements.txt b/pkg-requirements.txt new file mode 100644 index 000000000..67491c71d --- /dev/null +++ b/pkg-requirements.txt @@ -0,0 +1,3 @@ +build>=1.0.0 +setuptools>=65.0.0 +wheel>=0.40.0 diff --git a/scripts/build_loongsuite_package.py b/scripts/build_loongsuite_package.py new file mode 100755 index 000000000..e31d215d4 --- /dev/null +++ b/scripts/build_loongsuite_package.py @@ -0,0 +1,278 @@ +#!/usr/bin/env python3 +""" +Build script: Package all required whl files into tar.gz + +This script will: +1. Build all packages under instrumentation/ +2. Build all packages under instrumentation-genai/ +3. Build all packages under instrumentation-loongsuite/ +4. Build util/opentelemetry-util-genai/ +5. Skip duplicate packages according to config file +6. Package all whl files into tar.gz +""" + +import argparse +import json +import logging +import subprocess +import sys +import tarfile +from pathlib import Path +from typing import Set, List + +logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s") +logger = logging.getLogger(__name__) + + +def load_skip_config(config_path: Path) -> Set[str]: + """Load package names to skip from config file""" + if not config_path.exists(): + logger.warning(f"Config file {config_path} does not exist, using default config") + return set() + + with open(config_path, "r", encoding="utf-8") as f: + config = json.load(f) + + skip_packages = set(config.get("skip_packages", [])) + logger.info(f"Loaded {len(skip_packages)} packages to skip from config file: {skip_packages}") + return skip_packages + + +def get_package_name_from_whl(whl_path: Path) -> str: + """Extract package name from whl filename""" + # Format: package_name-version-py3-none-any.whl + # Or: package_name-version-cp39-cp39-linux_x86_64.whl + name = whl_path.stem # Remove .whl + # Find the part before the first number (version number) after the first - + parts = name.split("-") + if len(parts) >= 2: + # Package name is all parts before version number, joined with - + # Example: opentelemetry-instrumentation-langchain-2.0.0b0-py3-none-any + # Package name is: opentelemetry-instrumentation-langchain + # Need to find the position of version number (first part that looks like a version) + package_parts = [] + for part in parts: + # Version numbers usually contain digits and dots, or contain b0, dev, etc. + if any(c.isdigit() for c in part) or part in ("dev", "b0", "b1", "rc0", "rc1"): + break + package_parts.append(part) + return "-".join(package_parts) + return name + + +def build_package(package_dir: Path, dist_dir: Path, existing_whl_files: Set[Path]) -> List[Path]: + """Build whl file for a single package""" + pyproject_toml = package_dir / "pyproject.toml" + if not pyproject_toml.exists(): + logger.debug(f"Skipping {package_dir}, no pyproject.toml") + return [] + + logger.info(f"Building package: {package_dir}") + try: + # Record whl files before build + before_whl_files = set(dist_dir.glob("*.whl")) + + result = subprocess.run( + [sys.executable, "-m", "build", "--wheel", "--outdir", str(dist_dir)], + cwd=package_dir, + check=True, + capture_output=True, + text=True, + ) + + # Find newly generated whl files (exist after build but not before) + after_whl_files = set(dist_dir.glob("*.whl")) + new_whl_files = [f for f in after_whl_files - before_whl_files if f.suffix == ".whl"] + + if not new_whl_files: + logger.warning(f"No new whl files found after building {package_dir}") + if result.stdout: + logger.debug(f"stdout: {result.stdout}") + if result.stderr: + logger.debug(f"stderr: {result.stderr}") + + return sorted(new_whl_files) + except subprocess.CalledProcessError as e: + logger.error(f"Failed to build {package_dir}: {e}") + if e.stdout: + logger.error(f"stdout: {e.stdout}") + if e.stderr: + logger.error(f"stderr: {e.stderr}") + return [] + + +def collect_packages( + base_dir: Path, + dist_dir: Path, + skip_packages: Set[str], +) -> List[Path]: + """Collect all packages that need to be built""" + all_whl_files = [] + existing_whl_files = set(dist_dir.glob("*.whl")) + + # 1. Build packages under instrumentation/ + instrumentation_dir = base_dir / "instrumentation" + if instrumentation_dir.exists(): + logger.info("Building packages under instrumentation/...") + for package_dir in sorted(instrumentation_dir.iterdir()): + if package_dir.is_dir() and (package_dir / "pyproject.toml").exists(): + whl_files = build_package(package_dir, dist_dir, existing_whl_files) + all_whl_files.extend(whl_files) + existing_whl_files.update(whl_files) + + # 2. Build packages under instrumentation-genai/ + instrumentation_genai_dir = base_dir / "instrumentation-genai" + if instrumentation_genai_dir.exists(): + logger.info("Building packages under instrumentation-genai/...") + for package_dir in sorted(instrumentation_genai_dir.iterdir()): + if package_dir.is_dir() and (package_dir / "pyproject.toml").exists(): + whl_files = build_package(package_dir, dist_dir, existing_whl_files) + all_whl_files.extend(whl_files) + existing_whl_files.update(whl_files) + + # 3. Build packages under instrumentation-loongsuite/ + instrumentation_loongsuite_dir = base_dir / "instrumentation-loongsuite" + if instrumentation_loongsuite_dir.exists(): + logger.info("Building packages under instrumentation-loongsuite/...") + for package_dir in sorted(instrumentation_loongsuite_dir.iterdir()): + if package_dir.is_dir() and (package_dir / "pyproject.toml").exists(): + whl_files = build_package(package_dir, dist_dir, existing_whl_files) + all_whl_files.extend(whl_files) + existing_whl_files.update(whl_files) + + # 4. Build util/opentelemetry-util-genai/ + util_genai_dir = base_dir / "util" / "opentelemetry-util-genai" + if util_genai_dir.exists() and (util_genai_dir / "pyproject.toml").exists(): + logger.info("Building util/opentelemetry-util-genai/...") + whl_files = build_package(util_genai_dir, dist_dir, existing_whl_files) + all_whl_files.extend(whl_files) + existing_whl_files.update(whl_files) + + # 5. Build loongsuite-distro/ + loongsuite_distro_dir = base_dir / "loongsuite-distro" + if loongsuite_distro_dir.exists() and (loongsuite_distro_dir / "pyproject.toml").exists(): + logger.info("Building loongsuite-distro/...") + whl_files = build_package(loongsuite_distro_dir, dist_dir, existing_whl_files) + all_whl_files.extend(whl_files) + existing_whl_files.update(whl_files) + + # 6. Filter out packages that need to be skipped + filtered_whl_files = [] + skipped_count = 0 + seen_packages = {} # Used to detect duplicate packages + + for whl_file in all_whl_files: + package_name = get_package_name_from_whl(whl_file) + + # Check if in skip list + if package_name in skip_packages: + logger.info(f"Skipping package: {package_name} (according to config file)") + skipped_count += 1 + # Delete skipped whl file + whl_file.unlink() + continue + + # Check for duplicate packages (same package may have multiple whl files, e.g., different platforms) + if package_name in seen_packages: + # Keep the newest file + existing_file = seen_packages[package_name] + if whl_file.stat().st_mtime > existing_file.stat().st_mtime: + logger.debug(f"Replacing duplicate package {package_name}: {existing_file.name} -> {whl_file.name}") + existing_file.unlink() + seen_packages[package_name] = whl_file + filtered_whl_files.remove(existing_file) + filtered_whl_files.append(whl_file) + else: + logger.debug(f"Skipping older version {package_name}: {whl_file.name}") + whl_file.unlink() + else: + seen_packages[package_name] = whl_file + filtered_whl_files.append(whl_file) + + logger.info(f"Built {len(all_whl_files)} whl files in total") + logger.info(f"Skipped {skipped_count} packages") + logger.info(f"Final package contains {len(filtered_whl_files)} whl files") + + return filtered_whl_files + + +def create_tar_archive(whl_files: List[Path], output_path: Path): + """Package all whl files into tar.gz""" + logger.info(f"Creating tar archive: {output_path}") + + with tarfile.open(output_path, "w:gz") as tar: + for whl_file in sorted(whl_files): + # Only save filename, not path + tar.add(whl_file, arcname=whl_file.name) + logger.debug(f"Added to archive: {whl_file.name}") + + logger.info(f"Successfully created archive: {output_path} ({output_path.stat().st_size / 1024 / 1024:.2f} MB)") + + +def main(): + parser = argparse.ArgumentParser( + description="Build loongsuite Python Agent release package" + ) + parser.add_argument( + "--base-dir", + type=Path, + default=Path(__file__).parent.parent, + help="Project root directory (default: script's parent directory)", + ) + parser.add_argument( + "--dist-dir", + type=Path, + default=None, + help="Build output directory (default: base-dir/dist)", + ) + parser.add_argument( + "--config", + type=Path, + default=Path(__file__).parent / "loongsuite-build-config.json", + help="Config file path (default: scripts/loongsuite-build-config.json)", + ) + parser.add_argument( + "--output", + type=Path, + default=None, + help="Output tar.gz file path (default: dist/loongsuite-python-agent-.tar.gz)", + ) + parser.add_argument( + "--version", + type=str, + default="dev", + help="Version number (for output filename)", + ) + + args = parser.parse_args() + + base_dir = args.base_dir.resolve() + dist_dir = args.dist_dir or (base_dir / "dist") + dist_dir.mkdir(parents=True, exist_ok=True) + + # Clean old whl files + logger.info(f"Cleaning old build files: {dist_dir}") + for old_file in dist_dir.glob("*.whl"): + old_file.unlink() + + # Load skip config + skip_packages = load_skip_config(args.config) + + # Collect and build all packages + whl_files = collect_packages(base_dir, dist_dir, skip_packages) + + if not whl_files: + logger.error("No whl files found, build failed") + sys.exit(1) + + # Create tar archive + output_path = args.output or (dist_dir / f"loongsuite-python-agent-{args.version}.tar.gz") + create_tar_archive(whl_files, output_path) + + logger.info("Build completed!") + logger.info(f"Output file: {output_path}") + + +if __name__ == "__main__": + main() + diff --git a/scripts/loongsuite-build-config.json b/scripts/loongsuite-build-config.json new file mode 100644 index 000000000..4b567e738 --- /dev/null +++ b/scripts/loongsuite-build-config.json @@ -0,0 +1,11 @@ +{ + "skip_packages": [ + "opentelemetry-instrumentation-langchain" + ], + "description": "Build configuration file, defines packages to skip (to avoid duplicates)", + "notes": [ + "When there are packages with the same name in instrumentation-genai and instrumentation-loongsuite,", + "prefer the version in instrumentation-loongsuite and skip the version in instrumentation-genai" + ] +} + From 20c6f02c919c2813b74b089fe83da7c6847e9c16 Mon Sep 17 00:00:00 2001 From: cirilla-zmh Date: Wed, 24 Dec 2025 17:36:32 +0800 Subject: [PATCH 02/16] add loongsuite baggage processor Change-Id: I68d8da8ceb65141f1f598ab053c28cb5ecb76ea2 Co-developed-by: Cursor --- .../loongsuite-processor-baggage/LICENSE | 201 ++++++++++++++++++ .../loongsuite-processor-baggage/README.rst | 81 +++++++ .../pyproject.toml | 46 ++++ .../src/loongsuite/__init__.py | 14 ++ .../src/loongsuite/processor/__init__.py | 14 ++ .../loongsuite/processor/baggage/__init__.py | 19 ++ .../loongsuite/processor/baggage/processor.py | 130 +++++++++++ .../loongsuite/processor/baggage/version.py | 16 ++ .../test-requirements.txt | 5 + .../tests/__init__.py | 14 ++ .../tests/test_baggage_processor.py | 172 +++++++++++++++ 11 files changed, 712 insertions(+) create mode 100644 processor/loongsuite-processor-baggage/LICENSE create mode 100644 processor/loongsuite-processor-baggage/README.rst create mode 100644 processor/loongsuite-processor-baggage/pyproject.toml create mode 100644 processor/loongsuite-processor-baggage/src/loongsuite/__init__.py create mode 100644 processor/loongsuite-processor-baggage/src/loongsuite/processor/__init__.py create mode 100644 processor/loongsuite-processor-baggage/src/loongsuite/processor/baggage/__init__.py create mode 100644 processor/loongsuite-processor-baggage/src/loongsuite/processor/baggage/processor.py create mode 100644 processor/loongsuite-processor-baggage/src/loongsuite/processor/baggage/version.py create mode 100644 processor/loongsuite-processor-baggage/test-requirements.txt create mode 100644 processor/loongsuite-processor-baggage/tests/__init__.py create mode 100644 processor/loongsuite-processor-baggage/tests/test_baggage_processor.py diff --git a/processor/loongsuite-processor-baggage/LICENSE b/processor/loongsuite-processor-baggage/LICENSE new file mode 100644 index 000000000..261eeb9e9 --- /dev/null +++ b/processor/loongsuite-processor-baggage/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/processor/loongsuite-processor-baggage/README.rst b/processor/loongsuite-processor-baggage/README.rst new file mode 100644 index 000000000..054fae8fe --- /dev/null +++ b/processor/loongsuite-processor-baggage/README.rst @@ -0,0 +1,81 @@ +LoongSuite Baggage Span Processor +================================== + +The LoongSuite Baggage Span Processor reads entries stored in Baggage +from the parent context and adds the baggage entries' keys and values +to the span as attributes on span start. + +This processor supports: +- Prefix matching: Only process baggage keys that match specified prefixes +- Prefix stripping: Remove specified prefixes from baggage keys before adding to attributes + +Installation +------------ + +:: + + pip install loongsuite-processor-baggage + +Usage +----- + +Add the span processor when configuring the tracer provider. + +Example 1: Match specific prefixes and strip one of them + +:: + + from loongsuite.processor.baggage import LoongSuiteBaggageSpanProcessor + from opentelemetry.sdk.trace import TracerProvider + + tracer_provider = TracerProvider() + tracer_provider.add_span_processor( + LoongSuiteBaggageSpanProcessor( + allowed_prefixes={"traffic.", "app."}, + strip_prefixes={"traffic."} + ) + ) + + # baggage: traffic.hello_key = "value" + # Result: attributes will have hello_key = "value" (traffic. prefix stripped) + + # baggage: app.user_id = "123" + # Result: attributes will have app.user_id = "123" (app. prefix not stripped) + +Example 2: Allow all prefixes but strip specific ones + +:: + + from loongsuite.processor.baggage import LoongSuiteBaggageSpanProcessor + from opentelemetry.sdk.trace import TracerProvider + + tracer_provider = TracerProvider() + tracer_provider.add_span_processor( + LoongSuiteBaggageSpanProcessor( + allowed_prefixes=None, # Allow all + strip_prefixes={"traffic.", "app."} + ) + ) + +Example 3: Only match specific prefixes without stripping + +:: + + from loongsuite.processor.baggage import LoongSuiteBaggageSpanProcessor + from opentelemetry.sdk.trace import TracerProvider + + tracer_provider = TracerProvider() + tracer_provider.add_span_processor( + LoongSuiteBaggageSpanProcessor( + allowed_prefixes={"loongsuite."}, + strip_prefixes=None # No stripping + ) + ) + +⚠ Warning ⚠️ + +Do not put sensitive information in Baggage. + +To repeat: a consequence of adding data to Baggage is that the keys and +values will appear in all outgoing HTTP headers from the application. + diff --git a/processor/loongsuite-processor-baggage/pyproject.toml b/processor/loongsuite-processor-baggage/pyproject.toml new file mode 100644 index 000000000..7f10eb7a9 --- /dev/null +++ b/processor/loongsuite-processor-baggage/pyproject.toml @@ -0,0 +1,46 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "loongsuite-processor-baggage" +dynamic = ["version"] +description = "LoongSuite Baggage Span Processor with prefix matching and stripping" +readme = "README.rst" +license = "Apache-2.0" +requires-python = ">=3.9" +authors = [ + { name = "LoongSuite Python Agent Authors", email = "zmh405877@alibaba-inc.com" }, +] +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "License :: OSI Approved :: Apache Software License", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", +] +dependencies = [ + "opentelemetry-api ~= 1.5", + "opentelemetry-sdk ~= 1.5", +] + +[project.urls] +Homepage = "https://github.com/alibaba/loongsuite-python-agent" +Repository = "https://github.com/alibaba/loongsuite-python-agent" + +[tool.hatch.version] +path = "src/loongsuite/processor/baggage/version.py" + +[tool.hatch.build.targets.sdist] +include = [ + "/src", + "/tests", +] + +[tool.hatch.build.targets.wheel] +packages = ["src/loongsuite"] diff --git a/processor/loongsuite-processor-baggage/src/loongsuite/__init__.py b/processor/loongsuite-processor-baggage/src/loongsuite/__init__.py new file mode 100644 index 000000000..f87ce79b7 --- /dev/null +++ b/processor/loongsuite-processor-baggage/src/loongsuite/__init__.py @@ -0,0 +1,14 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + diff --git a/processor/loongsuite-processor-baggage/src/loongsuite/processor/__init__.py b/processor/loongsuite-processor-baggage/src/loongsuite/processor/__init__.py new file mode 100644 index 000000000..f87ce79b7 --- /dev/null +++ b/processor/loongsuite-processor-baggage/src/loongsuite/processor/__init__.py @@ -0,0 +1,14 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + diff --git a/processor/loongsuite-processor-baggage/src/loongsuite/processor/baggage/__init__.py b/processor/loongsuite-processor-baggage/src/loongsuite/processor/baggage/__init__.py new file mode 100644 index 000000000..0ef820585 --- /dev/null +++ b/processor/loongsuite-processor-baggage/src/loongsuite/processor/baggage/__init__.py @@ -0,0 +1,19 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from .processor import LoongSuiteBaggageSpanProcessor +from .version import __version__ + +__all__ = ["LoongSuiteBaggageSpanProcessor", "__version__"] + diff --git a/processor/loongsuite-processor-baggage/src/loongsuite/processor/baggage/processor.py b/processor/loongsuite-processor-baggage/src/loongsuite/processor/baggage/processor.py new file mode 100644 index 000000000..6668d3a7c --- /dev/null +++ b/processor/loongsuite-processor-baggage/src/loongsuite/processor/baggage/processor.py @@ -0,0 +1,130 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from typing import Optional, Set + +from opentelemetry.baggage import get_all as get_all_baggage +from opentelemetry.context import Context +from opentelemetry.sdk.trace import SpanProcessor +from opentelemetry.trace import Span + + +class LoongSuiteBaggageSpanProcessor(SpanProcessor): + """ + LoongSuite Baggage Span Processor + + Reads Baggage entries from the parent context and adds matching baggage + key-value pairs to span attributes based on configured prefix matching rules. + + Supported features: + 1. Prefix matching: Only process baggage keys that match specified prefixes + 2. Prefix stripping: Remove specified prefixes before writing to attributes + + Example: + # Configure matching prefixes: "traffic.", "app." + # Configure stripping prefix: "traffic." + # baggage: traffic.hello_key = "value" + # Result: attributes will have hello_key = "value" (prefix stripped) + + # baggage: app.user_id = "123" + # Result: attributes will have app.user_id = "123" (app. prefix not stripped) + + ⚠ Warning ⚠️ + + Do not put sensitive information in Baggage. + + To repeat: a consequence of adding data to Baggage is that the keys and + values will appear in all outgoing HTTP headers from the application. + """ + + def __init__( + self, + allowed_prefixes: Optional[Set[str]] = None, + strip_prefixes: Optional[Set[str]] = None, + ) -> None: + """ + Initialize LoongSuite Baggage Span Processor + + Args: + allowed_prefixes: Set of allowed baggage key prefixes. If None or empty, + all baggage keys are allowed. If specified, only keys + matching these prefixes will be processed. + strip_prefixes: Set of prefixes to strip. If a baggage key matches these + prefixes, they will be removed before writing to attributes. + """ + self._allowed_prefixes = allowed_prefixes or set() + self._strip_prefixes = strip_prefixes or set() + + # If allowed_prefixes is empty, allow all prefixes + self._allow_all = len(self._allowed_prefixes) == 0 + + def _should_process_key(self, key: str) -> bool: + """ + Determine whether this baggage key should be processed + + Args: + key: baggage key + + Returns: + True if the key should be processed, False otherwise + """ + if self._allow_all: + return True + + # Check if key matches any of the allowed prefixes + for prefix in self._allowed_prefixes: + if key.startswith(prefix): + return True + + return False + + def _strip_prefix(self, key: str) -> str: + """ + Strip matching prefix from key + + Args: + key: original baggage key + + Returns: + key with prefix stripped + """ + for prefix in self._strip_prefixes: + if key.startswith(prefix): + return key[len(prefix):] + return key + + def on_start( + self, span: "Span", parent_context: Optional[Context] = None + ) -> None: + """ + Called when a span starts, adds matching baggage entries to span attributes + + Args: + span: span to add attributes to + parent_context: parent context used to retrieve baggage + """ + baggage = get_all_baggage(parent_context) + + for key, value in baggage.items(): + # Check if this key should be processed + if not self._should_process_key(key): + continue + + # Strip prefix if needed + attribute_key = self._strip_prefix(key) + + # Add to span attributes + # Baggage values are strings, which are valid AttributeValue + span.set_attribute(attribute_key, value) # type: ignore[arg-type] + diff --git a/processor/loongsuite-processor-baggage/src/loongsuite/processor/baggage/version.py b/processor/loongsuite-processor-baggage/src/loongsuite/processor/baggage/version.py new file mode 100644 index 000000000..909864a1c --- /dev/null +++ b/processor/loongsuite-processor-baggage/src/loongsuite/processor/baggage/version.py @@ -0,0 +1,16 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +__version__ = "0.1.0" + diff --git a/processor/loongsuite-processor-baggage/test-requirements.txt b/processor/loongsuite-processor-baggage/test-requirements.txt new file mode 100644 index 000000000..2fe69045f --- /dev/null +++ b/processor/loongsuite-processor-baggage/test-requirements.txt @@ -0,0 +1,5 @@ +pytest>=7.0.0 +pytest-cov>=4.0.0 +opentelemetry-api>=1.5.0 +opentelemetry-sdk>=1.5.0 + diff --git a/processor/loongsuite-processor-baggage/tests/__init__.py b/processor/loongsuite-processor-baggage/tests/__init__.py new file mode 100644 index 000000000..f87ce79b7 --- /dev/null +++ b/processor/loongsuite-processor-baggage/tests/__init__.py @@ -0,0 +1,14 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + diff --git a/processor/loongsuite-processor-baggage/tests/test_baggage_processor.py b/processor/loongsuite-processor-baggage/tests/test_baggage_processor.py new file mode 100644 index 000000000..cfd740ff5 --- /dev/null +++ b/processor/loongsuite-processor-baggage/tests/test_baggage_processor.py @@ -0,0 +1,172 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import unittest + +from opentelemetry.baggage import get_all as get_all_baggage +from opentelemetry.baggage import set_baggage +from opentelemetry.context import attach, detach +from loongsuite.processor.baggage import LoongSuiteBaggageSpanProcessor +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import SpanProcessor +from opentelemetry.trace import Span, Tracer + + +class LoongSuiteBaggageSpanProcessorTest(unittest.TestCase): + def test_check_the_baggage_processor(self): + self.assertIsInstance( + LoongSuiteBaggageSpanProcessor(), SpanProcessor + ) + + def test_allow_all_prefixes(self): + """Test allowing all prefixes""" + tracer_provider = TracerProvider() + tracer_provider.add_span_processor( + LoongSuiteBaggageSpanProcessor(allowed_prefixes=None) + ) + + tracer = tracer_provider.get_tracer("my-tracer") + ctx = set_baggage("any_key", "any_value") + + with tracer.start_as_current_span(name="test", context=ctx) as span: + self.assertEqual(span._attributes["any_key"], "any_value") + + def test_prefix_matching(self): + """Test prefix matching functionality""" + tracer_provider = TracerProvider() + tracer_provider.add_span_processor( + LoongSuiteBaggageSpanProcessor( + allowed_prefixes={"traffic.", "app."} + ) + ) + + tracer = tracer_provider.get_tracer("my-tracer") + ctx = set_baggage("traffic.hello", "world") + ctx = set_baggage("app.user_id", "123", context=ctx) + ctx = set_baggage("other.key", "value", context=ctx) + + with tracer.start_as_current_span(name="test", context=ctx) as span: + # Matching prefixes should be added + self.assertEqual(span._attributes["traffic.hello"], "world") + self.assertEqual(span._attributes["app.user_id"], "123") + # Non-matching prefixes should not be added + self.assertNotIn("other.key", span._attributes) + + def test_prefix_stripping(self): + """Test prefix stripping functionality""" + tracer_provider = TracerProvider() + tracer_provider.add_span_processor( + LoongSuiteBaggageSpanProcessor( + allowed_prefixes={"traffic.", "app."}, + strip_prefixes={"traffic."} + ) + ) + + tracer = tracer_provider.get_tracer("my-tracer") + ctx = set_baggage("traffic.hello_key", "value") + ctx = set_baggage("app.user_id", "123", context=ctx) + + with tracer.start_as_current_span(name="test", context=ctx) as span: + # traffic. prefix should be stripped + self.assertEqual(span._attributes["hello_key"], "value") + self.assertNotIn("traffic.hello_key", span._attributes) + # app. prefix should not be stripped (not in strip_prefixes) + self.assertEqual(span._attributes["app.user_id"], "123") + + def test_multiple_strip_prefixes(self): + """Test multiple strip prefixes""" + tracer_provider = TracerProvider() + tracer_provider.add_span_processor( + LoongSuiteBaggageSpanProcessor( + allowed_prefixes=None, + strip_prefixes={"traffic.", "app."} + ) + ) + + tracer = tracer_provider.get_tracer("my-tracer") + ctx = set_baggage("traffic.key1", "value1") + ctx = set_baggage("app.key2", "value2", context=ctx) + ctx = set_baggage("other.key3", "value3", context=ctx) + + with tracer.start_as_current_span(name="test", context=ctx) as span: + self.assertEqual(span._attributes["key1"], "value1") + self.assertEqual(span._attributes["key2"], "value2") + self.assertEqual(span._attributes["other.key3"], "value3") + + def test_nested_spans(self): + """Test nested spans""" + tracer_provider = TracerProvider() + tracer_provider.add_span_processor( + LoongSuiteBaggageSpanProcessor( + allowed_prefixes={"traffic."}, + strip_prefixes={"traffic."} + ) + ) + + tracer = tracer_provider.get_tracer("my-tracer") + ctx = set_baggage("traffic.queen", "bee") + + with tracer.start_as_current_span(name="parent", context=ctx) as parent_span: + self.assertEqual(parent_span._attributes["queen"], "bee") + + with tracer.start_as_current_span(name="child", context=ctx) as child_span: + self.assertEqual(child_span._attributes["queen"], "bee") + + def test_context_token(self): + """Test using context token""" + tracer_provider = TracerProvider() + tracer_provider.add_span_processor( + LoongSuiteBaggageSpanProcessor( + allowed_prefixes={"traffic."}, + strip_prefixes={"traffic."} + ) + ) + + tracer = tracer_provider.get_tracer("my-tracer") + token = attach(set_baggage("traffic.bumble", "bee")) + + try: + with tracer.start_as_current_span("parent") as span: + self.assertEqual(span._attributes["bumble"], "bee") + + token2 = attach(set_baggage("traffic.moar", "bee")) + try: + with tracer.start_as_current_span("child") as child_span: + self.assertEqual(child_span._attributes["bumble"], "bee") + self.assertEqual(child_span._attributes["moar"], "bee") + finally: + detach(token2) + finally: + detach(token) + + def test_empty_prefixes(self): + """Test empty prefix sets""" + tracer_provider = TracerProvider() + tracer_provider.add_span_processor( + LoongSuiteBaggageSpanProcessor( + allowed_prefixes=set(), # Empty set, should allow all + strip_prefixes=set() + ) + ) + + tracer = tracer_provider.get_tracer("my-tracer") + ctx = set_baggage("any_key", "any_value") + + with tracer.start_as_current_span(name="test", context=ctx) as span: + self.assertEqual(span._attributes["any_key"], "any_value") + + +if __name__ == "__main__": + unittest.main() + From de5bb0bd69892fa0b555836360d879485bd614f8 Mon Sep 17 00:00:00 2001 From: cirilla-zmh Date: Wed, 24 Dec 2025 19:16:58 +0800 Subject: [PATCH 03/16] Add auto configuration for loongsuite processor baggage Change-Id: I1b330026b61256ad6095467770bd2b2668173899 Co-developed-by: Cursor --- loongsuite-distro/README.rst | 28 ++++ loongsuite-distro/pyproject.toml | 3 + .../src/loongsuite/distro/__init__.py | 145 +++++++++++++++- .../src/loongsuite/distro/bootstrap.py | 121 ++++++++------ .../src/loongsuite/distro/version.py | 2 - .../loongsuite/processor/baggage/__init__.py | 1 - .../loongsuite/processor/baggage/processor.py | 41 +++-- .../loongsuite/processor/baggage/version.py | 1 - .../tests/test_baggage_processor.py | 53 +++--- scripts/build_loongsuite_package.py | 157 ++++++++++++------ 10 files changed, 388 insertions(+), 164 deletions(-) diff --git a/loongsuite-distro/README.rst b/loongsuite-distro/README.rst index b5d33f474..142d2d6fb 100644 --- a/loongsuite-distro/README.rst +++ b/loongsuite-distro/README.rst @@ -10,11 +10,25 @@ Installation pip install loongsuite-distro +Optional dependencies: + +:: + + # Install with baggage processor support + pip install loongsuite-distro[baggage] + + # Install with OTLP exporter support + pip install loongsuite-distro[otlp] + + # Install with both + pip install loongsuite-distro[baggage,otlp] + Features -------- 1. **LoongSuite Distro**: Provides LoongSuite-specific OpenTelemetry configuration 2. **LoongSuite Bootstrap**: Install all LoongSuite components from tar package +3. **Baggage Processor**: Optional baggage span processor with prefix matching and stripping support Usage ----- @@ -39,6 +53,20 @@ Install latest version:: loongsuite-bootstrap --latest +### Configure Baggage Processor + +The baggage processor is automatically loaded if configured via environment variables. +First, install the optional dependency:: + + pip install loongsuite-distro[baggage] + +Then configure via environment variables:: + + export LOONGSUITE_PROCESSOR_BAGGAGE_ALLOWED_PREFIXES="traffic.,app." + export LOONGSUITE_PROCESSOR_BAGGAGE_STRIP_PREFIXES="traffic." + +The processor will only be loaded if ``LOONGSUITE_PROCESSOR_BAGGAGE_ALLOWED_PREFIXES`` is set. + For more usage, please refer to `LOONGSUITE_BOOTSTRAP_README.md`. References diff --git a/loongsuite-distro/pyproject.toml b/loongsuite-distro/pyproject.toml index 23d0e0723..7ea1f9173 100644 --- a/loongsuite-distro/pyproject.toml +++ b/loongsuite-distro/pyproject.toml @@ -35,6 +35,9 @@ dependencies = [ otlp = [ "opentelemetry-exporter-otlp ~= 1.40", ] +baggage = [ + "loongsuite-processor-baggage", +] [project.entry-points.opentelemetry_configurator] loongsuite = "loongsuite.distro:LoongSuiteConfigurator" diff --git a/loongsuite-distro/src/loongsuite/distro/__init__.py b/loongsuite-distro/src/loongsuite/distro/__init__.py index 3b6b4590d..375f38909 100644 --- a/loongsuite-distro/src/loongsuite/distro/__init__.py +++ b/loongsuite-distro/src/loongsuite/distro/__init__.py @@ -12,9 +12,11 @@ # See the License for the specific language governing permissions and # limitations under the License. +import logging import os -from typing import Any +from typing import TYPE_CHECKING, Any, Optional, Set, cast +from opentelemetry import trace from opentelemetry.environment_variables import ( OTEL_LOGS_EXPORTER, OTEL_METRICS_EXPORTER, @@ -23,17 +25,150 @@ from opentelemetry.instrumentation.distro import BaseDistro from opentelemetry.sdk._configuration import _OTelSDKConfigurator from opentelemetry.sdk.environment_variables import OTEL_EXPORTER_OTLP_PROTOCOL +from opentelemetry.sdk.trace import TracerProvider + +if TYPE_CHECKING: + from opentelemetry.sdk.trace import SpanProcessor + +logger = logging.getLogger(__name__) + +# Environment variable names for baggage processor configuration +_LOONGSUITE_PROCESSOR_BAGGAGE_ALLOWED_PREFIXES = ( + "LOONGSUITE_PROCESSOR_BAGGAGE_ALLOWED_PREFIXES" +) +_LOONGSUITE_PROCESSOR_BAGGAGE_STRIP_PREFIXES = ( + "LOONGSUITE_PROCESSOR_BAGGAGE_STRIP_PREFIXES" +) class LoongSuiteConfigurator(_OTelSDKConfigurator): - """LoongSuite configurator, inherits from OpenTelemetry SDK configurator""" - pass + """ + LoongSuite configurator, inherits from OpenTelemetry SDK configurator + + Automatically adds LoongSuiteBaggageSpanProcessor if configured via environment variables. + Only loads the processor if LOONGSUITE_PROCESSOR_BAGGAGE_ALLOWED_PREFIXES is set. + """ + + def _configure(self, **kwargs: Any) -> None: + # Call parent method to complete base initialization + super()._configure(**kwargs) # type: ignore[misc] + + # Get tracer provider + tracer_provider = trace.get_tracer_provider() + + if isinstance(tracer_provider, TracerProvider): + # Get additional processors + additional_processors = self._get_additional_span_processors( + **kwargs + ) + + # Add additional processors + for processor in additional_processors: + tracer_provider.add_span_processor(processor) + + def _get_additional_span_processors( + self, **kwargs: Any + ) -> list["SpanProcessor"]: + """ + Return additional span processors to add to trace provider + + Subclasses can override this method to provide custom processors. + + Supports configuration via environment variables for baggage processor: + - LOONGSUITE_PROCESSOR_BAGGAGE_ALLOWED_PREFIXES: Comma-separated list of prefixes for matching baggage keys + - LOONGSUITE_PROCESSOR_BAGGAGE_STRIP_PREFIXES: Comma-separated list of prefixes to strip from baggage keys + + The baggage processor is only loaded if LOONGSUITE_PROCESSOR_BAGGAGE_ALLOWED_PREFIXES is set. + + Args: + **kwargs: Arguments passed to _configure + + Returns: + List of span processors to add + """ + processors: list["SpanProcessor"] = [] + + # Check if baggage allowed prefixes is configured + allowed_prefixes_str = os.getenv( + _LOONGSUITE_PROCESSOR_BAGGAGE_ALLOWED_PREFIXES + ) + + if allowed_prefixes_str: + # Try to load loongsuite-processor-baggage + try: + # Dynamic import to avoid type checker errors + from loongsuite.processor.baggage import ( # noqa: PLC0415 + LoongSuiteBaggageSpanProcessor, + ) + + # Parse allowed prefixes + allowed_prefixes = self._parse_prefixes(allowed_prefixes_str) + + # Parse strip prefixes + strip_prefixes_str = os.getenv( + _LOONGSUITE_PROCESSOR_BAGGAGE_STRIP_PREFIXES + ) + strip_prefixes = ( + self._parse_prefixes(strip_prefixes_str) + if strip_prefixes_str + else None + ) + + # Create processor + # LoongSuiteBaggageSpanProcessor inherits from SpanProcessor + processor_instance = LoongSuiteBaggageSpanProcessor( # type: ignore[misc] + allowed_prefixes=allowed_prefixes + if allowed_prefixes + else None, + strip_prefixes=strip_prefixes if strip_prefixes else None, + ) + # Type cast since LoongSuiteBaggageSpanProcessor inherits from SpanProcessor + processor = cast("SpanProcessor", processor_instance) + processors.append(processor) + + logger.info( + "Loaded LoongSuiteBaggageSpanProcessor with allowed_prefixes=%s, strip_prefixes=%s", + allowed_prefixes, + strip_prefixes, + ) + except ImportError as e: + logger.warning( + "Failed to import loongsuite.processor.baggage: %s. " + "Baggage processor will not be loaded. " + "Please install loongsuite-processor-baggage package.", + e, + ) + + return processors + + @staticmethod + def _parse_prefixes(prefixes_str: str) -> Optional[Set[str]]: + """ + Parse comma-separated prefix string + + Args: + prefixes_str: Comma-separated prefix string, e.g., "traffic.,app." + + Returns: + Set of prefixes, or None if input is empty + """ + if not prefixes_str or not prefixes_str.strip(): + return None + + # Split and strip whitespace + prefixes = { + prefix.strip() + for prefix in prefixes_str.split(",") + if prefix.strip() + } + + return prefixes if prefixes else None class LoongSuiteDistro(BaseDistro): """ LoongSuite Distro configures default OpenTelemetry settings. - + This is the Distro provided by LoongSuite, which configures default exporters and protocols. """ @@ -43,5 +178,3 @@ def _configure(self, **kwargs: Any) -> None: os.environ.setdefault(OTEL_METRICS_EXPORTER, "otlp") os.environ.setdefault(OTEL_LOGS_EXPORTER, "otlp") os.environ.setdefault(OTEL_EXPORTER_OTLP_PROTOCOL, "grpc") - - diff --git a/loongsuite-distro/src/loongsuite/distro/bootstrap.py b/loongsuite-distro/src/loongsuite/distro/bootstrap.py index 37a8b30b1..e759bd3aa 100644 --- a/loongsuite-distro/src/loongsuite/distro/bootstrap.py +++ b/loongsuite-distro/src/loongsuite/distro/bootstrap.py @@ -20,7 +20,7 @@ """ import argparse -import json +import json as json_lib import logging import shutil import subprocess @@ -29,9 +29,7 @@ import tempfile import urllib.request from pathlib import Path -from typing import Optional, Set, List, Tuple - -from packaging.requirements import Requirement +from typing import List, Optional, Set, Tuple logger = logging.getLogger(__name__) @@ -49,14 +47,14 @@ def load_list_file(file_path: Path) -> Set[str]: """Load list from file (one package name per line)""" if not file_path.exists(): return set() - + packages = set() with open(file_path, "r", encoding="utf-8") as f: for line in f: line = line.strip() if line and not line.startswith("#"): packages.add(line) - + return packages @@ -67,7 +65,13 @@ def get_package_name_from_whl(whl_path: Path) -> str: if len(parts) >= 2: package_parts = [] for i, part in enumerate(parts): - if any(c.isdigit() for c in part) or part in ("dev", "b0", "b1", "rc0", "rc1"): + if any(c.isdigit() for c in part) or part in ( + "dev", + "b0", + "b1", + "rc0", + "rc1", + ): break package_parts.append(part) return "-".join(package_parts) @@ -85,18 +89,18 @@ def download_file(url: str, dest: Path) -> Path: def extract_tar(tar_path: Path, extract_dir: Path) -> List[Path]: """Extract tar.gz file, return all whl file paths""" logger.info(f"Extracting tar file: {tar_path} -> {extract_dir}") - + whl_files = [] with tarfile.open(tar_path, "r:gz") as tar: tar.extractall(extract_dir) - + # Find all whl files for member in tar.getmembers(): if member.name.endswith(".whl"): whl_path = extract_dir / member.name if whl_path.exists(): whl_files.append(whl_path) - + logger.info(f"Extraction completed, found {len(whl_files)} whl files") return sorted(whl_files) @@ -108,44 +112,48 @@ def filter_packages( ) -> Tuple[List[Path], List[Path]]: """ Filter packages based on blacklist/whitelist - + Returns: (base dependency packages list, instrumentation packages list) """ base_packages = [] instrumentation_packages = [] - + blacklist = blacklist or set() whitelist = whitelist or set() - + for whl_file in whl_files: package_name = get_package_name_from_whl(whl_file) - + # Check blacklist if blacklist and package_name in blacklist: logger.debug(f"Skipping package (blacklist): {package_name}") continue - + # Check whitelist if whitelist and package_name not in whitelist: - logger.debug(f"Skipping package (not in whitelist): {package_name}") + logger.debug( + f"Skipping package (not in whitelist): {package_name}" + ) continue - + # Classify: base dependencies vs instrumentation if package_name in BASE_DEPENDENCIES: base_packages.append(whl_file) else: instrumentation_packages.append(whl_file) - + return base_packages, instrumentation_packages -def install_packages(whl_files: List[Path], find_links_dir: Path, upgrade: bool = False): +def install_packages( + whl_files: List[Path], find_links_dir: Path, upgrade: bool = False +): """Install packages using pip""" if not whl_files: logger.warning("No packages to install") return - + cmd = [ sys.executable, "-m", @@ -154,13 +162,13 @@ def install_packages(whl_files: List[Path], find_links_dir: Path, upgrade: bool "--find-links", str(find_links_dir), ] - + if upgrade: cmd.append("--upgrade") - + # Add all whl files cmd.extend([str(whl) for whl in whl_files]) - + logger.info(f"Executing install command: {' '.join(cmd)}") try: subprocess.run(cmd, check=True) @@ -179,7 +187,7 @@ def install_from_tar( ): """ Install loongsuite packages from tar package - + Args: tar_path: tar file path or URL (can be Path or str) blacklist: blacklist (do not install these packages) @@ -195,38 +203,44 @@ def install_from_tar( tar_path = temp_tar else: tar_path = Path(tar_path) - + if not tar_path.exists(): raise FileNotFoundError(f"Tar file does not exist: {tar_path}") - + # Create temporary directory temp_dir = Path(tempfile.mkdtemp(prefix="loongsuite-")) - + try: # Extract tar file whl_files = extract_tar(tar_path, temp_dir) - + if not whl_files: raise ValueError("No whl files found in tar file") - + # Filter packages base_packages, instrumentation_packages = filter_packages( whl_files, blacklist, whitelist ) - + # Ensure base dependencies must be installed if not base_packages: - logger.warning("Warning: No base dependency packages found, this may cause installation to fail") - + logger.warning( + "Warning: No base dependency packages found, this may cause installation to fail" + ) + # Merge all packages to install all_packages = base_packages + instrumentation_packages - - logger.info(f"Will install {len(base_packages)} base dependency packages") - logger.info(f"Will install {len(instrumentation_packages)} instrumentation packages") - + + logger.info( + f"Will install {len(base_packages)} base dependency packages" + ) + logger.info( + f"Will install {len(instrumentation_packages)} instrumentation packages" + ) + # Install install_packages(all_packages, temp_dir, upgrade) - + finally: if not keep_temp: shutil.rmtree(temp_dir, ignore_errors=True) @@ -234,21 +248,20 @@ def install_from_tar( logger.info(f"Temporary directory kept at: {temp_dir}") -def get_latest_release_url(repo: str = "alibaba/loongsuite-python-agent") -> str: +def get_latest_release_url( + repo: str = "alibaba/loongsuite-python-agent", +) -> str: """Get latest release tar.gz URL from GitHub API""" - import urllib.request - import json as json_lib - api_url = f"https://api.github.com/repos/{repo}/releases/latest" logger.info(f"Fetching latest release: {api_url}") - + try: with urllib.request.urlopen(api_url) as response: data = json_lib.loads(response.read()) for asset in data.get("assets", []): if asset["name"].endswith(".tar.gz"): return asset["browser_download_url"] - + # If no asset found, try to build URL from tag tag = data.get("tag_name", "").lstrip("v") return f"https://github.com/{repo}/releases/download/{data.get('tag_name')}/loongsuite-python-agent-{tag}.tar.gz" @@ -261,12 +274,12 @@ def main(): parser = argparse.ArgumentParser( description=""" LoongSuite Bootstrap - Install loongsuite Python Agent from tar package - + This tool installs all loongsuite components from tar.gz file. Supports blacklist/whitelist to control which instrumentations to install. """ ) - + parser.add_argument( "-t", "--tar", @@ -311,9 +324,9 @@ def main(): default="install", help="action type: install to install packages, requirements to output package list", ) - + args = parser.parse_args() - + # Determine tar file path tar_path = None if args.tar: @@ -324,16 +337,16 @@ def main(): tar_path = get_latest_release_url() else: parser.error("Must specify one of --tar, --version, or --latest") - + # Load blacklist/whitelist blacklist = load_list_file(args.blacklist) if args.blacklist else None whitelist = load_list_file(args.whitelist) if args.whitelist else None - + if blacklist: logger.info(f"Blacklist: {len(blacklist)} packages") if whitelist: logger.info(f"Whitelist: {len(whitelist)} packages") - + if args.action == "requirements": # Output package list tar_path_str = str(tar_path) @@ -343,20 +356,20 @@ def main(): tar_path = temp_tar else: tar_path = Path(tar_path) - + temp_dir = Path(tempfile.mkdtemp(prefix="loongsuite-")) try: whl_files = extract_tar(tar_path, temp_dir) base_packages, instrumentation_packages = filter_packages( whl_files, blacklist, whitelist ) - + print("# LoongSuite Python Agent Package List") print("# Base dependency packages (must be installed):") for whl in base_packages: package_name = get_package_name_from_whl(whl) print(f"{package_name}") - + print("\n# Instrumentation packages:") for whl in instrumentation_packages: package_name = get_package_name_from_whl(whl) @@ -380,5 +393,3 @@ def main(): format="%(levelname)s: %(message)s", ) main() - - diff --git a/loongsuite-distro/src/loongsuite/distro/version.py b/loongsuite-distro/src/loongsuite/distro/version.py index 51848a23d..4effd145c 100644 --- a/loongsuite-distro/src/loongsuite/distro/version.py +++ b/loongsuite-distro/src/loongsuite/distro/version.py @@ -13,5 +13,3 @@ # limitations under the License. __version__ = "0.1.0.dev" - - diff --git a/processor/loongsuite-processor-baggage/src/loongsuite/processor/baggage/__init__.py b/processor/loongsuite-processor-baggage/src/loongsuite/processor/baggage/__init__.py index 0ef820585..83844521d 100644 --- a/processor/loongsuite-processor-baggage/src/loongsuite/processor/baggage/__init__.py +++ b/processor/loongsuite-processor-baggage/src/loongsuite/processor/baggage/__init__.py @@ -16,4 +16,3 @@ from .version import __version__ __all__ = ["LoongSuiteBaggageSpanProcessor", "__version__"] - diff --git a/processor/loongsuite-processor-baggage/src/loongsuite/processor/baggage/processor.py b/processor/loongsuite-processor-baggage/src/loongsuite/processor/baggage/processor.py index 6668d3a7c..6221bb941 100644 --- a/processor/loongsuite-processor-baggage/src/loongsuite/processor/baggage/processor.py +++ b/processor/loongsuite-processor-baggage/src/loongsuite/processor/baggage/processor.py @@ -23,27 +23,27 @@ class LoongSuiteBaggageSpanProcessor(SpanProcessor): """ LoongSuite Baggage Span Processor - + Reads Baggage entries from the parent context and adds matching baggage key-value pairs to span attributes based on configured prefix matching rules. - + Supported features: 1. Prefix matching: Only process baggage keys that match specified prefixes 2. Prefix stripping: Remove specified prefixes before writing to attributes - + Example: # Configure matching prefixes: "traffic.", "app." # Configure stripping prefix: "traffic." # baggage: traffic.hello_key = "value" # Result: attributes will have hello_key = "value" (prefix stripped) - + # baggage: app.user_id = "123" # Result: attributes will have app.user_id = "123" (app. prefix not stripped) - + ⚠ Warning ⚠️ - + Do not put sensitive information in Baggage. - + To repeat: a consequence of adding data to Baggage is that the keys and values will appear in all outgoing HTTP headers from the application. """ @@ -55,7 +55,7 @@ def __init__( ) -> None: """ Initialize LoongSuite Baggage Span Processor - + Args: allowed_prefixes: Set of allowed baggage key prefixes. If None or empty, all baggage keys are allowed. If specified, only keys @@ -65,43 +65,43 @@ def __init__( """ self._allowed_prefixes = allowed_prefixes or set() self._strip_prefixes = strip_prefixes or set() - + # If allowed_prefixes is empty, allow all prefixes self._allow_all = len(self._allowed_prefixes) == 0 def _should_process_key(self, key: str) -> bool: """ Determine whether this baggage key should be processed - + Args: key: baggage key - + Returns: True if the key should be processed, False otherwise """ if self._allow_all: return True - + # Check if key matches any of the allowed prefixes for prefix in self._allowed_prefixes: if key.startswith(prefix): return True - + return False def _strip_prefix(self, key: str) -> str: """ Strip matching prefix from key - + Args: key: original baggage key - + Returns: key with prefix stripped """ for prefix in self._strip_prefixes: if key.startswith(prefix): - return key[len(prefix):] + return key[len(prefix) :] return key def on_start( @@ -109,22 +109,21 @@ def on_start( ) -> None: """ Called when a span starts, adds matching baggage entries to span attributes - + Args: span: span to add attributes to parent_context: parent context used to retrieve baggage """ baggage = get_all_baggage(parent_context) - + for key, value in baggage.items(): # Check if this key should be processed if not self._should_process_key(key): continue - + # Strip prefix if needed attribute_key = self._strip_prefix(key) - + # Add to span attributes # Baggage values are strings, which are valid AttributeValue span.set_attribute(attribute_key, value) # type: ignore[arg-type] - diff --git a/processor/loongsuite-processor-baggage/src/loongsuite/processor/baggage/version.py b/processor/loongsuite-processor-baggage/src/loongsuite/processor/baggage/version.py index 909864a1c..5fd301e2e 100644 --- a/processor/loongsuite-processor-baggage/src/loongsuite/processor/baggage/version.py +++ b/processor/loongsuite-processor-baggage/src/loongsuite/processor/baggage/version.py @@ -13,4 +13,3 @@ # limitations under the License. __version__ = "0.1.0" - diff --git a/processor/loongsuite-processor-baggage/tests/test_baggage_processor.py b/processor/loongsuite-processor-baggage/tests/test_baggage_processor.py index cfd740ff5..11225a504 100644 --- a/processor/loongsuite-processor-baggage/tests/test_baggage_processor.py +++ b/processor/loongsuite-processor-baggage/tests/test_baggage_processor.py @@ -14,20 +14,17 @@ import unittest -from opentelemetry.baggage import get_all as get_all_baggage +from loongsuite.processor.baggage import LoongSuiteBaggageSpanProcessor + from opentelemetry.baggage import set_baggage from opentelemetry.context import attach, detach -from loongsuite.processor.baggage import LoongSuiteBaggageSpanProcessor from opentelemetry.sdk.trace import TracerProvider from opentelemetry.sdk.trace.export import SpanProcessor -from opentelemetry.trace import Span, Tracer class LoongSuiteBaggageSpanProcessorTest(unittest.TestCase): def test_check_the_baggage_processor(self): - self.assertIsInstance( - LoongSuiteBaggageSpanProcessor(), SpanProcessor - ) + self.assertIsInstance(LoongSuiteBaggageSpanProcessor(), SpanProcessor) def test_allow_all_prefixes(self): """Test allowing all prefixes""" @@ -38,7 +35,7 @@ def test_allow_all_prefixes(self): tracer = tracer_provider.get_tracer("my-tracer") ctx = set_baggage("any_key", "any_value") - + with tracer.start_as_current_span(name="test", context=ctx) as span: self.assertEqual(span._attributes["any_key"], "any_value") @@ -55,7 +52,7 @@ def test_prefix_matching(self): ctx = set_baggage("traffic.hello", "world") ctx = set_baggage("app.user_id", "123", context=ctx) ctx = set_baggage("other.key", "value", context=ctx) - + with tracer.start_as_current_span(name="test", context=ctx) as span: # Matching prefixes should be added self.assertEqual(span._attributes["traffic.hello"], "world") @@ -69,14 +66,14 @@ def test_prefix_stripping(self): tracer_provider.add_span_processor( LoongSuiteBaggageSpanProcessor( allowed_prefixes={"traffic.", "app."}, - strip_prefixes={"traffic."} + strip_prefixes={"traffic."}, ) ) tracer = tracer_provider.get_tracer("my-tracer") ctx = set_baggage("traffic.hello_key", "value") ctx = set_baggage("app.user_id", "123", context=ctx) - + with tracer.start_as_current_span(name="test", context=ctx) as span: # traffic. prefix should be stripped self.assertEqual(span._attributes["hello_key"], "value") @@ -89,8 +86,7 @@ def test_multiple_strip_prefixes(self): tracer_provider = TracerProvider() tracer_provider.add_span_processor( LoongSuiteBaggageSpanProcessor( - allowed_prefixes=None, - strip_prefixes={"traffic.", "app."} + allowed_prefixes=None, strip_prefixes={"traffic.", "app."} ) ) @@ -98,7 +94,7 @@ def test_multiple_strip_prefixes(self): ctx = set_baggage("traffic.key1", "value1") ctx = set_baggage("app.key2", "value2", context=ctx) ctx = set_baggage("other.key3", "value3", context=ctx) - + with tracer.start_as_current_span(name="test", context=ctx) as span: self.assertEqual(span._attributes["key1"], "value1") self.assertEqual(span._attributes["key2"], "value2") @@ -109,18 +105,21 @@ def test_nested_spans(self): tracer_provider = TracerProvider() tracer_provider.add_span_processor( LoongSuiteBaggageSpanProcessor( - allowed_prefixes={"traffic."}, - strip_prefixes={"traffic."} + allowed_prefixes={"traffic."}, strip_prefixes={"traffic."} ) ) tracer = tracer_provider.get_tracer("my-tracer") ctx = set_baggage("traffic.queen", "bee") - - with tracer.start_as_current_span(name="parent", context=ctx) as parent_span: + + with tracer.start_as_current_span( + name="parent", context=ctx + ) as parent_span: self.assertEqual(parent_span._attributes["queen"], "bee") - - with tracer.start_as_current_span(name="child", context=ctx) as child_span: + + with tracer.start_as_current_span( + name="child", context=ctx + ) as child_span: self.assertEqual(child_span._attributes["queen"], "bee") def test_context_token(self): @@ -128,22 +127,23 @@ def test_context_token(self): tracer_provider = TracerProvider() tracer_provider.add_span_processor( LoongSuiteBaggageSpanProcessor( - allowed_prefixes={"traffic."}, - strip_prefixes={"traffic."} + allowed_prefixes={"traffic."}, strip_prefixes={"traffic."} ) ) tracer = tracer_provider.get_tracer("my-tracer") token = attach(set_baggage("traffic.bumble", "bee")) - + try: with tracer.start_as_current_span("parent") as span: self.assertEqual(span._attributes["bumble"], "bee") - + token2 = attach(set_baggage("traffic.moar", "bee")) try: with tracer.start_as_current_span("child") as child_span: - self.assertEqual(child_span._attributes["bumble"], "bee") + self.assertEqual( + child_span._attributes["bumble"], "bee" + ) self.assertEqual(child_span._attributes["moar"], "bee") finally: detach(token2) @@ -156,17 +156,16 @@ def test_empty_prefixes(self): tracer_provider.add_span_processor( LoongSuiteBaggageSpanProcessor( allowed_prefixes=set(), # Empty set, should allow all - strip_prefixes=set() + strip_prefixes=set(), ) ) tracer = tracer_provider.get_tracer("my-tracer") ctx = set_baggage("any_key", "any_value") - + with tracer.start_as_current_span(name="test", context=ctx) as span: self.assertEqual(span._attributes["any_key"], "any_value") if __name__ == "__main__": unittest.main() - diff --git a/scripts/build_loongsuite_package.py b/scripts/build_loongsuite_package.py index e31d215d4..83172a89e 100755 --- a/scripts/build_loongsuite_package.py +++ b/scripts/build_loongsuite_package.py @@ -18,7 +18,7 @@ import sys import tarfile from pathlib import Path -from typing import Set, List +from typing import List, Set logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s") logger = logging.getLogger(__name__) @@ -27,14 +27,18 @@ def load_skip_config(config_path: Path) -> Set[str]: """Load package names to skip from config file""" if not config_path.exists(): - logger.warning(f"Config file {config_path} does not exist, using default config") + logger.warning( + f"Config file {config_path} does not exist, using default config" + ) return set() - + with open(config_path, "r", encoding="utf-8") as f: config = json.load(f) - + skip_packages = set(config.get("skip_packages", [])) - logger.info(f"Loaded {len(skip_packages)} packages to skip from config file: {skip_packages}") + logger.info( + f"Loaded {len(skip_packages)} packages to skip from config file: {skip_packages}" + ) return skip_packages @@ -53,44 +57,63 @@ def get_package_name_from_whl(whl_path: Path) -> str: package_parts = [] for part in parts: # Version numbers usually contain digits and dots, or contain b0, dev, etc. - if any(c.isdigit() for c in part) or part in ("dev", "b0", "b1", "rc0", "rc1"): + if any(c.isdigit() for c in part) or part in ( + "dev", + "b0", + "b1", + "rc0", + "rc1", + ): break package_parts.append(part) return "-".join(package_parts) return name -def build_package(package_dir: Path, dist_dir: Path, existing_whl_files: Set[Path]) -> List[Path]: +def build_package( + package_dir: Path, dist_dir: Path, existing_whl_files: Set[Path] +) -> List[Path]: """Build whl file for a single package""" pyproject_toml = package_dir / "pyproject.toml" if not pyproject_toml.exists(): logger.debug(f"Skipping {package_dir}, no pyproject.toml") return [] - + logger.info(f"Building package: {package_dir}") try: # Record whl files before build before_whl_files = set(dist_dir.glob("*.whl")) - + result = subprocess.run( - [sys.executable, "-m", "build", "--wheel", "--outdir", str(dist_dir)], + [ + sys.executable, + "-m", + "build", + "--wheel", + "--outdir", + str(dist_dir), + ], cwd=package_dir, check=True, capture_output=True, text=True, ) - + # Find newly generated whl files (exist after build but not before) after_whl_files = set(dist_dir.glob("*.whl")) - new_whl_files = [f for f in after_whl_files - before_whl_files if f.suffix == ".whl"] - + new_whl_files = [ + f for f in after_whl_files - before_whl_files if f.suffix == ".whl" + ] + if not new_whl_files: - logger.warning(f"No new whl files found after building {package_dir}") + logger.warning( + f"No new whl files found after building {package_dir}" + ) if result.stdout: logger.debug(f"stdout: {result.stdout}") if result.stderr: logger.debug(f"stderr: {result.stderr}") - + return sorted(new_whl_files) except subprocess.CalledProcessError as e: logger.error(f"Failed to build {package_dir}: {e}") @@ -109,104 +132,135 @@ def collect_packages( """Collect all packages that need to be built""" all_whl_files = [] existing_whl_files = set(dist_dir.glob("*.whl")) - + # 1. Build packages under instrumentation/ instrumentation_dir = base_dir / "instrumentation" if instrumentation_dir.exists(): logger.info("Building packages under instrumentation/...") for package_dir in sorted(instrumentation_dir.iterdir()): - if package_dir.is_dir() and (package_dir / "pyproject.toml").exists(): - whl_files = build_package(package_dir, dist_dir, existing_whl_files) + if ( + package_dir.is_dir() + and (package_dir / "pyproject.toml").exists() + ): + whl_files = build_package( + package_dir, dist_dir, existing_whl_files + ) all_whl_files.extend(whl_files) existing_whl_files.update(whl_files) - + # 2. Build packages under instrumentation-genai/ instrumentation_genai_dir = base_dir / "instrumentation-genai" if instrumentation_genai_dir.exists(): logger.info("Building packages under instrumentation-genai/...") for package_dir in sorted(instrumentation_genai_dir.iterdir()): - if package_dir.is_dir() and (package_dir / "pyproject.toml").exists(): - whl_files = build_package(package_dir, dist_dir, existing_whl_files) + if ( + package_dir.is_dir() + and (package_dir / "pyproject.toml").exists() + ): + whl_files = build_package( + package_dir, dist_dir, existing_whl_files + ) all_whl_files.extend(whl_files) existing_whl_files.update(whl_files) - + # 3. Build packages under instrumentation-loongsuite/ instrumentation_loongsuite_dir = base_dir / "instrumentation-loongsuite" if instrumentation_loongsuite_dir.exists(): logger.info("Building packages under instrumentation-loongsuite/...") for package_dir in sorted(instrumentation_loongsuite_dir.iterdir()): - if package_dir.is_dir() and (package_dir / "pyproject.toml").exists(): - whl_files = build_package(package_dir, dist_dir, existing_whl_files) + if ( + package_dir.is_dir() + and (package_dir / "pyproject.toml").exists() + ): + whl_files = build_package( + package_dir, dist_dir, existing_whl_files + ) all_whl_files.extend(whl_files) existing_whl_files.update(whl_files) - + # 4. Build util/opentelemetry-util-genai/ util_genai_dir = base_dir / "util" / "opentelemetry-util-genai" - if util_genai_dir.exists() and (util_genai_dir / "pyproject.toml").exists(): + if ( + util_genai_dir.exists() + and (util_genai_dir / "pyproject.toml").exists() + ): logger.info("Building util/opentelemetry-util-genai/...") whl_files = build_package(util_genai_dir, dist_dir, existing_whl_files) all_whl_files.extend(whl_files) existing_whl_files.update(whl_files) - + # 5. Build loongsuite-distro/ loongsuite_distro_dir = base_dir / "loongsuite-distro" - if loongsuite_distro_dir.exists() and (loongsuite_distro_dir / "pyproject.toml").exists(): + if ( + loongsuite_distro_dir.exists() + and (loongsuite_distro_dir / "pyproject.toml").exists() + ): logger.info("Building loongsuite-distro/...") - whl_files = build_package(loongsuite_distro_dir, dist_dir, existing_whl_files) + whl_files = build_package( + loongsuite_distro_dir, dist_dir, existing_whl_files + ) all_whl_files.extend(whl_files) existing_whl_files.update(whl_files) - + # 6. Filter out packages that need to be skipped filtered_whl_files = [] skipped_count = 0 seen_packages = {} # Used to detect duplicate packages - + for whl_file in all_whl_files: package_name = get_package_name_from_whl(whl_file) - + # Check if in skip list if package_name in skip_packages: - logger.info(f"Skipping package: {package_name} (according to config file)") + logger.info( + f"Skipping package: {package_name} (according to config file)" + ) skipped_count += 1 # Delete skipped whl file whl_file.unlink() continue - + # Check for duplicate packages (same package may have multiple whl files, e.g., different platforms) if package_name in seen_packages: # Keep the newest file existing_file = seen_packages[package_name] if whl_file.stat().st_mtime > existing_file.stat().st_mtime: - logger.debug(f"Replacing duplicate package {package_name}: {existing_file.name} -> {whl_file.name}") + logger.debug( + f"Replacing duplicate package {package_name}: {existing_file.name} -> {whl_file.name}" + ) existing_file.unlink() seen_packages[package_name] = whl_file filtered_whl_files.remove(existing_file) filtered_whl_files.append(whl_file) else: - logger.debug(f"Skipping older version {package_name}: {whl_file.name}") + logger.debug( + f"Skipping older version {package_name}: {whl_file.name}" + ) whl_file.unlink() else: seen_packages[package_name] = whl_file filtered_whl_files.append(whl_file) - + logger.info(f"Built {len(all_whl_files)} whl files in total") logger.info(f"Skipped {skipped_count} packages") logger.info(f"Final package contains {len(filtered_whl_files)} whl files") - + return filtered_whl_files def create_tar_archive(whl_files: List[Path], output_path: Path): """Package all whl files into tar.gz""" logger.info(f"Creating tar archive: {output_path}") - + with tarfile.open(output_path, "w:gz") as tar: for whl_file in sorted(whl_files): # Only save filename, not path tar.add(whl_file, arcname=whl_file.name) logger.debug(f"Added to archive: {whl_file.name}") - - logger.info(f"Successfully created archive: {output_path} ({output_path.stat().st_size / 1024 / 1024:.2f} MB)") + + logger.info( + f"Successfully created archive: {output_path} ({output_path.stat().st_size / 1024 / 1024:.2f} MB)" + ) def main(): @@ -243,36 +297,37 @@ def main(): default="dev", help="Version number (for output filename)", ) - + args = parser.parse_args() - + base_dir = args.base_dir.resolve() dist_dir = args.dist_dir or (base_dir / "dist") dist_dir.mkdir(parents=True, exist_ok=True) - + # Clean old whl files logger.info(f"Cleaning old build files: {dist_dir}") for old_file in dist_dir.glob("*.whl"): old_file.unlink() - + # Load skip config skip_packages = load_skip_config(args.config) - + # Collect and build all packages whl_files = collect_packages(base_dir, dist_dir, skip_packages) - + if not whl_files: logger.error("No whl files found, build failed") sys.exit(1) - + # Create tar archive - output_path = args.output or (dist_dir / f"loongsuite-python-agent-{args.version}.tar.gz") + output_path = args.output or ( + dist_dir / f"loongsuite-python-agent-{args.version}.tar.gz" + ) create_tar_archive(whl_files, output_path) - + logger.info("Build completed!") logger.info(f"Output file: {output_path}") if __name__ == "__main__": main() - From 62b7dfbf1f2d61e8d224097c41109058a0d395a1 Mon Sep 17 00:00:00 2001 From: cirilla-zmh Date: Wed, 24 Dec 2025 19:31:54 +0800 Subject: [PATCH 04/16] Fix package script and add change log Change-Id: I5c46dda6c49ac2ea8f0745ab3c0af5c84988712a Co-developed-by: Cursor --- .../loongsuite-processor-baggage/CHANGELOG.md | 18 ++++++++++++++++++ scripts/build_loongsuite_package.py | 19 +++++++++++-------- tox-loongsuite.ini | 10 ++++++++++ 3 files changed, 39 insertions(+), 8 deletions(-) create mode 100644 processor/loongsuite-processor-baggage/CHANGELOG.md diff --git a/processor/loongsuite-processor-baggage/CHANGELOG.md b/processor/loongsuite-processor-baggage/CHANGELOG.md new file mode 100644 index 000000000..8b67f34c5 --- /dev/null +++ b/processor/loongsuite-processor-baggage/CHANGELOG.md @@ -0,0 +1,18 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## Unreleased + +## Version 0.1.0 + +### Added + +- Initial release of LoongSuite Baggage Span Processor +- Support for prefix matching to filter baggage keys +- Support for prefix stripping to remove prefixes from baggage keys before adding to span attributes +- Integration with LoongSuite Configurator via environment variables + diff --git a/scripts/build_loongsuite_package.py b/scripts/build_loongsuite_package.py index 83172a89e..fa53952f3 100755 --- a/scripts/build_loongsuite_package.py +++ b/scripts/build_loongsuite_package.py @@ -7,8 +7,11 @@ 2. Build all packages under instrumentation-genai/ 3. Build all packages under instrumentation-loongsuite/ 4. Build util/opentelemetry-util-genai/ -5. Skip duplicate packages according to config file -6. Package all whl files into tar.gz +5. Build processor/loongsuite-processor-baggage/ +6. Skip duplicate packages according to config file +7. Package all whl files into tar.gz + +Note: loongsuite-distro is not included as it is published separately to PyPI. """ import argparse @@ -189,15 +192,15 @@ def collect_packages( all_whl_files.extend(whl_files) existing_whl_files.update(whl_files) - # 5. Build loongsuite-distro/ - loongsuite_distro_dir = base_dir / "loongsuite-distro" + # 5. Build processor/loongsuite-processor-baggage/ + processor_baggage_dir = base_dir / "processor" / "loongsuite-processor-baggage" if ( - loongsuite_distro_dir.exists() - and (loongsuite_distro_dir / "pyproject.toml").exists() + processor_baggage_dir.exists() + and (processor_baggage_dir / "pyproject.toml").exists() ): - logger.info("Building loongsuite-distro/...") + logger.info("Building processor/loongsuite-processor-baggage/...") whl_files = build_package( - loongsuite_distro_dir, dist_dir, existing_whl_files + processor_baggage_dir, dist_dir, existing_whl_files ) all_whl_files.extend(whl_files) existing_whl_files.update(whl_files) diff --git a/tox-loongsuite.ini b/tox-loongsuite.ini index e2c78c608..1fcacb0c3 100644 --- a/tox-loongsuite.ini +++ b/tox-loongsuite.ini @@ -48,6 +48,10 @@ envlist = py3{10,11,12,13}-test-loongsuite-instrumentation-mem0-{oldest,latest} lint-loongsuite-instrumentation-mem0 + ; loongsuite-processor-baggage + py3{9,10,11,12,13}-test-loongsuite-processor-baggage + lint-loongsuite-processor-baggage + [testenv] test_deps = opentelemetry-api@{env:CORE_REPO}\#egg=opentelemetry-api&subdirectory=opentelemetry-api @@ -95,6 +99,9 @@ deps = loongsuite-mem0-latest: {[testenv]test_deps} loongsuite-mem0-latest: -r {toxinidir}/instrumentation-loongsuite/loongsuite-instrumentation-mem0/test-requirements-latest.txt + + loongsuite-processor-baggage: {[testenv]test_deps} + loongsuite-processor-baggage: -r {toxinidir}/processor/loongsuite-processor-baggage/test-requirements.txt ; FIXME: add coverage testing allowlist_externals = @@ -141,6 +148,9 @@ commands = test-loongsuite-instrumentation-mem0: pytest {toxinidir}/instrumentation-loongsuite/loongsuite-instrumentation-mem0/tests {posargs} lint-loongsuite-instrumentation-mem0: python -m ruff check {toxinidir}/instrumentation-loongsuite/loongsuite-instrumentation-mem0 + test-loongsuite-processor-baggage: pytest {toxinidir}/processor/loongsuite-processor-baggage/tests {posargs} + lint-loongsuite-processor-baggage: python -m ruff check {toxinidir}/processor/loongsuite-processor-baggage + ; TODO: add coverage commands ; coverage: {toxinidir}/scripts/coverage.sh From 2db10f0ba0e9f99ba9b4c73a93eef704eae241ef Mon Sep 17 00:00:00 2001 From: cirilla-zmh Date: Wed, 31 Dec 2025 09:44:07 +0800 Subject: [PATCH 05/16] Fix version check of loongsuite bootstrap Change-Id: If92015531c37a53515e97e86f4512d951a3f1eb6 Co-developed-by: Cursor --- .../src/loongsuite/distro/bootstrap.py | 96 ++++++++++++++++++- 1 file changed, 92 insertions(+), 4 deletions(-) diff --git a/loongsuite-distro/src/loongsuite/distro/bootstrap.py b/loongsuite-distro/src/loongsuite/distro/bootstrap.py index e759bd3aa..76d401717 100644 --- a/loongsuite-distro/src/loongsuite/distro/bootstrap.py +++ b/loongsuite-distro/src/loongsuite/distro/bootstrap.py @@ -28,8 +28,11 @@ import tarfile import tempfile import urllib.request +import zipfile from pathlib import Path -from typing import List, Optional, Set, Tuple +from typing import List, Optional, Set, Tuple, Union + +from packaging.specifiers import SpecifierSet logger = logging.getLogger(__name__) @@ -64,7 +67,7 @@ def get_package_name_from_whl(whl_path: Path) -> str: parts = name.split("-") if len(parts) >= 2: package_parts = [] - for i, part in enumerate(parts): + for part in parts: if any(c.isdigit() for c in part) or part in ( "dev", "b0", @@ -78,6 +81,75 @@ def get_package_name_from_whl(whl_path: Path) -> str: return name +def get_python_requirement_from_whl(whl_path: Path) -> Optional[str]: + """ + Extract Python version requirement from whl file metadata + + Args: + whl_path: Path to whl file + + Returns: + Python version requirement string (e.g., ">=3.10, <=3.13") or None if not found + """ + try: + with zipfile.ZipFile(whl_path, "r") as whl_zip: + # Look for METADATA file in the wheel + metadata_path = None + for name in whl_zip.namelist(): + if name.endswith("/METADATA") or name == "METADATA": + metadata_path = name + break + + if not metadata_path: + return None + + # Read METADATA file + with whl_zip.open(metadata_path) as metadata_file: + for line in metadata_file: + line_str = line.decode("utf-8").strip() + if line_str.startswith("Requires-Python:"): + return line_str.split(":", 1)[1].strip() + except Exception as e: + logger.debug(f"Failed to read Python requirement from {whl_path}: {e}") + + return None + + +def check_python_version_compatibility( + whl_path: Path, current_version: Tuple[int, int] +) -> Tuple[bool, Optional[str]]: + """ + Check if current Python version is compatible with whl file requirements + + Args: + whl_path: Path to whl file + current_version: Current Python version as (major, minor) tuple + + Returns: + (is_compatible, requirement_string) + is_compatible: True if compatible, False otherwise + requirement_string: Python requirement string if found, None otherwise + """ + requirement_str = get_python_requirement_from_whl(whl_path) + + if not requirement_str: + # If no requirement found, assume compatible + return True, None + + try: + # Parse the requirement string + spec = SpecifierSet(requirement_str) + # Convert current version to string format + current_version_str = f"{current_version[0]}.{current_version[1]}" + # Check if current version satisfies the requirement + is_compatible = spec.contains(current_version_str) + return is_compatible, requirement_str + except Exception as e: + logger.debug(f"Failed to parse Python requirement '{requirement_str}': {e}") + # If parsing fails, assume compatible to avoid false positives + return True, requirement_str + + def download_file(url: str, dest: Path) -> Path: """Download file to specified path""" logger.info(f"Downloading file: {url}") @@ -111,7 +183,7 @@ def filter_packages( whitelist: Optional[Set[str]] = None, ) -> Tuple[List[Path], List[Path]]: """ - Filter packages based on blacklist/whitelist + Filter packages based on blacklist/whitelist and Python version compatibility Returns: (base dependency packages list, instrumentation packages list) @@ -121,6 +193,10 @@ def filter_packages( blacklist = blacklist or set() whitelist = whitelist or set() + + # Get current Python version + current_version = (sys.version_info.major, sys.version_info.minor) + current_version_str = f"{current_version[0]}.{current_version[1]}" for whl_file in whl_files: package_name = get_package_name_from_whl(whl_file) @@ -137,6 +213,18 @@ def filter_packages( ) continue + # Check Python version compatibility + is_compatible, requirement_str = check_python_version_compatibility( + whl_file, current_version + ) + + if not is_compatible: + logger.warning( + f"Skipping package (Python version incompatible): {package_name} " + f"(requires Python {requirement_str}, current: {current_version_str})" + ) + continue + # Classify: base dependencies vs instrumentation if package_name in BASE_DEPENDENCIES: base_packages.append(whl_file) @@ -179,7 +267,7 @@ def install_packages( def install_from_tar( - tar_path: Path, + tar_path: Union[Path, str], blacklist: Optional[Set[str]] = None, whitelist: Optional[Set[str]] = None, upgrade: bool = False, From 32a241a89e48b2704c2aa052a05496bc73bfa0ab Mon Sep 17 00:00:00 2001 From: cirilla-zmh Date: Sun, 4 Jan 2026 10:58:39 +0800 Subject: [PATCH 06/16] Modify the version number requirements for the library Change-Id: Ic1ff413b609f15651959bfdb12bf424c758a5ba1 Co-developed-by: Cursor --- .../pyproject.toml | 4 +- .../pyproject.toml | 4 +- .../pyproject.toml | 6 +- .../pyproject.toml | 4 +- .../pyproject.toml | 4 +- .../pyproject.toml | 2 +- .../pyproject.toml | 6 +- loongsuite-distro/pyproject.toml | 2 +- .../src/loongsuite/distro/bootstrap.py | 514 ++++++++++++++---- scripts/build_loongsuite_package.py | 4 +- util/opentelemetry-util-genai/pyproject.toml | 6 +- 11 files changed, 435 insertions(+), 121 deletions(-) diff --git a/instrumentation-loongsuite/loongsuite-instrumentation-agentscope/pyproject.toml b/instrumentation-loongsuite/loongsuite-instrumentation-agentscope/pyproject.toml index 0a63ac5ab..c52a1ebb4 100644 --- a/instrumentation-loongsuite/loongsuite-instrumentation-agentscope/pyproject.toml +++ b/instrumentation-loongsuite/loongsuite-instrumentation-agentscope/pyproject.toml @@ -25,8 +25,8 @@ classifiers = [ ] dependencies = [ "opentelemetry-api ~= 1.37", - "opentelemetry-instrumentation ~= 0.58b0", - "opentelemetry-semantic-conventions ~= 0.58b0", + "opentelemetry-instrumentation >= 0.58b0", + "opentelemetry-semantic-conventions >= 0.58b0", "opentelemetry-util-genai", "wrapt", ] diff --git a/instrumentation-loongsuite/loongsuite-instrumentation-agno/pyproject.toml b/instrumentation-loongsuite/loongsuite-instrumentation-agno/pyproject.toml index c3d8fc16e..16bcf2fb7 100644 --- a/instrumentation-loongsuite/loongsuite-instrumentation-agno/pyproject.toml +++ b/instrumentation-loongsuite/loongsuite-instrumentation-agno/pyproject.toml @@ -26,8 +26,8 @@ classifiers = [ ] dependencies = [ "opentelemetry-api ~= 1.37", - "opentelemetry-instrumentation ~= 0.58b0", - "opentelemetry-semantic-conventions ~= 0.58b0", + "opentelemetry-instrumentation >= 0.58b0", + "opentelemetry-semantic-conventions >= 0.58b0", ] [project.optional-dependencies] diff --git a/instrumentation-loongsuite/loongsuite-instrumentation-dashscope/pyproject.toml b/instrumentation-loongsuite/loongsuite-instrumentation-dashscope/pyproject.toml index 472e829ae..87efc60e0 100644 --- a/instrumentation-loongsuite/loongsuite-instrumentation-dashscope/pyproject.toml +++ b/instrumentation-loongsuite/loongsuite-instrumentation-dashscope/pyproject.toml @@ -26,9 +26,9 @@ classifiers = [ ] dependencies = [ "opentelemetry-api ~= 1.37", - "opentelemetry-instrumentation ~= 0.58b0", - "opentelemetry-semantic-conventions ~= 0.58b0", - "opentelemetry-util-genai ~= 0.2b0", + "opentelemetry-instrumentation >= 0.58b0", + "opentelemetry-semantic-conventions >= 0.58b0", + "opentelemetry-util-genai > 0.2b0", ] [project.optional-dependencies] diff --git a/instrumentation-loongsuite/loongsuite-instrumentation-dify/pyproject.toml b/instrumentation-loongsuite/loongsuite-instrumentation-dify/pyproject.toml index b498abac2..5d492609d 100644 --- a/instrumentation-loongsuite/loongsuite-instrumentation-dify/pyproject.toml +++ b/instrumentation-loongsuite/loongsuite-instrumentation-dify/pyproject.toml @@ -26,8 +26,8 @@ classifiers = [ ] dependencies = [ "opentelemetry-api ~= 1.37", - "opentelemetry-instrumentation ~= 0.58b0", - "opentelemetry-semantic-conventions ~= 0.58b0", + "opentelemetry-instrumentation >= 0.58b0", + "opentelemetry-semantic-conventions >= 0.58b0", ] [project.optional-dependencies] diff --git a/instrumentation-loongsuite/loongsuite-instrumentation-langchain/pyproject.toml b/instrumentation-loongsuite/loongsuite-instrumentation-langchain/pyproject.toml index ea1dafe2d..2853b613f 100644 --- a/instrumentation-loongsuite/loongsuite-instrumentation-langchain/pyproject.toml +++ b/instrumentation-loongsuite/loongsuite-instrumentation-langchain/pyproject.toml @@ -26,8 +26,8 @@ classifiers = [ ] dependencies = [ "opentelemetry-api ~= 1.37", - "opentelemetry-instrumentation ~= 0.58b0", - "opentelemetry-semantic-conventions ~= 0.58b0", + "opentelemetry-instrumentation >= 0.58b0", + "opentelemetry-semantic-conventions >= 0.58b0", ] [project.optional-dependencies] diff --git a/instrumentation-loongsuite/loongsuite-instrumentation-mcp/pyproject.toml b/instrumentation-loongsuite/loongsuite-instrumentation-mcp/pyproject.toml index e1e548b4d..560692f19 100644 --- a/instrumentation-loongsuite/loongsuite-instrumentation-mcp/pyproject.toml +++ b/instrumentation-loongsuite/loongsuite-instrumentation-mcp/pyproject.toml @@ -8,7 +8,7 @@ dynamic = ["version"] description = "OpenTelemetry MCP (Model Context Protocol) instrumentation" readme = "README.md" license = "Apache-2.0" -requires-python = ">=3.10, <=3.13" +requires-python = ">=3.10" authors = [ { name = "LoongSuite Python Agent Authors"}, ] diff --git a/instrumentation-loongsuite/loongsuite-instrumentation-mem0/pyproject.toml b/instrumentation-loongsuite/loongsuite-instrumentation-mem0/pyproject.toml index 0e4a685c7..ff3c03bbe 100644 --- a/instrumentation-loongsuite/loongsuite-instrumentation-mem0/pyproject.toml +++ b/instrumentation-loongsuite/loongsuite-instrumentation-mem0/pyproject.toml @@ -26,9 +26,9 @@ classifiers = [ dependencies = [ "wrapt >=1.17.3", "opentelemetry-api ~=1.37", - "opentelemetry-instrumentation ~=0.58b0", - "opentelemetry-semantic-conventions ~=0.58b0", - "opentelemetry-util-genai ~= 0.2b0", + "opentelemetry-instrumentation >=0.58b0", + "opentelemetry-semantic-conventions >=0.58b0", + "opentelemetry-util-genai >= 0.2b0", ] [project.optional-dependencies] diff --git a/loongsuite-distro/pyproject.toml b/loongsuite-distro/pyproject.toml index 7ea1f9173..0cdc6e522 100644 --- a/loongsuite-distro/pyproject.toml +++ b/loongsuite-distro/pyproject.toml @@ -27,8 +27,8 @@ classifiers = [ ] dependencies = [ "opentelemetry-api ~= 1.12", - "opentelemetry-instrumentation ~= 0.58b0", "opentelemetry-sdk ~= 1.13", + "opentelemetry-instrumentation >= 0.58b0", ] [project.optional-dependencies] diff --git a/loongsuite-distro/src/loongsuite/distro/bootstrap.py b/loongsuite-distro/src/loongsuite/distro/bootstrap.py index 76d401717..b52d64514 100644 --- a/loongsuite-distro/src/loongsuite/distro/bootstrap.py +++ b/loongsuite-distro/src/loongsuite/distro/bootstrap.py @@ -30,8 +30,9 @@ import urllib.request import zipfile from pathlib import Path -from typing import List, Optional, Set, Tuple, Union +from typing import Any, List, Optional, Set, Tuple, Union +from packaging.requirements import Requirement from packaging.specifiers import SpecifierSet logger = logging.getLogger(__name__) @@ -81,15 +82,15 @@ def get_package_name_from_whl(whl_path: Path) -> str: return name -def get_python_requirement_from_whl(whl_path: Path) -> Optional[str]: +def get_metadata_from_whl(whl_path: Path) -> Optional[dict[str, Any]]: """ - Extract Python version requirement from whl file metadata - + Extract metadata from whl file + Args: whl_path: Path to whl file - + Returns: - Python version requirement string (e.g., ">=3.10, <=3.13") or None if not found + Dictionary with metadata fields, or None if not found """ try: with zipfile.ZipFile(whl_path, "r") as whl_zip: @@ -99,43 +100,144 @@ def get_python_requirement_from_whl(whl_path: Path) -> Optional[str]: if name.endswith("/METADATA") or name == "METADATA": metadata_path = name break - + if not metadata_path: return None - + + metadata = {} # Read METADATA file with whl_zip.open(metadata_path) as metadata_file: for line in metadata_file: line_str = line.decode("utf-8").strip() + if not line_str: + continue if line_str.startswith("Requires-Python:"): - return line_str.split(":", 1)[1].strip() + metadata["requires_python"] = line_str.split(":", 1)[1].strip() + elif line_str.startswith("Requires-Dist:"): + if "requires_dist" not in metadata: + metadata["requires_dist"] = [] + metadata["requires_dist"].append(line_str.split(":", 1)[1].strip()) + elif line_str.startswith(" ") or line_str.startswith("\t"): + # Continuation line for multi-line fields + if "requires_dist" in metadata and metadata["requires_dist"]: + metadata["requires_dist"][-1] += " " + line_str.strip() + + return metadata if metadata else None except Exception as e: - logger.debug(f"Failed to read Python requirement from {whl_path}: {e}") - + logger.debug(f"Failed to read metadata from {whl_path}: {e}") + + return None + + +def get_python_requirement_from_whl(whl_path: Path) -> Optional[str]: + """ + Extract Python version requirement from whl file metadata + + Args: + whl_path: Path to whl file + + Returns: + Python version requirement string (e.g., ">=3.10, <=3.13") or None if not found + """ + metadata = get_metadata_from_whl(whl_path) + return metadata.get("requires_python") if metadata else None + + +def get_installed_package_version(package_name: str) -> Optional[str]: + """ + Get installed version of a package + + Args: + package_name: Package name + + Returns: + Installed version string, or None if not installed + """ + cmd = [sys.executable, "-m", "pip", "show", package_name] + try: + result = subprocess.run(cmd, capture_output=True, text=True, check=True) + for line in result.stdout.splitlines(): + if line.startswith("Version:"): + return line.split(":", 1)[1].strip() + except subprocess.CalledProcessError: + # Package not installed + return None return None +def check_dependency_compatibility( + whl_path: Path, skip_version_check: bool = False +) -> Tuple[bool, Optional[str]]: + """ + Check if package dependencies are compatible with installed packages + + Args: + whl_path: Path to whl file + skip_version_check: If True, skip version compatibility check + + Returns: + (is_compatible, conflict_message) + is_compatible: True if compatible, False otherwise + conflict_message: Description of conflict if incompatible, None otherwise + """ + if skip_version_check: + return True, None + + metadata = get_metadata_from_whl(whl_path) + if not metadata or "requires_dist" not in metadata: + return True, None + + # Key packages to check compatibility + key_packages = { + "opentelemetry-instrumentation", + "opentelemetry-semantic-conventions", + } + + conflicts = [] + for req_str in metadata.get("requires_dist", []): + try: + req = Requirement(req_str) + if req.name.lower() in key_packages: + installed_version = get_installed_package_version(req.name) + if installed_version: + # Check if installed version satisfies requirement + if not req.specifier.contains(installed_version): + conflicts.append( + f"{req.name} {installed_version} does not satisfy {req_str}" + ) + except Exception as e: + logger.debug(f"Failed to parse requirement '{req_str}': {e}") + # If parsing fails, assume compatible to avoid false positives + continue + + if conflicts: + conflict_msg = "; ".join(conflicts) + return False, conflict_msg + + return True, None + + def check_python_version_compatibility( whl_path: Path, current_version: Tuple[int, int] ) -> Tuple[bool, Optional[str]]: """ Check if current Python version is compatible with whl file requirements - + Args: whl_path: Path to whl file current_version: Current Python version as (major, minor) tuple - + Returns: (is_compatible, requirement_string) is_compatible: True if compatible, False otherwise requirement_string: Python requirement string if found, None otherwise """ requirement_str = get_python_requirement_from_whl(whl_path) - + if not requirement_str: # If no requirement found, assume compatible return True, None - + try: # Parse the requirement string spec = SpecifierSet(requirement_str) @@ -145,7 +247,9 @@ def check_python_version_compatibility( is_compatible = spec.contains(current_version_str) return is_compatible, requirement_str except Exception as e: - logger.debug(f"Failed to parse Python requirement '{requirement_str}': {e}") + logger.debug( + f"Failed to parse Python requirement '{requirement_str}': {e}" + ) # If parsing fails, assume compatible to avoid false positives return True, requirement_str @@ -181,9 +285,17 @@ def filter_packages( whl_files: List[Path], blacklist: Optional[Set[str]] = None, whitelist: Optional[Set[str]] = None, + skip_version_check: bool = False, ) -> Tuple[List[Path], List[Path]]: """ - Filter packages based on blacklist/whitelist and Python version compatibility + Filter packages based on blacklist/whitelist, Python version compatibility, + and dependency version compatibility + + Args: + whl_files: List of whl file paths + blacklist: blacklist (do not install these packages) + whitelist: whitelist (only install these packages if specified) + skip_version_check: If True, skip dependency version compatibility check Returns: (base dependency packages list, instrumentation packages list) @@ -193,7 +305,7 @@ def filter_packages( blacklist = blacklist or set() whitelist = whitelist or set() - + # Get current Python version current_version = (sys.version_info.major, sys.version_info.minor) current_version_str = f"{current_version[0]}.{current_version[1]}" @@ -217,7 +329,7 @@ def filter_packages( is_compatible, requirement_str = check_python_version_compatibility( whl_file, current_version ) - + if not is_compatible: logger.warning( f"Skipping package (Python version incompatible): {package_name} " @@ -225,6 +337,18 @@ def filter_packages( ) continue + # Check dependency version compatibility + is_dep_compatible, conflict_msg = check_dependency_compatibility( + whl_file, skip_version_check + ) + + if not is_dep_compatible: + logger.warning( + f"Skipping package (dependency version incompatible): {package_name} " + f"({conflict_msg})" + ) + continue + # Classify: base dependencies vs instrumentation if package_name in BASE_DEPENDENCIES: base_packages.append(whl_file) @@ -266,48 +390,183 @@ def install_packages( raise +def get_installed_loongsuite_packages() -> List[str]: + """ + Get list of installed loongsuite and opentelemetry packages to uninstall + + Excludes: + - loongsuite-distro + - opentelemetry-api + - opentelemetry-sdk + - opentelemetry-instrumentation + + Returns: + List of installed package names to uninstall + """ + # Packages to exclude from uninstallation + EXCLUDED_PACKAGES = { + "loongsuite-distro", + "opentelemetry-api", + "opentelemetry-sdk", + "opentelemetry-instrumentation", + } + + cmd = [sys.executable, "-m", "pip", "list", "--format=json"] + try: + result = subprocess.run(cmd, capture_output=True, text=True, check=True) + installed_packages = json_lib.loads(result.stdout) + + # Filter packages to uninstall + packages_to_uninstall = [] + for pkg in installed_packages: + name = pkg.get("name", "") + name_lower = name.lower() + + # Skip excluded packages + if name_lower in EXCLUDED_PACKAGES: + continue + + # Include loongsuite-* packages (except loongsuite-distro) + if name_lower.startswith("loongsuite-"): + packages_to_uninstall.append(name) + # Include opentelemetry-* packages (except opentelemetry-api and opentelemetry-sdk) + elif name_lower.startswith("opentelemetry-"): + packages_to_uninstall.append(name) + + return packages_to_uninstall + except subprocess.CalledProcessError as e: + logger.error(f"Failed to get installed packages: {e}") + raise + except json_lib.JSONDecodeError as e: + logger.error(f"Failed to parse pip list output: {e}") + raise + + +def uninstall_packages(package_names: List[str], yes: bool = False): + """Uninstall packages using pip""" + if not package_names: + logger.warning("No packages to uninstall") + return + + cmd = [ + sys.executable, + "-m", + "pip", + "uninstall", + ] + + if yes: + cmd.append("-y") + + # Add all package names + cmd.extend(package_names) + + logger.info(f"Executing uninstall command: {' '.join(cmd)}") + try: + subprocess.run(cmd, check=True) + logger.info("Uninstallation completed") + except subprocess.CalledProcessError as e: + logger.error(f"Uninstallation failed: {e}") + raise + + +def resolve_tar_path(tar_path: Union[Path, str]) -> Tuple[Path, Optional[Path]]: + """ + Resolve tar path, downloading from URI if necessary + + Args: + tar_path: tar file path or URI (can be Path or str) + + Returns: + (local_tar_path, temp_dir_to_cleanup) + local_tar_path: Path to local tar file + temp_dir_to_cleanup: Path to temporary directory to clean up (None if not downloaded) + """ + tar_path_str = str(tar_path) + if tar_path_str.startswith(("http://", "https://")): + # Download from URI + temp_dir = Path(tempfile.mkdtemp(prefix="loongsuite-download-")) + temp_tar = temp_dir / "loongsuite.tar.gz" + download_file(tar_path_str, temp_tar) + return temp_tar, temp_dir + else: + tar_path = Path(tar_path) + if not tar_path.exists(): + raise FileNotFoundError(f"Tar file does not exist: {tar_path}") + return tar_path, None + + +def get_package_names_from_tar( + tar_path: Path, + blacklist: Optional[Set[str]] = None, + whitelist: Optional[Set[str]] = None, +) -> List[str]: + """ + Extract package names from tar file + + Args: + tar_path: Path to tar file + blacklist: blacklist (do not include these packages) + whitelist: whitelist (only include these packages if specified) + + Returns: + List of package names + """ + temp_dir = Path(tempfile.mkdtemp(prefix="loongsuite-")) + try: + whl_files = extract_tar(tar_path, temp_dir) + if not whl_files: + raise ValueError("No whl files found in tar file") + + base_packages, instrumentation_packages = filter_packages( + whl_files, blacklist, whitelist + ) + + # Get package names + package_names = [] + for whl in base_packages + instrumentation_packages: + package_name = get_package_name_from_whl(whl) + package_names.append(package_name) + + return package_names + finally: + shutil.rmtree(temp_dir, ignore_errors=True) + + def install_from_tar( tar_path: Union[Path, str], blacklist: Optional[Set[str]] = None, whitelist: Optional[Set[str]] = None, upgrade: bool = False, keep_temp: bool = False, + skip_version_check: bool = False, ): """ Install loongsuite packages from tar package Args: - tar_path: tar file path or URL (can be Path or str) + tar_path: tar file path or URI (can be Path or str) blacklist: blacklist (do not install these packages) whitelist: whitelist (only install these packages if specified) upgrade: whether to upgrade already installed packages keep_temp: whether to keep temporary directory """ - # If it's a URL, download first - tar_path_str = str(tar_path) - if tar_path_str.startswith(("http://", "https://")): - temp_tar = Path(tempfile.mkdtemp()) / "loongsuite.tar.gz" - download_file(tar_path_str, temp_tar) - tar_path = temp_tar - else: - tar_path = Path(tar_path) + # Resolve tar path (download from URI if necessary) + local_tar_path, temp_tar_dir = resolve_tar_path(tar_path) - if not tar_path.exists(): - raise FileNotFoundError(f"Tar file does not exist: {tar_path}") - - # Create temporary directory + # Create temporary directory for extraction temp_dir = Path(tempfile.mkdtemp(prefix="loongsuite-")) try: # Extract tar file - whl_files = extract_tar(tar_path, temp_dir) + whl_files = extract_tar(local_tar_path, temp_dir) if not whl_files: raise ValueError("No whl files found in tar file") # Filter packages base_packages, instrumentation_packages = filter_packages( - whl_files, blacklist, whitelist + whl_files, blacklist, whitelist, skip_version_check ) # Ensure base dependencies must be installed @@ -332,8 +591,62 @@ def install_from_tar( finally: if not keep_temp: shutil.rmtree(temp_dir, ignore_errors=True) + if temp_tar_dir and temp_tar_dir.exists(): + shutil.rmtree(temp_tar_dir, ignore_errors=True) else: logger.info(f"Temporary directory kept at: {temp_dir}") + if temp_tar_dir: + logger.info(f"Downloaded tar file kept at: {local_tar_path}") + + +def uninstall_loongsuite_packages( + blacklist: Optional[Set[str]] = None, + whitelist: Optional[Set[str]] = None, + yes: bool = False, +): + """ + Uninstall installed loongsuite packages + + Args: + blacklist: blacklist (do not uninstall these packages) + whitelist: whitelist (only uninstall these packages if specified) + yes: automatically confirm uninstallation + """ + # Get installed loongsuite packages + installed_packages = get_installed_loongsuite_packages() + + if not installed_packages: + logger.warning("No loongsuite packages found installed") + return + + # Apply blacklist/whitelist filters + blacklist = blacklist or set() + whitelist = whitelist or set() + + package_names = [] + for pkg in installed_packages: + # Check blacklist + if blacklist and pkg in blacklist: + logger.debug(f"Skipping package (blacklist): {pkg}") + continue + + # Check whitelist + if whitelist and pkg not in whitelist: + logger.debug(f"Skipping package (not in whitelist): {pkg}") + continue + + package_names.append(pkg) + + if not package_names: + logger.warning("No packages to uninstall after filtering") + return + + logger.info(f"Will uninstall {len(package_names)} packages:") + for name in package_names: + logger.info(f" - {name}") + + # Uninstall + uninstall_packages(package_names, yes) def get_latest_release_url( @@ -361,70 +674,78 @@ def get_latest_release_url( def main(): parser = argparse.ArgumentParser( description=""" - LoongSuite Bootstrap - Install loongsuite Python Agent from tar package + LoongSuite Bootstrap - Install/Uninstall loongsuite Python Agent from tar package - This tool installs all loongsuite components from tar.gz file. - Supports blacklist/whitelist to control which instrumentations to install. + This tool installs or uninstalls all loongsuite components from tar.gz file. + Supports blacklist/whitelist to control which instrumentations to install/uninstall. """ ) parser.add_argument( - "-t", - "--tar", + "-a", + "--action", + choices=["install", "uninstall"], + required=True, + help="action type: install to install packages, uninstall to uninstall packages", + ) + + # Common arguments + parser.add_argument( + "--blacklist", type=Path, - help="tar package path or GitHub Releases URL", + help="blacklist file path (one package name per line, do not install/uninstall these packages)", ) parser.add_argument( + "--whitelist", + type=Path, + help="whitelist file path (one package name per line, only install/uninstall these packages)", + ) + + # Install-specific arguments + install_group = parser.add_argument_group("install options") + install_group.add_argument( + "-t", + "--tar", + type=str, + help="tar package path or URI (required for install action, supports http:// and https://)", + ) + install_group.add_argument( "-v", "--version", type=str, - help="version number, download from GitHub Releases (e.g., 1.0.0)", + help="version number, download from GitHub Releases (e.g., 1.0.0) (for install action)", ) - parser.add_argument( + install_group.add_argument( "--latest", action="store_true", - help="install latest version (from GitHub Releases)", + help="install latest version (from GitHub Releases) (for install action)", ) - parser.add_argument( - "--blacklist", - type=Path, - help="blacklist file path (one package name per line, do not install these packages)", - ) - parser.add_argument( - "--whitelist", - type=Path, - help="whitelist file path (one package name per line, only install these packages)", - ) - parser.add_argument( + install_group.add_argument( "--upgrade", action="store_true", - help="upgrade already installed packages", + help="upgrade already installed packages (for install action)", ) - parser.add_argument( + install_group.add_argument( "--keep-temp", action="store_true", help="keep temporary directory (for debugging)", ) - parser.add_argument( - "-a", - "--action", - choices=["install", "requirements"], - default="install", - help="action type: install to install packages, requirements to output package list", + install_group.add_argument( + "--force", + action="store_true", + help="force installation even if dependency versions are incompatible", ) - args = parser.parse_args() + # Uninstall-specific arguments + uninstall_group = parser.add_argument_group("uninstall options") + uninstall_group.add_argument( + "-y", + "--yes", + action="store_true", + help="automatically confirm uninstallation (for uninstall action)", + ) - # Determine tar file path - tar_path = None - if args.tar: - tar_path = args.tar - elif args.version: - tar_path = f"https://github.com/alibaba/loongsuite-python-agent/releases/download/v{args.version}/loongsuite-python-agent-{args.version}.tar.gz" - elif args.latest: - tar_path = get_latest_release_url() - else: - parser.error("Must specify one of --tar, --version, or --latest") + args = parser.parse_args() # Load blacklist/whitelist blacklist = load_list_file(args.blacklist) if args.blacklist else None @@ -435,36 +756,18 @@ def main(): if whitelist: logger.info(f"Whitelist: {len(whitelist)} packages") - if args.action == "requirements": - # Output package list - tar_path_str = str(tar_path) - if tar_path_str.startswith(("http://", "https://")): - temp_tar = Path(tempfile.mkdtemp()) / "loongsuite.tar.gz" - download_file(tar_path_str, temp_tar) - tar_path = temp_tar + if args.action == "install": + # Determine tar file path + tar_path = None + if args.tar: + tar_path = args.tar + elif args.version: + tar_path = f"https://github.com/alibaba/loongsuite-python-agent/releases/download/v{args.version}/loongsuite-python-agent-{args.version}.tar.gz" + elif args.latest: + tar_path = get_latest_release_url() else: - tar_path = Path(tar_path) - - temp_dir = Path(tempfile.mkdtemp(prefix="loongsuite-")) - try: - whl_files = extract_tar(tar_path, temp_dir) - base_packages, instrumentation_packages = filter_packages( - whl_files, blacklist, whitelist - ) + parser.error("For install action, must specify one of --tar, --version, or --latest") - print("# LoongSuite Python Agent Package List") - print("# Base dependency packages (must be installed):") - for whl in base_packages: - package_name = get_package_name_from_whl(whl) - print(f"{package_name}") - - print("\n# Instrumentation packages:") - for whl in instrumentation_packages: - package_name = get_package_name_from_whl(whl) - print(f"{package_name}") - finally: - shutil.rmtree(temp_dir, ignore_errors=True) - else: # Install install_from_tar( tar_path, @@ -472,6 +775,15 @@ def main(): whitelist=whitelist, upgrade=args.upgrade, keep_temp=args.keep_temp, + skip_version_check=args.force, + ) + + elif args.action == "uninstall": + # Uninstall installed loongsuite packages + uninstall_loongsuite_packages( + blacklist=blacklist, + whitelist=whitelist, + yes=args.yes, ) diff --git a/scripts/build_loongsuite_package.py b/scripts/build_loongsuite_package.py index fa53952f3..c31875105 100755 --- a/scripts/build_loongsuite_package.py +++ b/scripts/build_loongsuite_package.py @@ -193,7 +193,9 @@ def collect_packages( existing_whl_files.update(whl_files) # 5. Build processor/loongsuite-processor-baggage/ - processor_baggage_dir = base_dir / "processor" / "loongsuite-processor-baggage" + processor_baggage_dir = ( + base_dir / "processor" / "loongsuite-processor-baggage" + ) if ( processor_baggage_dir.exists() and (processor_baggage_dir / "pyproject.toml").exists() diff --git a/util/opentelemetry-util-genai/pyproject.toml b/util/opentelemetry-util-genai/pyproject.toml index da0ed9b22..c58951066 100644 --- a/util/opentelemetry-util-genai/pyproject.toml +++ b/util/opentelemetry-util-genai/pyproject.toml @@ -25,9 +25,9 @@ classifiers = [ "Programming Language :: Python :: 3.13", ] dependencies = [ - "opentelemetry-instrumentation ~= 0.58b0", - "opentelemetry-semantic-conventions ~= 0.58b0", - "opentelemetry-api>=1.31.0", + "opentelemetry-instrumentation >= 0.58b0", + "opentelemetry-semantic-conventions >= 0.58b0", + "opentelemetry-api >= 1.31.0", ] [project.entry-points.opentelemetry_genai_completion_hook] From eb82d7c7c4c70147cb5c746f80d7b90f4da7b78f Mon Sep 17 00:00:00 2001 From: cirilla-zmh Date: Sun, 4 Jan 2026 19:35:43 +0800 Subject: [PATCH 07/16] Add generate tasks for loongsuite and update README with new instrumentation details - Introduced a new Tox environment for generating loongsuite-related tasks. - Added scripts for generating bootstrap and README files for instrumentation packages. - Updated instrumentation package versions in README to reflect the latest changes. - Enhanced bootstrap.py to support auto-detection of installed libraries for instrumentation packages. Change-Id: I1a2b3c4d5e6f7g8h9i0j1k2l3m4n5o6p7q8r9s0t Change-Id: Ie083727b756ab8959b588ccb4854459916b8278c Co-developed-by: Cursor --- .../loongsuite_distro-0.1b0-py3-none-any.whl | Bin 0 -> 17314 bytes ...ongsuite_util_genai-0.1b0-py3-none-any.whl | Bin 0 -> 46934 bytes instrumentation-loongsuite/README.md | 5 +- .../src/loongsuite/distro/bootstrap.py | 510 +++++++++++++++--- .../src/loongsuite/distro/bootstrap_gen.py | 86 +++ scripts/generate_loongsuite_bootstrap.py | 231 ++++++++ scripts/generate_loongsuite_readme.py | 95 ++++ tox-loongsuite.ini | 16 + 8 files changed, 871 insertions(+), 72 deletions(-) create mode 100644 dist-pypi/loongsuite_distro-0.1b0-py3-none-any.whl create mode 100644 dist-pypi/loongsuite_util_genai-0.1b0-py3-none-any.whl create mode 100644 loongsuite-distro/src/loongsuite/distro/bootstrap_gen.py create mode 100644 scripts/generate_loongsuite_bootstrap.py create mode 100644 scripts/generate_loongsuite_readme.py diff --git a/dist-pypi/loongsuite_distro-0.1b0-py3-none-any.whl b/dist-pypi/loongsuite_distro-0.1b0-py3-none-any.whl new file mode 100644 index 0000000000000000000000000000000000000000..954cb08f0d076a55482678ca4b321916b7246bef GIT binary patch literal 17314 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 KwHElt)&BuK?dmZA literal 0 HcmV?d00001 diff --git a/dist-pypi/loongsuite_util_genai-0.1b0-py3-none-any.whl b/dist-pypi/loongsuite_util_genai-0.1b0-py3-none-any.whl new file mode 100644 index 0000000000000000000000000000000000000000..7cf3e95fcd13309a292599352d495066cce10c4d GIT binary patch literal 46934 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 EFOm=}DgXcg literal 0 HcmV?d00001 diff --git a/instrumentation-loongsuite/README.md b/instrumentation-loongsuite/README.md index 8327b2d99..7e47239c3 100644 --- a/instrumentation-loongsuite/README.md +++ b/instrumentation-loongsuite/README.md @@ -1,8 +1,9 @@ | Instrumentation | Supported Packages | Metrics support | Semconv status | | --------------- | ------------------ | --------------- | -------------- | -| [loongsuite-instrumentation-agentscope](./loongsuite-instrumentation-agentscope) | agentscope >= 0.1.5.dev0 | No | development -| [loongsuite-instrumentation-agno](./loongsuite-instrumentation-agno) | agno >= 1.5.0 | No | development +| [loongsuite-instrumentation-agentscope](./loongsuite-instrumentation-agentscope) | agentscope >= 1.0.0 | No | development +| [loongsuite-instrumentation-agno](./loongsuite-instrumentation-agno) | agno | No | development +| [loongsuite-instrumentation-dashscope](./loongsuite-instrumentation-dashscope) | dashscope >= 1.0.0 | No | development | [loongsuite-instrumentation-dify](./loongsuite-instrumentation-dify) | dify | No | development | [loongsuite-instrumentation-google-adk](./loongsuite-instrumentation-google-adk) | google-adk >= 0.1.0 | Yes | experimental | [loongsuite-instrumentation-langchain](./loongsuite-instrumentation-langchain) | langchain_core >= 0.1.0 | Yes | development diff --git a/loongsuite-distro/src/loongsuite/distro/bootstrap.py b/loongsuite-distro/src/loongsuite/distro/bootstrap.py index b52d64514..988760f5a 100644 --- a/loongsuite-distro/src/loongsuite/distro/bootstrap.py +++ b/loongsuite-distro/src/loongsuite/distro/bootstrap.py @@ -32,6 +32,10 @@ from pathlib import Path from typing import Any, List, Optional, Set, Tuple, Union +from loongsuite.distro.bootstrap_gen import ( + default_instrumentations as gen_default_instrumentations, +) +from loongsuite.distro.bootstrap_gen import libraries as gen_libraries from packaging.requirements import Requirement from packaging.specifiers import SpecifierSet @@ -63,23 +67,56 @@ def load_list_file(file_path: Path) -> Set[str]: def get_package_name_from_whl(whl_path: Path) -> str: - """Extract package name from whl filename""" - name = whl_path.stem + """ + Extract package name from whl filename + + Wheel filename format: {package_name}-{version}-{python_tag}-{abi_tag}-{platform_tag}.whl + Example: loongsuite_instrumentation_mem0-0.1.0-py3-none-any.whl + + Returns normalized package name with hyphens (e.g., "loongsuite-instrumentation-mem0") + """ + name = whl_path.stem # Remove .whl extension parts = name.split("-") - if len(parts) >= 2: - package_parts = [] - for part in parts: - if any(c.isdigit() for c in part) or part in ( - "dev", - "b0", - "b1", - "rc0", - "rc1", - ): - break - package_parts.append(part) - return "-".join(package_parts) - return name + + if len(parts) < 2: + # If no hyphens, return as-is (shouldn't happen for valid wheels) + return name.replace("_", "-") + + package_parts = [] + for part in parts: + # Check if this part looks like a version number + # Version numbers typically: + # - Start with a digit + # - Contain dots (e.g., "0.1.0", "1.2.3") + # - Or are build tags like "dev", "b0", etc. + # - Or are Python/ABI/platform tags + + # Check for version-like patterns: starts with digit and contains dot, or is a known tag + is_version_like = ( + ( + part and part[0].isdigit() and "." in part + ) # e.g., "0.1.0", "1.2.3" + or part in ("dev", "b0", "b1", "rc0", "rc1") # Build tags + or part.startswith("py") # Python tags: "py3", "py2", "py" + or part in ("none", "any") # ABI/platform tags + ) + + if is_version_like: + break + + package_parts.append(part) + + if not package_parts: + # Fallback: if we couldn't extract, use first part + result = parts[0] if parts else name + else: + # Join with hyphens + result = "-".join(package_parts) + + # Normalize: convert underscores to hyphens for package name consistency + # (wheel filenames may use underscores, but package names use hyphens) + result = result.replace("_", "-") + return result def get_metadata_from_whl(whl_path: Path) -> Optional[dict[str, Any]]: @@ -105,26 +142,50 @@ def get_metadata_from_whl(whl_path: Path) -> Optional[dict[str, Any]]: return None metadata = {} + current_field = None # Read METADATA file with whl_zip.open(metadata_path) as metadata_file: for line in metadata_file: line_str = line.decode("utf-8").strip() if not line_str: + current_field = None + continue + + # Check for continuation line + if line_str.startswith(" ") or line_str.startswith("\t"): + if current_field and current_field in metadata: + if isinstance(metadata[current_field], list): + if metadata[current_field]: + metadata[current_field][-1] += ( + " " + line_str.strip() + ) + else: + metadata[current_field] += ( + " " + line_str.strip() + ) continue - if line_str.startswith("Requires-Python:"): - metadata["requires_python"] = line_str.split(":", 1)[1].strip() - elif line_str.startswith("Requires-Dist:"): - if "requires_dist" not in metadata: - metadata["requires_dist"] = [] - metadata["requires_dist"].append(line_str.split(":", 1)[1].strip()) - elif line_str.startswith(" ") or line_str.startswith("\t"): - # Continuation line for multi-line fields - if "requires_dist" in metadata and metadata["requires_dist"]: - metadata["requires_dist"][-1] += " " + line_str.strip() + + # Parse field name and value + if ":" in line_str: + field_name, field_value = line_str.split(":", 1) + field_name = field_name.strip() + field_value = field_value.strip() + current_field = field_name + + if field_name == "Requires-Python": + metadata["requires_python"] = field_value + elif field_name == "Requires-Dist": + if "requires_dist" not in metadata: + metadata["requires_dist"] = [] + metadata["requires_dist"].append(field_value) + elif field_name == "Provides-Extra": + if "provides_extra" not in metadata: + metadata["provides_extra"] = [] + metadata["provides_extra"].append(field_value) return metadata if metadata else None - except Exception as e: - logger.debug(f"Failed to read metadata from {whl_path}: {e}") + except Exception: + pass return None @@ -148,23 +209,223 @@ def get_installed_package_version(package_name: str) -> Optional[str]: Get installed version of a package Args: - package_name: Package name + package_name: Package name (may contain hyphens or underscores) Returns: Installed version string, or None if not installed """ + # Try original name first cmd = [sys.executable, "-m", "pip", "show", package_name] try: - result = subprocess.run(cmd, capture_output=True, text=True, check=True) + result = subprocess.run( + cmd, capture_output=True, text=True, check=True, timeout=5 + ) for line in result.stdout.splitlines(): if line.startswith("Version:"): return line.split(":", 1)[1].strip() - except subprocess.CalledProcessError: - # Package not installed - return None + except ( + subprocess.CalledProcessError, + subprocess.TimeoutExpired, + Exception, + ): + pass + + # Try with underscores replaced by hyphens + normalized = package_name.replace("_", "-") + if normalized != package_name: + cmd = [sys.executable, "-m", "pip", "show", normalized] + try: + result = subprocess.run( + cmd, capture_output=True, text=True, check=True, timeout=5 + ) + for line in result.stdout.splitlines(): + if line.startswith("Version:"): + return line.split(":", 1)[1].strip() + except ( + subprocess.CalledProcessError, + subprocess.TimeoutExpired, + Exception, + ): + pass + + # Try with hyphens replaced by underscores + normalized = package_name.replace("-", "_") + if normalized != package_name: + cmd = [sys.executable, "-m", "pip", "show", normalized] + try: + result = subprocess.run( + cmd, capture_output=True, text=True, check=True, timeout=5 + ) + for line in result.stdout.splitlines(): + if line.startswith("Version:"): + return line.split(":", 1)[1].strip() + except ( + subprocess.CalledProcessError, + subprocess.TimeoutExpired, + Exception, + ): + pass + return None +def _is_library_installed(req_str: str) -> bool: + """ + Check if a library is installed and version satisfies requirement + + Similar to opentelemetry-bootstrap's _is_installed function + + Args: + req_str: Requirement string (e.g., "redis >= 2.6") + + Returns: + True if library is installed and version satisfies requirement, False otherwise + """ + try: + req = Requirement(req_str) + package_name = req.name + + # Try original name first + dist_version = get_installed_package_version(package_name) + + # If not found, try normalized versions (handle underscores vs hyphens) + if dist_version is None: + normalized = package_name.replace("_", "-") + if normalized != package_name: + dist_version = get_installed_package_version(normalized) + + if dist_version is None: + normalized = package_name.replace("-", "_") + if normalized != package_name: + dist_version = get_installed_package_version(normalized) + + if dist_version is None: + return False + + # Check if installed version satisfies requirement + return req.specifier.contains(dist_version) + except Exception: + return False + + +def _is_instrumentation_in_bootstrap_gen(package_name: str) -> bool: + """ + Check if a package is an instrumentation listed in bootstrap_gen.py + + Args: + package_name: Package name to check + + Returns: + True if the package is in bootstrap_gen.py (either in libraries or default_instrumentations) + """ + if not package_name: + return False + + normalized_name = package_name.replace("_", "-") + + # Check default instrumentations + for default_instr in gen_default_instrumentations: + if isinstance(default_instr, str): + default_pkg_name = ( + default_instr.split("==")[0] + .split(">=")[0] + .split("<=")[0] + .split("~=")[0] + .split("!=")[0] + .strip() + ) + if ( + default_pkg_name == normalized_name + or default_pkg_name == package_name + ): + return True + + # Check libraries mapping + for lib_mapping in gen_libraries: + instrumentation = lib_mapping.get("instrumentation", "") + if isinstance(instrumentation, str): + instr_pkg_name = ( + instrumentation.split("==")[0] + .split(">=")[0] + .split("<=")[0] + .split("~=")[0] + .split("!=")[0] + .strip() + ) + if ( + instr_pkg_name == normalized_name + or instr_pkg_name == package_name + ): + return True + + return False + + +def get_target_libraries_from_bootstrap_gen( + package_name: str, +) -> Tuple[List[str], bool]: + """ + Get target library requirements from bootstrap_gen.py + + This function uses the pre-generated bootstrap_gen.py file to get + target library information, similar to opentelemetry-bootstrap. + + Args: + package_name: Name of the instrumentation package (e.g., "opentelemetry-instrumentation-redis") + May contain hyphens or underscores, will be normalized + + Returns: + Tuple of (target_libraries list, is_default_instrumentation bool) + target_libraries contains library requirement strings (e.g., ["redis >= 2.6"]) + is_default_instrumentation is True if this is a default instrumentation + """ + if not package_name: + return [], False + + # Normalize package name: convert underscores to hyphens for matching + normalized_name = package_name.replace("_", "-") + + # Check if it's a default instrumentation + for default_instr in gen_default_instrumentations: + if isinstance(default_instr, str): + default_pkg_name = ( + default_instr.split("==")[0] + .split(">=")[0] + .split("<=")[0] + .split("~=")[0] + .split("!=")[0] + .strip() + ) + if ( + default_pkg_name == normalized_name + or default_pkg_name == package_name + ): + return [], True + + # Look up in libraries mapping + target_libraries = [] + for lib_mapping in gen_libraries: + instrumentation = lib_mapping.get("instrumentation", "") + if isinstance(instrumentation, str): + instr_pkg_name = ( + instrumentation.split("==")[0] + .split(">=")[0] + .split("<=")[0] + .split("~=")[0] + .split("!=")[0] + .strip() + ) + if ( + instr_pkg_name == normalized_name + or instr_pkg_name == package_name + ): + target_lib = lib_mapping.get("library", "") + if target_lib and isinstance(target_lib, str): + target_libraries.append(target_lib) + + return target_libraries, False + + def check_dependency_compatibility( whl_path: Path, skip_version_check: bool = False ) -> Tuple[bool, Optional[str]]: @@ -205,8 +466,7 @@ def check_dependency_compatibility( conflicts.append( f"{req.name} {installed_version} does not satisfy {req_str}" ) - except Exception as e: - logger.debug(f"Failed to parse requirement '{req_str}': {e}") + except Exception: # If parsing fails, assume compatible to avoid false positives continue @@ -246,10 +506,7 @@ def check_python_version_compatibility( # Check if current version satisfies the requirement is_compatible = spec.contains(current_version_str) return is_compatible, requirement_str - except Exception as e: - logger.debug( - f"Failed to parse Python requirement '{requirement_str}': {e}" - ) + except Exception: # If parsing fails, assume compatible to avoid false positives return True, requirement_str @@ -286,16 +543,18 @@ def filter_packages( blacklist: Optional[Set[str]] = None, whitelist: Optional[Set[str]] = None, skip_version_check: bool = False, + auto_detect: bool = False, ) -> Tuple[List[Path], List[Path]]: """ Filter packages based on blacklist/whitelist, Python version compatibility, - and dependency version compatibility + dependency version compatibility, and optionally auto-detect installed libraries Args: whl_files: List of whl file paths blacklist: blacklist (do not install these packages) whitelist: whitelist (only install these packages if specified) skip_version_check: If True, skip dependency version compatibility check + auto_detect: If True, only install instrumentation packages if their target libraries are installed Returns: (base dependency packages list, instrumentation packages list) @@ -310,50 +569,106 @@ def filter_packages( current_version = (sys.version_info.major, sys.version_info.minor) current_version_str = f"{current_version[0]}.{current_version[1]}" + logger.info(f"Scanning {len(whl_files)} packages for installation...") + if auto_detect: + logger.info( + "Auto-detect mode enabled: will only install instrumentations for detected libraries" + ) + for whl_file in whl_files: package_name = get_package_name_from_whl(whl_file) # Check blacklist if blacklist and package_name in blacklist: - logger.debug(f"Skipping package (blacklist): {package_name}") + logger.info(f"Skipping {package_name} (blacklist)") continue # Check whitelist if whitelist and package_name not in whitelist: - logger.debug( - f"Skipping package (not in whitelist): {package_name}" - ) + logger.info(f"Skipping {package_name} (not in whitelist)") continue - # Check Python version compatibility - is_compatible, requirement_str = check_python_version_compatibility( - whl_file, current_version - ) - - if not is_compatible: - logger.warning( - f"Skipping package (Python version incompatible): {package_name} " - f"(requires Python {requirement_str}, current: {current_version_str})" + # Check Python version compatibility (only for instrumentations in bootstrap_gen.py) + # Base dependencies and utility packages are installed without Python version check + is_instrumentation = _is_instrumentation_in_bootstrap_gen(package_name) + if is_instrumentation: + is_compatible, requirement_str = ( + check_python_version_compatibility(whl_file, current_version) ) - continue - - # Check dependency version compatibility - is_dep_compatible, conflict_msg = check_dependency_compatibility( - whl_file, skip_version_check - ) + if not is_compatible: + logger.info( + f"Skipping {package_name} (Python version incompatible: requires {requirement_str}, current: {current_version_str})" + ) + continue - if not is_dep_compatible: - logger.warning( - f"Skipping package (dependency version incompatible): {package_name} " - f"({conflict_msg})" + # Check dependency version compatibility (only for base dependencies) + # Instrumentation packages will be checked by pip during installation + if package_name in BASE_DEPENDENCIES: + is_dep_compatible, conflict_msg = check_dependency_compatibility( + whl_file, skip_version_check ) - continue + if not is_dep_compatible: + logger.warning( + f"Skipping {package_name} (dependency version incompatible: {conflict_msg})" + ) + continue # Classify: base dependencies vs instrumentation if package_name in BASE_DEPENDENCIES: base_packages.append(whl_file) else: - instrumentation_packages.append(whl_file) + # For instrumentation packages, check if auto-detect is enabled + if auto_detect: + target_libraries, is_default = ( + get_target_libraries_from_bootstrap_gen(package_name) + ) + + # Default instrumentations are always installed (like opentelemetry-bootstrap) + if is_default: + logger.info( + f"Will install {package_name} (default instrumentation)" + ) + instrumentation_packages.append(whl_file) + elif target_libraries: + # Check if any target library is installed + library_installed = False + installed_libs = [] + not_installed_libs = [] + for lib_req in target_libraries: + if _is_library_installed(lib_req): + library_installed = True + try: + req = Requirement(lib_req) + installed_libs.append(req.name) + except Exception: + installed_libs.append(lib_req) + else: + try: + req = Requirement(lib_req) + not_installed_libs.append(req.name) + except Exception: + not_installed_libs.append(lib_req) + + if library_installed: + logger.info( + f"Will install {package_name} (detected libraries: {', '.join(installed_libs)})" + ) + instrumentation_packages.append(whl_file) + else: + logger.info( + f"Skipping {package_name} (required libraries not installed: {', '.join(not_installed_libs)})" + ) + continue + else: + # No mapping found in bootstrap_gen.py, skip it + logger.info( + f"Skipping {package_name} (no target libraries mapping in bootstrap_gen.py)" + ) + continue + else: + # Auto-detect disabled, install all instrumentation packages + logger.info(f"Will install {package_name}") + instrumentation_packages.append(whl_file) return base_packages, instrumentation_packages @@ -413,7 +728,9 @@ def get_installed_loongsuite_packages() -> List[str]: cmd = [sys.executable, "-m", "pip", "list", "--format=json"] try: - result = subprocess.run(cmd, capture_output=True, text=True, check=True) + result = subprocess.run( + cmd, capture_output=True, text=True, check=True + ) installed_packages = json_lib.loads(result.stdout) # Filter packages to uninstall @@ -470,7 +787,9 @@ def uninstall_packages(package_names: List[str], yes: bool = False): raise -def resolve_tar_path(tar_path: Union[Path, str]) -> Tuple[Path, Optional[Path]]: +def resolve_tar_path( + tar_path: Union[Path, str], +) -> Tuple[Path, Optional[Path]]: """ Resolve tar path, downloading from URI if necessary @@ -519,7 +838,7 @@ def get_package_names_from_tar( raise ValueError("No whl files found in tar file") base_packages, instrumentation_packages = filter_packages( - whl_files, blacklist, whitelist + whl_files, blacklist, whitelist, auto_detect=False ) # Get package names @@ -540,6 +859,7 @@ def install_from_tar( upgrade: bool = False, keep_temp: bool = False, skip_version_check: bool = False, + auto_detect: bool = False, ): """ Install loongsuite packages from tar package @@ -550,6 +870,8 @@ def install_from_tar( whitelist: whitelist (only install these packages if specified) upgrade: whether to upgrade already installed packages keep_temp: whether to keep temporary directory + skip_version_check: If True, skip dependency version compatibility check + auto_detect: If True, only install instrumentation packages if their target libraries are installed """ # Resolve tar path (download from URI if necessary) local_tar_path, temp_tar_dir = resolve_tar_path(tar_path) @@ -558,15 +880,19 @@ def install_from_tar( temp_dir = Path(tempfile.mkdtemp(prefix="loongsuite-")) try: + logger.info("Extracting packages from tar file...") # Extract tar file whl_files = extract_tar(local_tar_path, temp_dir) if not whl_files: raise ValueError("No whl files found in tar file") + logger.info(f"Found {len(whl_files)} packages in tar file") + # Filter packages + logger.info("Filtering packages...") base_packages, instrumentation_packages = filter_packages( - whl_files, blacklist, whitelist, skip_version_check + whl_files, blacklist, whitelist, skip_version_check, auto_detect ) # Ensure base dependencies must be installed @@ -578,6 +904,10 @@ def install_from_tar( # Merge all packages to install all_packages = base_packages + instrumentation_packages + if not all_packages: + logger.warning("No packages to install after filtering") + return + logger.info( f"Will install {len(base_packages)} base dependency packages" ) @@ -585,8 +915,16 @@ def install_from_tar( f"Will install {len(instrumentation_packages)} instrumentation packages" ) + if instrumentation_packages: + logger.info("Instrumentation packages to install:") + for pkg in instrumentation_packages: + pkg_name = get_package_name_from_whl(pkg) + logger.info(f" - {pkg_name}") + # Install + logger.info("Installing packages...") install_packages(all_packages, temp_dir, upgrade) + logger.info("Installation completed successfully!") finally: if not keep_temp: @@ -735,6 +1073,16 @@ def main(): action="store_true", help="force installation even if dependency versions are incompatible", ) + install_group.add_argument( + "--auto-detect", + action="store_true", + help="only install instrumentation packages if their target libraries are installed (similar to opentelemetry-bootstrap)", + ) + install_group.add_argument( + "--verbose", + action="store_true", + help="enable verbose debug logging", + ) # Uninstall-specific arguments uninstall_group = parser.add_argument_group("uninstall options") @@ -747,6 +1095,25 @@ def main(): args = parser.parse_args() + # Configure logging level + if args.verbose or ( + hasattr(args, "action") + and args.action == "install" + and hasattr(args, "auto_detect") + and args.auto_detect + ): + logging.basicConfig( + level=logging.DEBUG, + format="%(levelname)s: %(message)s", + force=True, + ) + logger.setLevel(logging.DEBUG) + else: + logging.basicConfig( + level=logging.INFO, format="%(levelname)s: %(message)s", force=True + ) + logger.setLevel(logging.INFO) + # Load blacklist/whitelist blacklist = load_list_file(args.blacklist) if args.blacklist else None whitelist = load_list_file(args.whitelist) if args.whitelist else None @@ -766,7 +1133,9 @@ def main(): elif args.latest: tar_path = get_latest_release_url() else: - parser.error("For install action, must specify one of --tar, --version, or --latest") + parser.error( + "For install action, must specify one of --tar, --version, or --latest" + ) # Install install_from_tar( @@ -776,6 +1145,7 @@ def main(): upgrade=args.upgrade, keep_temp=args.keep_temp, skip_version_check=args.force, + auto_detect=args.auto_detect, ) elif args.action == "uninstall": diff --git a/loongsuite-distro/src/loongsuite/distro/bootstrap_gen.py b/loongsuite-distro/src/loongsuite/distro/bootstrap_gen.py new file mode 100644 index 000000000..19e31b0eb --- /dev/null +++ b/loongsuite-distro/src/loongsuite/distro/bootstrap_gen.py @@ -0,0 +1,86 @@ + +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# DO NOT EDIT. THIS FILE WAS AUTOGENERATED FROM INSTRUMENTATION PACKAGES. +# RUN `python scripts/generate_loongsuite_bootstrap.py` TO REGENERATE. + +libraries = [ + {"library": "openai >= 1.26.0", "instrumentation": "opentelemetry-instrumentation-openai-v2"}, + {"library": "google-cloud-aiplatform >= 1.64", "instrumentation": "opentelemetry-instrumentation-vertexai>=2.0b0"}, + {"library": "aio_pika >= 7.2.0, < 10.0.0", "instrumentation": "opentelemetry-instrumentation-aio-pika==0.61b0.dev"}, + {"library": "aiohttp ~= 3.0", "instrumentation": "opentelemetry-instrumentation-aiohttp-client==0.61b0.dev"}, + {"library": "aiohttp ~= 3.0", "instrumentation": "opentelemetry-instrumentation-aiohttp-server==0.61b0.dev"}, + {"library": "aiokafka >= 0.8, < 1.0", "instrumentation": "opentelemetry-instrumentation-aiokafka==0.61b0.dev"}, + {"library": "aiopg >= 0.13.0, < 2.0.0", "instrumentation": "opentelemetry-instrumentation-aiopg==0.61b0.dev"}, + {"library": "asgiref ~= 3.0", "instrumentation": "opentelemetry-instrumentation-asgi==0.61b0.dev"}, + {"library": "asyncclick ~= 8.0", "instrumentation": "opentelemetry-instrumentation-asyncclick==0.61b0.dev"}, + {"library": "asyncpg >= 0.12.0", "instrumentation": "opentelemetry-instrumentation-asyncpg==0.61b0.dev"}, + {"library": "boto~=2.0", "instrumentation": "opentelemetry-instrumentation-boto==0.61b0.dev"}, + {"library": "boto3 ~= 1.0", "instrumentation": "opentelemetry-instrumentation-boto3sqs==0.61b0.dev"}, + {"library": "botocore ~= 1.0", "instrumentation": "opentelemetry-instrumentation-botocore==0.61b0.dev"}, + {"library": "cassandra-driver ~= 3.25", "instrumentation": "opentelemetry-instrumentation-cassandra==0.61b0.dev"}, + {"library": "scylla-driver ~= 3.25", "instrumentation": "opentelemetry-instrumentation-cassandra==0.61b0.dev"}, + {"library": "celery >= 4.0, < 6.0", "instrumentation": "opentelemetry-instrumentation-celery==0.61b0.dev"}, + {"library": "click >= 8.1.3, < 9.0.0", "instrumentation": "opentelemetry-instrumentation-click==0.61b0.dev"}, + {"library": "confluent-kafka >= 1.8.2, <= 2.11.0", "instrumentation": "opentelemetry-instrumentation-confluent-kafka==0.61b0.dev"}, + {"library": "django >= 1.10", "instrumentation": "opentelemetry-instrumentation-django==0.61b0.dev"}, + {"library": "elasticsearch >= 6.0", "instrumentation": "opentelemetry-instrumentation-elasticsearch==0.61b0.dev"}, + {"library": "falcon >= 1.4.1, < 5.0.0", "instrumentation": "opentelemetry-instrumentation-falcon==0.61b0.dev"}, + {"library": "fastapi ~= 0.92", "instrumentation": "opentelemetry-instrumentation-fastapi==0.61b0.dev"}, + {"library": "flask >= 1.0", "instrumentation": "opentelemetry-instrumentation-flask==0.61b0.dev"}, + {"library": "grpcio >= 1.42.0", "instrumentation": "opentelemetry-instrumentation-grpc==0.61b0.dev"}, + {"library": "httpx >= 0.18.0", "instrumentation": "opentelemetry-instrumentation-httpx==0.61b0.dev"}, + {"library": "jinja2 >= 2.7, < 4.0", "instrumentation": "opentelemetry-instrumentation-jinja2==0.61b0.dev"}, + {"library": "kafka-python >= 2.0, < 3.0", "instrumentation": "opentelemetry-instrumentation-kafka-python==0.61b0.dev"}, + {"library": "kafka-python-ng >= 2.0, < 3.0", "instrumentation": "opentelemetry-instrumentation-kafka-python==0.61b0.dev"}, + {"library": "mysql-connector-python >= 8.0, < 10.0", "instrumentation": "opentelemetry-instrumentation-mysql==0.61b0.dev"}, + {"library": "mysqlclient < 3", "instrumentation": "opentelemetry-instrumentation-mysqlclient==0.61b0.dev"}, + {"library": "pika >= 0.12.0", "instrumentation": "opentelemetry-instrumentation-pika==0.61b0.dev"}, + {"library": "psycopg >= 3.1.0", "instrumentation": "opentelemetry-instrumentation-psycopg==0.61b0.dev"}, + {"library": "psycopg2 >= 2.7.3.1", "instrumentation": "opentelemetry-instrumentation-psycopg2==0.61b0.dev"}, + {"library": "psycopg2-binary >= 2.7.3.1", "instrumentation": "opentelemetry-instrumentation-psycopg2==0.61b0.dev"}, + {"library": "pymemcache >= 1.3.5, < 5", "instrumentation": "opentelemetry-instrumentation-pymemcache==0.61b0.dev"}, + {"library": "pymongo >= 3.1, < 5.0", "instrumentation": "opentelemetry-instrumentation-pymongo==0.61b0.dev"}, + {"library": "pymssql >= 2.1.5, < 3", "instrumentation": "opentelemetry-instrumentation-pymssql==0.61b0.dev"}, + {"library": "PyMySQL < 2", "instrumentation": "opentelemetry-instrumentation-pymysql==0.61b0.dev"}, + {"library": "pyramid >= 1.7", "instrumentation": "opentelemetry-instrumentation-pyramid==0.61b0.dev"}, + {"library": "redis >= 2.6", "instrumentation": "opentelemetry-instrumentation-redis==0.61b0.dev"}, + {"library": "remoulade >= 0.50", "instrumentation": "opentelemetry-instrumentation-remoulade==0.61b0.dev"}, + {"library": "requests ~= 2.0", "instrumentation": "opentelemetry-instrumentation-requests==0.61b0.dev"}, + {"library": "sqlalchemy >= 1.0.0, < 2.1.0", "instrumentation": "opentelemetry-instrumentation-sqlalchemy==0.61b0.dev"}, + {"library": "starlette >= 0.13", "instrumentation": "opentelemetry-instrumentation-starlette==0.61b0.dev"}, + {"library": "psutil >= 5", "instrumentation": "opentelemetry-instrumentation-system-metrics==0.61b0.dev"}, + {"library": "tornado >= 5.1.1", "instrumentation": "opentelemetry-instrumentation-tornado==0.61b0.dev"}, + {"library": "tortoise-orm >= 0.17.0", "instrumentation": "opentelemetry-instrumentation-tortoiseorm==0.61b0.dev"}, + {"library": "pydantic >= 1.10.2", "instrumentation": "opentelemetry-instrumentation-tortoiseorm==0.61b0.dev"}, + {"library": "urllib3 >= 1.0.0, < 3.0.0", "instrumentation": "opentelemetry-instrumentation-urllib3==0.61b0.dev"}, + {"library": "agentscope >= 1.0.0", "instrumentation": "loongsuite-instrumentation-agentscope==1.0.0"}, + {"library": "agno", "instrumentation": "loongsuite-instrumentation-agno==0.1b0.dev"}, + {"library": "dashscope >= 1.0.0", "instrumentation": "loongsuite-instrumentation-dashscope==0.1.0.dev0"}, + {"library": "langchain_core >= 0.1.0", "instrumentation": "loongsuite-instrumentation-langchain==1.0.0"}, + {"library": "mcp >= 1.3.0, <= 1.13.1", "instrumentation": "loongsuite-instrumentation-mcp==0.1.0"}, + {"library": "mem0ai >= 1.0.0", "instrumentation": "loongsuite-instrumentation-mem0==0.1.0"}, +] + +default_instrumentations = [ + "opentelemetry-instrumentation-asyncio==0.61b0.dev", + "opentelemetry-instrumentation-dbapi==0.61b0.dev", + "opentelemetry-instrumentation-logging==0.61b0.dev", + "opentelemetry-instrumentation-sqlite3==0.61b0.dev", + "opentelemetry-instrumentation-threading==0.61b0.dev", + "opentelemetry-instrumentation-urllib==0.61b0.dev", + "opentelemetry-instrumentation-wsgi==0.61b0.dev", + "loongsuite-instrumentation-dify==1.1.0", +] diff --git a/scripts/generate_loongsuite_bootstrap.py b/scripts/generate_loongsuite_bootstrap.py new file mode 100644 index 000000000..98848958f --- /dev/null +++ b/scripts/generate_loongsuite_bootstrap.py @@ -0,0 +1,231 @@ +#!/usr/bin/env python3 + +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import ast +import logging +import subprocess +from pathlib import Path + +import tomli +from generate_instrumentation_bootstrap import ( + independent_packages, + packages_to_exclude, +) +from otel_packaging import get_instrumentation_packages as get_upstream_packages + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger("loongsuite_bootstrap_generator") + +# Get root path +scripts_path = Path(__file__).parent +root_path = scripts_path.parent + +_template = """ +{header} + +# DO NOT EDIT. THIS FILE WAS AUTOGENERATED FROM INSTRUMENTATION PACKAGES. +# RUN `python scripts/generate_loongsuite_bootstrap.py` TO REGENERATE. + +{source} +""" + +_source_tmpl = """ +libraries = [] +default_instrumentations = [] +""" + +gen_path = root_path / "loongsuite-distro" / "src" / "loongsuite" / "distro" / "bootstrap_gen.py" + + +def get_instrumentation_packages(): + """Get all instrumentation packages from various directories""" + packages = [] + + logger.info("Scanning instrumentation packages...") + + # Get packages from upstream directories (instrumentation, instrumentation-genai) + # using otel_packaging + logger.info("Processing upstream packages (instrumentation, instrumentation-genai)...") + for pkg in get_upstream_packages(independent_packages=independent_packages): + if pkg["name"] not in packages_to_exclude: + packages.append(pkg) + + # Scan instrumentation-loongsuite directory (reuse same logic as otel_packaging) + loongsuite_dir = root_path / "instrumentation-loongsuite" + if loongsuite_dir.exists(): + logger.info("Processing loongsuite packages (instrumentation-loongsuite)...") + pkg_dirs = sorted([d for d in loongsuite_dir.iterdir() if d.is_dir()]) + for pkg_dir in pkg_dirs: + pyproject_toml = pkg_dir / "pyproject.toml" + if not pyproject_toml.exists(): + continue + + try: + # Get version using hatch command (same as otel_packaging) + # Suppress hatch's verbose output + version = subprocess.check_output( + "hatch version", + shell=True, + cwd=pkg_dir, + universal_newlines=True, + ).strip() + + # Read pyproject.toml + with open(pyproject_toml, "rb") as f: + pyproject = tomli.load(f) + + pkg_name = pyproject["project"]["name"] + + # Skip if this package is in the exclusion list + if pkg_name in packages_to_exclude: + continue + + # Get optional dependencies + optional_deps = pyproject["project"]["optional-dependencies"] + instruments = optional_deps.get("instruments", []) + instruments_any = optional_deps.get("instruments-any", []) + + # Handle independent packages + if pkg_name in independent_packages: + specifier = independent_packages[pkg_name] + requirement = f"{pkg_name}{specifier}" if specifier else f"{pkg_name}=={version}" + else: + requirement = f"{pkg_name}=={version}" + + packages.append({ + "name": pkg_name, + "version": version, + "instruments": instruments, + "instruments-any": instruments_any, + "requirement": requirement, + }) + except subprocess.CalledProcessError as e: + logger.warning(f"Could not get hatch version from {pkg_dir.name}: {e}") + continue + except Exception as e: + logger.warning(f"Failed to process {pkg_dir.name}: {e}") + continue + + return packages + + +def main(): + # Read license header + header_path = scripts_path / "license_header.txt" + if header_path.exists(): + with open(header_path, "r", encoding="utf-8") as f: + header = f.read() + else: + header = "# Copyright The OpenTelemetry Authors\n# Licensed under the Apache License, Version 2.0\n" + + # Get all packages + packages = get_instrumentation_packages() + logger.info(f"Found {len(packages)} instrumentation packages") + + # Build AST nodes + default_instrumentations = ast.List(elts=[]) + libraries = ast.List(elts=[]) + + logger.info("Building bootstrap configuration...") + for pkg in packages: + # If no instruments and no instruments-any, it's a default instrumentation + if not pkg["instruments"] and not pkg["instruments-any"]: + default_instrumentations.elts.append( + ast.Constant(value=pkg["requirement"]) + ) + else: + # Add instruments (all must be installed) + for target_pkg in pkg["instruments"]: + libraries.elts.append( + ast.Dict( + keys=[ + ast.Constant(value="library"), + ast.Constant(value="instrumentation"), + ], + values=[ + ast.Constant(value=target_pkg), + ast.Constant(value=pkg["requirement"]), + ], + ) + ) + + # Add instruments-any (at least one must be installed) + for target_pkg in pkg["instruments-any"]: + libraries.elts.append( + ast.Dict( + keys=[ + ast.Constant(value="library"), + ast.Constant(value="instrumentation"), + ], + values=[ + ast.Constant(value=target_pkg), + ast.Constant(value=pkg["requirement"]), + ], + ) + ) + + # Generate source code manually (avoiding astor dependency) + logger.info("Generating source code...") + # Build libraries list string + libraries_lines = ["libraries = ["] + for lib_mapping in libraries.elts: + if isinstance(lib_mapping, ast.Dict): + # Extract keys and values + lib_key = lib_mapping.keys[0] + instr_key = lib_mapping.keys[1] + lib_val_node = lib_mapping.values[0] + instr_val_node = lib_mapping.values[1] + + # Get string values + lib_key_str = lib_key.value if isinstance(lib_key, ast.Constant) else (lib_key.s if hasattr(lib_key, 's') else "library") + instr_key_str = instr_key.value if isinstance(instr_key, ast.Constant) else (instr_key.s if hasattr(instr_key, 's') else "instrumentation") + lib_val_str = lib_val_node.value if isinstance(lib_val_node, ast.Constant) else (lib_val_node.s if hasattr(lib_val_node, 's') else "") + instr_val_str = instr_val_node.value if isinstance(instr_val_node, ast.Constant) else (instr_val_node.s if hasattr(instr_val_node, 's') else "") + + # Escape quotes in values + lib_val_str = lib_val_str.replace('"', '\\"') + instr_val_str = instr_val_str.replace('"', '\\"') + + libraries_lines.append(f' {{"{lib_key_str}": "{lib_val_str}", "{instr_key_str}": "{instr_val_str}"}},') + libraries_lines.append("]") + + # Build default_instrumentations list string + default_lines = ["default_instrumentations = ["] + for default_instr in default_instrumentations.elts: + if isinstance(default_instr, ast.Constant): + instr_val = default_instr.value.replace('"', '\\"') + default_lines.append(f' "{instr_val}",') + default_lines.append("]") + + # Combine source + source = "\n".join(libraries_lines) + "\n\n" + "\n".join(default_lines) + + # Format with header + formatted_source = _template.format(header=header, source=source) + + # Write to file + gen_path.parent.mkdir(parents=True, exist_ok=True) + with open(gen_path, "w", encoding="utf-8") as f: + f.write(formatted_source) + + logger.info("generated %s", gen_path) + logger.info(" - %d default instrumentations", len(default_instrumentations.elts)) + logger.info(" - %d library mappings", len(libraries.elts)) + + +if __name__ == "__main__": + main() + diff --git a/scripts/generate_loongsuite_readme.py b/scripts/generate_loongsuite_readme.py new file mode 100644 index 000000000..f89cdeae8 --- /dev/null +++ b/scripts/generate_loongsuite_readme.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python3 +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import logging +import os +from pathlib import Path + +import tomli + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger("loongsuite_readme_generator") + +_prefix = "loongsuite-instrumentation-" + +header = """ +| Instrumentation | Supported Packages | Metrics support | Semconv status | +| --------------- | ------------------ | --------------- | -------------- |""" + + +def main(base_instrumentation_path): + table = [header] + for instrumentation in sorted(os.listdir(base_instrumentation_path)): + instrumentation_path = os.path.join( + base_instrumentation_path, instrumentation + ) + if not os.path.isdir( + instrumentation_path + ) or not instrumentation.startswith(_prefix): + continue + + pyproject_toml = Path(instrumentation_path) / "pyproject.toml" + if not pyproject_toml.exists(): + continue + + try: + with open(pyproject_toml, "rb") as f: + pyproject = tomli.load(f) + + project = pyproject.get("project", {}) + optional_deps = project.get("optional-dependencies", {}) + instruments = optional_deps.get("instruments", []) + instruments_any = optional_deps.get("instruments-any", []) + + # Extract package name from instrumentation directory name + # e.g., "loongsuite-instrumentation-agentscope" -> "agentscope" + name = instrumentation.replace(_prefix, "") + + instruments_all = () + if not instruments and not instruments_any: + instruments_all = (name,) + else: + instruments_all = tuple(instruments + instruments_any) + + # Try to get metrics support and semconv status from pyproject.toml + # These might not be present, so use defaults + supports_metrics = project.get("supports_metrics", False) + semconv_status = project.get("semconv_status", "development") + + metric_column = "Yes" if supports_metrics else "No" + + table.append( + f"| [{instrumentation}](./{instrumentation}) | {','.join(instruments_all)} | {metric_column} | {semconv_status}" + ) + except Exception as e: + logger.warning(f"Failed to process {instrumentation}: {e}") + continue + + readme_path = os.path.join(base_instrumentation_path, "README.md") + with open(readme_path, "w", encoding="utf-8") as fh: + fh.write("\n".join(table)) + logger.info(f"Generated {readme_path}") + + +if __name__ == "__main__": + root_path = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + instrumentation_path = os.path.join(root_path, "instrumentation-loongsuite") + if os.path.exists(instrumentation_path): + main(instrumentation_path) + else: + logger.warning(f"Instrumentation path does not exist: {instrumentation_path}") + diff --git a/tox-loongsuite.ini b/tox-loongsuite.ini index 1fcacb0c3..e66fa243c 100644 --- a/tox-loongsuite.ini +++ b/tox-loongsuite.ini @@ -52,6 +52,9 @@ envlist = py3{9,10,11,12,13}-test-loongsuite-processor-baggage lint-loongsuite-processor-baggage + ; generate tasks + generate-loongsuite + [testenv] test_deps = opentelemetry-api@{env:CORE_REPO}\#egg=opentelemetry-api&subdirectory=opentelemetry-api @@ -154,6 +157,19 @@ commands = ; TODO: add coverage commands ; coverage: {toxinidir}/scripts/coverage.sh +[testenv:generate-loongsuite] +deps = + -r {toxinidir}/gen-requirements.txt + +allowlist_externals = + {toxinidir}/scripts/generate_loongsuite_bootstrap.py + {toxinidir}/scripts/generate_loongsuite_readme.py + pytest + +commands = + {toxinidir}/scripts/generate_loongsuite_bootstrap.py + {toxinidir}/scripts/generate_loongsuite_readme.py + [testenv:docs] deps = -c {toxinidir}/dev-requirements.txt From 17d98aaa268e9261ff7b391a2daa784fc494ae80 Mon Sep 17 00:00:00 2001 From: cirilla-zmh Date: Mon, 5 Jan 2026 10:34:51 +0800 Subject: [PATCH 08/16] Staging commit Change-Id: Iba33588aedef449bc1691210bbc51879ca6e1caa Co-developed-by: Cursor --- loongsuite-distro/BOOTSTRAP_REVIEW.md | 272 ++++++++++++++++++ .../src/loongsuite/distro/bootstrap.py | 266 +++++++++-------- 2 files changed, 411 insertions(+), 127 deletions(-) create mode 100644 loongsuite-distro/BOOTSTRAP_REVIEW.md diff --git a/loongsuite-distro/BOOTSTRAP_REVIEW.md b/loongsuite-distro/BOOTSTRAP_REVIEW.md new file mode 100644 index 000000000..2ebff4fb6 --- /dev/null +++ b/loongsuite-distro/BOOTSTRAP_REVIEW.md @@ -0,0 +1,272 @@ +# bootstrap.py 流程梳理与优化建议 + +## 一、基本流程梳理 + +### 1. 安装流程 (`install_from_tar`) + +``` +main() + └─> install_from_tar() + ├─> resolve_tar_path() # 解析 tar 路径,可能需要下载 + ├─> extract_tar() # 解压 tar 文件,获取所有 .whl 文件 + ├─> filter_packages() # 过滤包(核心逻辑) + │ ├─> get_package_name_from_whl() # 从 whl 文件名提取包名 + │ ├─> _is_instrumentation_in_bootstrap_gen() # 检查是否为 instrumentation + │ ├─> check_python_version_compatibility() # 检查 Python 版本兼容性 + │ ├─> check_dependency_compatibility() # 检查依赖版本兼容性 + │ └─> get_target_libraries_from_bootstrap_gen() + _is_library_installed() # 自动检测 + └─> install_packages() # 使用 pip 安装 +``` + +### 2. 卸载流程 (`uninstall_loongsuite_packages`) + +``` +main() + └─> uninstall_loongsuite_packages() + ├─> get_installed_loongsuite_packages() # 获取已安装的包列表 + └─> uninstall_packages() # 使用 pip 卸载 +``` + +### 3. 核心辅助函数 + +- **包名处理**: + - `get_package_name_from_whl()`: 从 whl 文件名提取包名 + - `get_installed_package_version()`: 获取已安装包的版本(处理下划线/连字符变体) + +- **元数据提取**: + - `get_metadata_from_whl()`: 从 whl 文件提取 METADATA + - `get_python_requirement_from_whl()`: 提取 Python 版本要求 + +- **兼容性检查**: + - `check_python_version_compatibility()`: 检查 Python 版本 + - `check_dependency_compatibility()`: 检查依赖版本 + +- **bootstrap_gen 查询**: + - `_is_instrumentation_in_bootstrap_gen()`: 检查是否为 instrumentation + - `get_target_libraries_from_bootstrap_gen()`: 获取目标库列表 + +- **库检测**: + - `_is_library_installed()`: 检查库是否已安装 + +## 二、发现的问题和优化建议 + +### 1. 🔴 包名规范化逻辑重复 + +**问题**: +- `get_installed_package_version()` 中三次尝试(原始名、下划线→连字符、连字符→下划线) +- `_is_library_installed()` 中也有类似逻辑 +- 多个地方都有 `normalized_name = package_name.replace("_", "-")` 的重复 + +**建议**: +```python +def normalize_package_name(package_name: str) -> str: + """统一规范化包名:将下划线转换为连字符""" + return package_name.replace("_", "-") + +def get_package_name_variants(package_name: str) -> List[str]: + """获取包名的所有可能变体(用于查找)""" + normalized = normalize_package_name(package_name) + variants = [package_name] + if normalized != package_name: + variants.append(normalized) + # 如果需要,也可以添加反向变体 + return variants +``` + +### 2. 🔴 从 requirement 字符串提取包名的逻辑重复 + +**问题**: +在 `_is_instrumentation_in_bootstrap_gen()` 和 `get_target_libraries_from_bootstrap_gen()` 中都有: +```python +default_pkg_name = ( + default_instr.split("==")[0] + .split(">=")[0] + .split("<=")[0] + .split("~=")[0] + .split("!=")[0] + .strip() +) +``` + +**建议**: +```python +def extract_package_name_from_requirement(req_str: str) -> str: + """从 requirement 字符串中提取包名""" + try: + return Requirement(req_str).name + except Exception: + # Fallback: 手动解析 + for op in ["==", ">=", "<=", "~=", "!=", ">", "<"]: + if op in req_str: + return req_str.split(op)[0].strip() + return req_str.strip() +``` + +### 3. 🟡 get_installed_package_version 中的重复代码 + +**问题**: +三个几乎相同的 try-except 块,只是包名不同。 + +**建议**: +```python +def get_installed_package_version(package_name: str) -> Optional[str]: + """获取已安装包的版本""" + variants = get_package_name_variants(package_name) + + for variant in variants: + version = _try_get_version(variant) + if version: + return version + return None + +def _try_get_version(package_name: str) -> Optional[str]: + """尝试获取单个包名变体的版本""" + cmd = [sys.executable, "-m", "pip", "show", package_name] + try: + result = subprocess.run( + cmd, capture_output=True, text=True, check=True, timeout=5 + ) + for line in result.stdout.splitlines(): + if line.startswith("Version:"): + return line.split(":", 1)[1].strip() + except (subprocess.CalledProcessError, subprocess.TimeoutExpired): + pass + return None +``` + +### 4. 🟡 filter_packages 函数过长 + +**问题**: +`filter_packages()` 函数有 130+ 行,包含太多逻辑,可读性差。 + +**建议**: +拆分为多个小函数: +```python +def filter_packages(...): + """主函数,协调各个过滤步骤""" + base_packages = [] + instrumentation_packages = [] + + for whl_file in whl_files: + package_name = get_package_name_from_whl(whl_file) + + if _should_skip_package(package_name, whl_file, blacklist, whitelist, + skip_version_check, auto_detect): + continue + + if package_name in BASE_DEPENDENCIES: + base_packages.append(whl_file) + else: + if _should_install_instrumentation(package_name, whl_file, auto_detect): + instrumentation_packages.append(whl_file) + + return base_packages, instrumentation_packages + +def _should_skip_package(...) -> bool: + """检查是否应该跳过该包""" + # 黑名单/白名单检查 + # Python 版本检查 + # 依赖版本检查 + pass + +def _should_install_instrumentation(...) -> bool: + """检查是否应该安装该 instrumentation""" + # auto-detect 逻辑 + pass +``` + +### 5. 🟡 包名匹配逻辑重复 + +**问题**: +多处都有 `normalized_name == package_name or default_pkg_name == package_name` 这样的匹配。 + +**建议**: +```python +def package_names_match(name1: str, name2: str) -> bool: + """检查两个包名是否匹配(考虑规范化)""" + normalized1 = normalize_package_name(name1) + normalized2 = normalize_package_name(name2) + return (normalized1 == normalized2 or + name1 == name2 or + normalized1 == name2 or + name1 == normalized2) +``` + +### 6. 🟢 常量提取 + +**问题**: +`EXCLUDED_PACKAGES` 在函数内部定义,应该移到模块级别。 + +**建议**: +```python +# 在模块级别定义 +UNINSTALL_EXCLUDED_PACKAGES = { + "loongsuite-distro", + "opentelemetry-api", + "opentelemetry-sdk", + "opentelemetry-instrumentation", +} +``` + +### 7. 🟢 错误处理改进 + +**问题**: +多处使用 `except Exception: pass`,可能隐藏重要错误。 + +**建议**: +更具体地捕获异常,至少记录警告: +```python +except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as e: + logger.debug(f"Failed to get version for {package_name}: {e}") + return None +except Exception as e: + logger.warning(f"Unexpected error getting version for {package_name}: {e}") + return None +``` + +### 8. 🟢 使用 packaging 库解析 requirement + +**问题**: +手动解析 requirement 字符串(split("==")[0]...)不够健壮。 + +**建议**: +统一使用 `packaging.requirements.Requirement` 解析(已经在用,但有些地方还在手动解析)。 + +### 9. 🟡 模块化建议 + +**建议**将代码拆分为多个模块**: + +``` +loongsuite/distro/ + ├── bootstrap.py # 主入口和 CLI + ├── package_utils.py # 包名处理、版本获取等工具函数 + ├── metadata.py # whl 元数据提取 + ├── compatibility.py # 兼容性检查 + └── bootstrap_gen.py # bootstrap_gen 查询(已存在) +``` + +## 三、优先级建议 + +### 高优先级(立即优化) +1. ✅ 提取包名规范化函数(减少重复,提高一致性) +2. ✅ 提取 requirement 解析函数(多处使用,容易出错) +3. ✅ 简化 `get_installed_package_version()`(消除重复代码) + +### 中优先级(后续优化) +4. ⚠️ 拆分 `filter_packages()` 函数(提高可读性) +5. ⚠️ 提取包名匹配函数(统一匹配逻辑) +6. ⚠️ 改进错误处理(更好的调试体验) + +### 低优先级(可选) +7. 💡 模块化拆分(如果文件继续增长) +8. 💡 使用更专业的 metadata 解析库(如果遇到解析问题) + +## 四、总结 + +当前代码功能完整,但存在以下主要问题: +1. **代码重复**:包名规范化、requirement 解析等逻辑在多处重复 +2. **函数过长**:`filter_packages()` 函数包含太多逻辑 +3. **错误处理**:过于宽泛的异常捕获可能隐藏问题 + +建议优先解决代码重复问题,这将提高代码的可维护性和一致性。 + diff --git a/loongsuite-distro/src/loongsuite/distro/bootstrap.py b/loongsuite-distro/src/loongsuite/distro/bootstrap.py index 988760f5a..7745f399f 100644 --- a/loongsuite-distro/src/loongsuite/distro/bootstrap.py +++ b/loongsuite-distro/src/loongsuite/distro/bootstrap.py @@ -50,6 +50,100 @@ "opentelemetry-semantic-conventions", } +# Packages to exclude from uninstallation +UNINSTALL_EXCLUDED_PACKAGES = { + "loongsuite-distro", + "opentelemetry-api", + "opentelemetry-sdk", + "opentelemetry-instrumentation", +} + + +def normalize_package_name(package_name: str) -> str: + """ + Normalize package name by converting underscores to hyphens. + + Package names in PyPI use hyphens, but wheel filenames may use underscores. + This function ensures consistent package name format. + + Args: + package_name: Package name (may contain underscores or hyphens) + + Returns: + Normalized package name with hyphens + """ + return package_name.replace("_", "-") + + +def get_package_name_variants(package_name: str) -> List[str]: + """ + Get all possible variants of a package name for lookup. + + This is useful when checking if a package is installed, as package names + may be stored with either underscores or hyphens. + + Args: + package_name: Package name + + Returns: + List of package name variants to try + """ + variants = [package_name] + normalized = normalize_package_name(package_name) + if normalized != package_name: + variants.append(normalized) + # Also try reverse (hyphens to underscores) for completeness + reverse = package_name.replace("-", "_") + if reverse != package_name and reverse not in variants: + variants.append(reverse) + return variants + + +def extract_package_name_from_requirement(req_str: str) -> str: + """ + Extract package name from a requirement string. + + Examples: + "redis >= 2.6" -> "redis" + "opentelemetry-instrumentation==0.60b1" -> "opentelemetry-instrumentation" + "package-name~=1.0" -> "package-name" + + Args: + req_str: Requirement string + + Returns: + Package name extracted from requirement + """ + try: + return Requirement(req_str).name + except Exception: + # Fallback: manual parsing if Requirement parsing fails + for op in ["==", ">=", "<=", "~=", "!=", ">", "<"]: + if op in req_str: + return req_str.split(op)[0].strip() + return req_str.strip() + + +def package_names_match(name1: str, name2: str) -> bool: + """ + Check if two package names match (considering normalization). + + Args: + name1: First package name + name2: Second package name + + Returns: + True if names match (after normalization), False otherwise + """ + normalized1 = normalize_package_name(name1) + normalized2 = normalize_package_name(name2) + return ( + normalized1 == normalized2 + or name1 == name2 + or normalized1 == name2 + or name1 == normalized2 + ) + def load_list_file(file_path: Path) -> Set[str]: """Load list from file (one package name per line)""" @@ -204,17 +298,16 @@ def get_python_requirement_from_whl(whl_path: Path) -> Optional[str]: return metadata.get("requires_python") if metadata else None -def get_installed_package_version(package_name: str) -> Optional[str]: +def _try_get_package_version(package_name: str) -> Optional[str]: """ - Get installed version of a package - + Try to get version of a package using pip show. + Args: - package_name: Package name (may contain hyphens or underscores) - + package_name: Package name to check + Returns: - Installed version string, or None if not installed + Version string if found, None otherwise """ - # Try original name first cmd = [sys.executable, "-m", "pip", "show", package_name] try: result = subprocess.run( @@ -223,57 +316,39 @@ def get_installed_package_version(package_name: str) -> Optional[str]: for line in result.stdout.splitlines(): if line.startswith("Version:"): return line.split(":", 1)[1].strip() - except ( - subprocess.CalledProcessError, - subprocess.TimeoutExpired, - Exception, - ): - pass + except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as e: + logger.debug(f"Failed to get version for {package_name}: {e}") + except Exception as e: + logger.warning(f"Unexpected error getting version for {package_name}: {e}") + return None - # Try with underscores replaced by hyphens - normalized = package_name.replace("_", "-") - if normalized != package_name: - cmd = [sys.executable, "-m", "pip", "show", normalized] - try: - result = subprocess.run( - cmd, capture_output=True, text=True, check=True, timeout=5 - ) - for line in result.stdout.splitlines(): - if line.startswith("Version:"): - return line.split(":", 1)[1].strip() - except ( - subprocess.CalledProcessError, - subprocess.TimeoutExpired, - Exception, - ): - pass - - # Try with hyphens replaced by underscores - normalized = package_name.replace("-", "_") - if normalized != package_name: - cmd = [sys.executable, "-m", "pip", "show", normalized] - try: - result = subprocess.run( - cmd, capture_output=True, text=True, check=True, timeout=5 - ) - for line in result.stdout.splitlines(): - if line.startswith("Version:"): - return line.split(":", 1)[1].strip() - except ( - subprocess.CalledProcessError, - subprocess.TimeoutExpired, - Exception, - ): - pass +def get_installed_package_version(package_name: str) -> Optional[str]: + """ + Get installed version of a package. + + Tries multiple name variants (with underscores/hyphens) to handle + different naming conventions. + + Args: + package_name: Package name (may contain hyphens or underscores) + + Returns: + Installed version string, or None if not installed + """ + variants = get_package_name_variants(package_name) + for variant in variants: + version = _try_get_package_version(variant) + if version: + return version return None def _is_library_installed(req_str: str) -> bool: """ - Check if a library is installed and version satisfies requirement + Check if a library is installed and version satisfies requirement. - Similar to opentelemetry-bootstrap's _is_installed function + Similar to opentelemetry-bootstrap's _is_installed function. Args: req_str: Requirement string (e.g., "redis >= 2.6") @@ -285,32 +360,22 @@ def _is_library_installed(req_str: str) -> bool: req = Requirement(req_str) package_name = req.name - # Try original name first + # get_installed_package_version already tries multiple variants dist_version = get_installed_package_version(package_name) - # If not found, try normalized versions (handle underscores vs hyphens) - if dist_version is None: - normalized = package_name.replace("_", "-") - if normalized != package_name: - dist_version = get_installed_package_version(normalized) - - if dist_version is None: - normalized = package_name.replace("-", "_") - if normalized != package_name: - dist_version = get_installed_package_version(normalized) - if dist_version is None: return False # Check if installed version satisfies requirement return req.specifier.contains(dist_version) - except Exception: + except Exception as e: + logger.debug(f"Failed to check if library is installed for {req_str}: {e}") return False def _is_instrumentation_in_bootstrap_gen(package_name: str) -> bool: """ - Check if a package is an instrumentation listed in bootstrap_gen.py + Check if a package is an instrumentation listed in bootstrap_gen.py. Args: package_name: Package name to check @@ -321,41 +386,19 @@ def _is_instrumentation_in_bootstrap_gen(package_name: str) -> bool: if not package_name: return False - normalized_name = package_name.replace("_", "-") - # Check default instrumentations for default_instr in gen_default_instrumentations: if isinstance(default_instr, str): - default_pkg_name = ( - default_instr.split("==")[0] - .split(">=")[0] - .split("<=")[0] - .split("~=")[0] - .split("!=")[0] - .strip() - ) - if ( - default_pkg_name == normalized_name - or default_pkg_name == package_name - ): + default_pkg_name = extract_package_name_from_requirement(default_instr) + if package_names_match(default_pkg_name, package_name): return True # Check libraries mapping for lib_mapping in gen_libraries: instrumentation = lib_mapping.get("instrumentation", "") if isinstance(instrumentation, str): - instr_pkg_name = ( - instrumentation.split("==")[0] - .split(">=")[0] - .split("<=")[0] - .split("~=")[0] - .split("!=")[0] - .strip() - ) - if ( - instr_pkg_name == normalized_name - or instr_pkg_name == package_name - ): + instr_pkg_name = extract_package_name_from_requirement(instrumentation) + if package_names_match(instr_pkg_name, package_name): return True return False @@ -365,7 +408,7 @@ def get_target_libraries_from_bootstrap_gen( package_name: str, ) -> Tuple[List[str], bool]: """ - Get target library requirements from bootstrap_gen.py + Get target library requirements from bootstrap_gen.py. This function uses the pre-generated bootstrap_gen.py file to get target library information, similar to opentelemetry-bootstrap. @@ -382,24 +425,11 @@ def get_target_libraries_from_bootstrap_gen( if not package_name: return [], False - # Normalize package name: convert underscores to hyphens for matching - normalized_name = package_name.replace("_", "-") - # Check if it's a default instrumentation for default_instr in gen_default_instrumentations: if isinstance(default_instr, str): - default_pkg_name = ( - default_instr.split("==")[0] - .split(">=")[0] - .split("<=")[0] - .split("~=")[0] - .split("!=")[0] - .strip() - ) - if ( - default_pkg_name == normalized_name - or default_pkg_name == package_name - ): + default_pkg_name = extract_package_name_from_requirement(default_instr) + if package_names_match(default_pkg_name, package_name): return [], True # Look up in libraries mapping @@ -407,18 +437,8 @@ def get_target_libraries_from_bootstrap_gen( for lib_mapping in gen_libraries: instrumentation = lib_mapping.get("instrumentation", "") if isinstance(instrumentation, str): - instr_pkg_name = ( - instrumentation.split("==")[0] - .split(">=")[0] - .split("<=")[0] - .split("~=")[0] - .split("!=")[0] - .strip() - ) - if ( - instr_pkg_name == normalized_name - or instr_pkg_name == package_name - ): + instr_pkg_name = extract_package_name_from_requirement(instrumentation) + if package_names_match(instr_pkg_name, package_name): target_lib = lib_mapping.get("library", "") if target_lib and isinstance(target_lib, str): target_libraries.append(target_lib) @@ -718,14 +738,6 @@ def get_installed_loongsuite_packages() -> List[str]: Returns: List of installed package names to uninstall """ - # Packages to exclude from uninstallation - EXCLUDED_PACKAGES = { - "loongsuite-distro", - "opentelemetry-api", - "opentelemetry-sdk", - "opentelemetry-instrumentation", - } - cmd = [sys.executable, "-m", "pip", "list", "--format=json"] try: result = subprocess.run( @@ -740,7 +752,7 @@ def get_installed_loongsuite_packages() -> List[str]: name_lower = name.lower() # Skip excluded packages - if name_lower in EXCLUDED_PACKAGES: + if name_lower in UNINSTALL_EXCLUDED_PACKAGES: continue # Include loongsuite-* packages (except loongsuite-distro) From 014452e96e42cd3bb62f34740de8cfa01ecd519d Mon Sep 17 00:00:00 2001 From: cirilla-zmh Date: Mon, 12 Jan 2026 09:54:15 +0800 Subject: [PATCH 09/16] Fix mcp instrumentation Change-Id: I6506a60a343036a43089982aa52d4a7165db9b0d Co-developed-by: Cursor --- .../instrumentation/agentscope/__init__.py | 12 +++++++++++ .../pyproject.toml | 2 +- .../instrumentation/mcp/__init__.py | 9 +++++--- .../instrumentation/mcp/utils.py | 21 ++++++++++++++++++- .../src/loongsuite/distro/bootstrap.py | 2 +- 5 files changed, 40 insertions(+), 6 deletions(-) diff --git a/instrumentation-loongsuite/loongsuite-instrumentation-agentscope/src/opentelemetry/instrumentation/agentscope/__init__.py b/instrumentation-loongsuite/loongsuite-instrumentation-agentscope/src/opentelemetry/instrumentation/agentscope/__init__.py index de6799666..7fc1c8bdb 100644 --- a/instrumentation-loongsuite/loongsuite-instrumentation-agentscope/src/opentelemetry/instrumentation/agentscope/__init__.py +++ b/instrumentation-loongsuite/loongsuite-instrumentation-agentscope/src/opentelemetry/instrumentation/agentscope/__init__.py @@ -203,6 +203,18 @@ def wrap_formatter_with_tracer(wrapped, instance, args, kwargs): logger.debug("Patched setup_tracing") except Exception as e: logger.warning(f"Failed to patch setup_tracing: {e}") + + # Patch _check_tracing_enabled to return False + # We always want to disable tracing in native AgentScope library + try: + wrap_function_wrapper( + module="agentscope.tracing._trace", + name="_check_tracing_enabled", + wrapper=self._check_tracing_enabled_patch, + ) + logger.debug("Patched _check_tracing_enabled") + except Exception as e: + logger.warning(f"Failed to patch _check_tracing_enabled: {e}") # Patch _check_tracing_enabled to return False # We always want to disable tracing in native AgentScope library diff --git a/instrumentation-loongsuite/loongsuite-instrumentation-mcp/pyproject.toml b/instrumentation-loongsuite/loongsuite-instrumentation-mcp/pyproject.toml index 560692f19..e106d2074 100644 --- a/instrumentation-loongsuite/loongsuite-instrumentation-mcp/pyproject.toml +++ b/instrumentation-loongsuite/loongsuite-instrumentation-mcp/pyproject.toml @@ -31,7 +31,7 @@ dependencies = [ [project.optional-dependencies] instruments = [ - "mcp >= 1.3.0, <= 1.13.1", + "mcp >= 1.3.0, <= 1.25.0", ] test = [ "opentelemetry-sdk", diff --git a/instrumentation-loongsuite/loongsuite-instrumentation-mcp/src/opentelemetry/instrumentation/mcp/__init__.py b/instrumentation-loongsuite/loongsuite-instrumentation-mcp/src/opentelemetry/instrumentation/mcp/__init__.py index 0f589b6ba..2213faff5 100644 --- a/instrumentation-loongsuite/loongsuite-instrumentation-mcp/src/opentelemetry/instrumentation/mcp/__init__.py +++ b/instrumentation-loongsuite/loongsuite-instrumentation-mcp/src/opentelemetry/instrumentation/mcp/__init__.py @@ -19,6 +19,7 @@ ) from opentelemetry.instrumentation.mcp.utils import ( _get_logger, + _get_streamable_http_client_name, _is_version_supported, _is_ws_installed, ) @@ -53,6 +54,8 @@ for method_name, rpc_name in RPC_NAME_MAPPING.items() ] +_streamable_http_client_name = _get_streamable_http_client_name() + class MCPInstrumentor(BaseInstrumentor): """ @@ -99,7 +102,7 @@ def _instrument(self, **kwargs: Any) -> None: ) wrap_function_wrapper( module="mcp.client.streamable_http", - name="streamablehttp_client", + name=_streamable_http_client_name, wrapper=streamable_http_client_wrapper(), ) wrap_function_wrapper( @@ -140,10 +143,10 @@ def _uninstrument(self, **kwargs: Any) -> None: try: import mcp.client.streamable_http # noqa: PLC0415 - unwrap(mcp.client.streamable_http, "streamablehttp_client") + unwrap(mcp.client.streamable_http, _streamable_http_client_name) except Exception: logger.warning( - "Fail to uninstrument streamablehttp_client", exc_info=True + "Fail to uninstrument streamable_http_client", exc_info=True ) try: diff --git a/instrumentation-loongsuite/loongsuite-instrumentation-mcp/src/opentelemetry/instrumentation/mcp/utils.py b/instrumentation-loongsuite/loongsuite-instrumentation-mcp/src/opentelemetry/instrumentation/mcp/utils.py index 42655cdc3..23c33e243 100644 --- a/instrumentation-loongsuite/loongsuite-instrumentation-mcp/src/opentelemetry/instrumentation/mcp/utils.py +++ b/instrumentation-loongsuite/loongsuite-instrumentation-mcp/src/opentelemetry/instrumentation/mcp/utils.py @@ -18,10 +18,17 @@ _has_mcp_types = False MIN_SUPPORTED_VERSION = (1, 3, 0) -MAX_SUPPORTED_VERSION = (1, 13, 1) +MAX_SUPPORTED_VERSION = (1, 25, 0) MCP_PACKAGE_NAME = "mcp" DEFAULT_MAX_ATTRIBUTE_LENGTH = 1024 * 1024 +# Version thresholds for API changes +# v1.24.0: streamable_http_client was added (streamablehttp_client deprecated) +STREAMABLE_HTTP_CLIENT_NEW_NAME_VERSION = (1, 24, 0) +# Streamable HTTP client function names +STREAMABLE_HTTP_CLIENT_NEW_NAME = "streamable_http_client" +STREAMABLE_HTTP_CLIENT_OLD_NAME = "streamablehttp_client" + _max_attributes_length = None @@ -77,6 +84,18 @@ def _is_version_supported() -> bool: ) +def _get_streamable_http_client_name() -> str: + """ + Get the correct streamable HTTP client function name based on MCP version. + - v1.24.0+: uses `streamable_http_client` (new name) + - v1.3.0 - v1.23.x: uses `streamablehttp_client` (old name) + """ + current_version = _get_mcp_version() + if current_version >= STREAMABLE_HTTP_CLIENT_NEW_NAME_VERSION: + return STREAMABLE_HTTP_CLIENT_NEW_NAME + return STREAMABLE_HTTP_CLIENT_OLD_NAME + + def _is_capture_content_enabled() -> bool: capture_content = environ.get( MCPEnvironmentVariables.CAPTURE_INPUT_ENABLED, "true" diff --git a/loongsuite-distro/src/loongsuite/distro/bootstrap.py b/loongsuite-distro/src/loongsuite/distro/bootstrap.py index 7745f399f..1470e1c0b 100644 --- a/loongsuite-distro/src/loongsuite/distro/bootstrap.py +++ b/loongsuite-distro/src/loongsuite/distro/bootstrap.py @@ -105,7 +105,7 @@ def extract_package_name_from_requirement(req_str: str) -> str: Examples: "redis >= 2.6" -> "redis" - "opentelemetry-instrumentation==0.60b1" -> "opentelemetry-instrumentation" + "opentelemetry-instrumentation==0.60b0" -> "opentelemetry-instrumentation" "package-name~=1.0" -> "package-name" Args: From 214316aa20c6af7e4cb446a68d619bb87b13bd33 Mon Sep 17 00:00:00 2001 From: cirilla-zmh Date: Sat, 14 Feb 2026 10:04:54 +0800 Subject: [PATCH 10/16] Add script to sych upstream Change-Id: Ib7fb5d4584442ed4ccfbcfbb3ad9268eecde0aa0 Co-developed-by: Cursor --- .github/scripts/sync-upstream-with-rebase.sh | 318 ++++++++++++++++++ .github/workflows/sync-upstream.yml | 66 ++++ .../instrumentation/agentscope/__init__.py | 2 +- .../src/loongsuite/distro/bootstrap.py | 52 +-- .../src/loongsuite/distro/bootstrap_gen.py | 276 ++++++++++++--- scripts/generate_loongsuite_bootstrap.py | 136 +++++--- scripts/generate_loongsuite_readme.py | 19 +- .../README-loongsuite.rst | 33 ++ .../scripts/generate_version_mapping_table.py | 122 +++++++ .../scripts/update_upstream_version_map.py | 110 ++++++ .../upstream_version_map.json | 17 + 11 files changed, 1021 insertions(+), 130 deletions(-) create mode 100755 .github/scripts/sync-upstream-with-rebase.sh create mode 100644 .github/workflows/sync-upstream.yml create mode 100644 util/opentelemetry-util-genai/scripts/generate_version_mapping_table.py create mode 100644 util/opentelemetry-util-genai/scripts/update_upstream_version_map.py create mode 100644 util/opentelemetry-util-genai/upstream_version_map.json diff --git a/.github/scripts/sync-upstream-with-rebase.sh b/.github/scripts/sync-upstream-with-rebase.sh new file mode 100755 index 000000000..60232fc11 --- /dev/null +++ b/.github/scripts/sync-upstream-with-rebase.sh @@ -0,0 +1,318 @@ +#!/usr/bin/env bash +# +# Sync upstream changes into a local branch via "git merge" (not rebase). +# +# Why merge instead of rebase? +# - After merge, the sync branch is a direct descendant of the local base +# branch, so merging the sync branch back into main is always clean. +# - Upstream commits are preserved in the merge history; next time we run +# "git merge upstream/main", Git automatically skips already-merged commits. +# - Conflicts only need to be resolved once (during the merge), not twice +# (once during rebase, once when merging back). + +set -euo pipefail + +SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +REPO_ROOT=$(cd "$SCRIPT_DIR/../.." && pwd) + +UPSTREAM_REMOTE="upstream" +UPSTREAM_URL="" +UPSTREAM_BRANCH="main" +UPSTREAM_COMMIT="" # if set, merge this commit instead of upstream/branch tip +BASE_BRANCH="main" +SYNC_BRANCH="" +RESUME=false +SKIP_PR=false +SKIP_MAPPING_UPDATE=false + +MAPPING_FILE="$REPO_ROOT/util/opentelemetry-util-genai/upstream_version_map.json" +VERSION_FILE="$REPO_ROOT/util/opentelemetry-util-genai/src/opentelemetry/util/genai/version.py" +UPDATE_MAPPING_SCRIPT="$REPO_ROOT/util/opentelemetry-util-genai/scripts/update_upstream_version_map.py" +GENERATE_TABLE_SCRIPT="$REPO_ROOT/util/opentelemetry-util-genai/scripts/generate_version_mapping_table.py" +README_FILE="$REPO_ROOT/util/opentelemetry-util-genai/README-loongsuite.rst" + +usage() { + cat <<'EOF' +Usage: + .github/scripts/sync-upstream-with-rebase.sh [options] + +Options: + --upstream-remote Upstream git remote name (default: upstream) + --upstream-url Upstream remote URL (required when remote does not exist) + --upstream-branch Upstream branch name (default: main) + --upstream-commit Sync to this specific commit instead of branch tip + --base-branch Local base branch name (default: main) + --sync-branch Sync working branch (default: auto-generated) + --resume Continue after manual conflict resolution + --skip-pr Do not create pull request automatically + --skip-mapping-update Do not update util-genai upstream mapping files + -h, --help Show this help message + +Typical flow: + 1) Start sync: + .github/scripts/sync-upstream-with-rebase.sh --upstream-url + + 2) Sync to a specific upstream commit: + .github/scripts/sync-upstream-with-rebase.sh --upstream-url \\ + --upstream-commit + + 3) If conflict occurs, resolve conflicts, then: + git add + git commit # finish the merge commit + .github/scripts/sync-upstream-with-rebase.sh --resume --sync-branch + (or /tmp/sync-upstream-with-rebase.sh if script not on current branch) +EOF +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --upstream-remote) + UPSTREAM_REMOTE="$2" + shift 2 + ;; + --upstream-url) + UPSTREAM_URL="$2" + shift 2 + ;; + --upstream-branch) + UPSTREAM_BRANCH="$2" + shift 2 + ;; + --upstream-commit) + UPSTREAM_COMMIT="$2" + shift 2 + ;; + --base-branch) + BASE_BRANCH="$2" + shift 2 + ;; + --sync-branch) + SYNC_BRANCH="$2" + shift 2 + ;; + --resume) + RESUME=true + shift + ;; + --skip-pr) + SKIP_PR=true + shift + ;; + --skip-mapping-update) + SKIP_MAPPING_UPDATE=true + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown option: $1" + usage + exit 1 + ;; + esac +done + +cd "$REPO_ROOT" + +require_command() { + if ! command -v "$1" >/dev/null 2>&1; then + echo "Missing required command: $1" + exit 1 + fi +} + +is_merge_in_progress() { + [[ -f .git/MERGE_HEAD ]] +} + +ensure_clean_worktree() { + if [[ -n $(git status --porcelain) ]]; then + echo "Worktree is not clean. Please commit or stash changes first." + exit 1 + fi +} + +ensure_remote() { + if git remote get-url "$UPSTREAM_REMOTE" >/dev/null 2>&1; then + return + fi + if [[ -z "$UPSTREAM_URL" ]]; then + echo "Remote '$UPSTREAM_REMOTE' not found. Please provide --upstream-url." + exit 1 + fi + git remote add "$UPSTREAM_REMOTE" "$UPSTREAM_URL" +} + +# Resolved ref to merge (either a specific commit or branch tip) +get_upstream_target() { + if [[ -n "$UPSTREAM_COMMIT" ]]; then + git rev-parse --verify "$UPSTREAM_COMMIT" + else + echo "${UPSTREAM_REMOTE}/${UPSTREAM_BRANCH}" + fi +} + +extract_upstream_version() { + local target + target=$(get_upstream_target) + git show "${target}:util/opentelemetry-util-genai/src/opentelemetry/util/genai/version.py" \ + | python3 -c 'import re,sys; m=re.search(r"__version__\s*=\s*\"([^\"]+)\"", sys.stdin.read()); print(m.group(1) if m else "unknown")' +} + +update_mapping_and_readme() { + local target upstream_commit upstream_version ref_type ref_val + target=$(get_upstream_target) + upstream_commit=$(git rev-parse "$target") + upstream_version=$(extract_upstream_version) + + if [[ -n "$UPSTREAM_COMMIT" ]]; then + ref_type="commit" + ref_val="$upstream_commit" + else + ref_type="branch" + ref_val="$UPSTREAM_BRANCH" + fi + + python3 "$UPDATE_MAPPING_SCRIPT" \ + --mapping-file "$MAPPING_FILE" \ + --version-file "$VERSION_FILE" \ + --upstream-version "$upstream_version" \ + --upstream-commit "$upstream_commit" \ + --upstream-ref-type "$ref_type" \ + --upstream-ref "$ref_val" + + python3 "$GENERATE_TABLE_SCRIPT" --mapping "$MAPPING_FILE" --readme "$README_FILE" + + if ! git diff --quiet -- "$MAPPING_FILE" "$README_FILE"; then + git add "$MAPPING_FILE" "$README_FILE" + git commit -m "chore(util-genai): update upstream mapping after sync" + fi +} + +push_branch() { + git push -u origin "$SYNC_BRANCH" +} + +create_pr() { + if [[ "$SKIP_PR" == true ]]; then + return + fi + require_command gh + local title body upstream_desc + upstream_desc="${UPSTREAM_REMOTE}/${UPSTREAM_BRANCH}" + if [[ -n "$UPSTREAM_COMMIT" ]]; then + upstream_desc="commit $(git rev-parse --short "$UPSTREAM_COMMIT") (from ${UPSTREAM_REMOTE}/${UPSTREAM_BRANCH})" + fi + title="chore: sync ${upstream_desc} into ${BASE_BRANCH}" + body=$( + cat </dev/null 2>&1; then + echo "PR for branch $SYNC_BRANCH already exists, skipping creation." + return + fi + + gh pr create --base "$BASE_BRANCH" --head "$SYNC_BRANCH" --title "$title" --body "$body" +} + +# ── Main ────────────────────────────────────────────────────────────── + +require_command git +require_command python3 + +ensure_remote +git fetch origin "$BASE_BRANCH" +git fetch "$UPSTREAM_REMOTE" "$UPSTREAM_BRANCH" +# When --upstream-commit is set, ensure we have that commit (might need full fetch) +if [[ -n "$UPSTREAM_COMMIT" ]]; then + if ! git rev-parse --verify "$UPSTREAM_COMMIT" >/dev/null 2>&1; then + echo "Commit $UPSTREAM_COMMIT not found. Fetching all upstream refs..." + git fetch "$UPSTREAM_REMOTE" + fi + if ! git rev-parse --verify "$UPSTREAM_COMMIT" >/dev/null 2>&1; then + echo "Error: commit $UPSTREAM_COMMIT does not exist in $UPSTREAM_REMOTE." + exit 1 + fi +fi + +if [[ "$RESUME" == true ]]; then + # ── Resume after manual conflict resolution ── + if [[ -z "$SYNC_BRANCH" ]]; then + SYNC_BRANCH=$(git branch --show-current) + fi + git checkout "$SYNC_BRANCH" + + if is_merge_in_progress; then + echo "Merge is still in progress (MERGE_HEAD exists)." + echo "Please finish the merge commit first:" + echo " git add " + echo " git commit" + echo "Then re-run:" + echo " .github/scripts/sync-upstream-with-rebase.sh --resume --sync-branch $SYNC_BRANCH" + exit 2 + fi + + echo "Merge completed. Proceeding to finalize." + +else + # ── Start a new sync ── + ensure_clean_worktree + + if [[ -z "$SYNC_BRANCH" ]]; then + SYNC_BRANCH="sync/upstream-$(date -u +%Y%m%d-%H%M%S)" + fi + + echo "Creating sync branch: $SYNC_BRANCH (from origin/$BASE_BRANCH)" + git checkout -B "$SYNC_BRANCH" "origin/$BASE_BRANCH" + + UPSTREAM_TARGET=$(get_upstream_target) + if [[ -n "$UPSTREAM_COMMIT" ]]; then + echo "Merging upstream commit $(git rev-parse --short "$UPSTREAM_TARGET") into $SYNC_BRANCH ..." + else + echo "Merging ${UPSTREAM_REMOTE}/${UPSTREAM_BRANCH} into $SYNC_BRANCH ..." + fi + if ! git merge "$UPSTREAM_TARGET" \ + --no-edit \ + -m "Merge upstream $(git rev-parse --short "$UPSTREAM_TARGET") into ${SYNC_BRANCH}"; then + + echo "" + echo "════════════════════════════════════════════════════════════" + echo " Merge conflict detected." + echo "" + echo " Please resolve conflicts manually:" + echo " 1) Fix conflicting files" + echo " 2) git add " + echo " 3) git commit # finishes the merge commit" + echo " 4) Re-run this script:" + echo " .github/scripts/sync-upstream-with-rebase.sh \\" + echo " --resume --sync-branch $SYNC_BRANCH" + echo "════════════════════════════════════════════════════════════" + exit 2 + fi + + echo "Merge completed without conflicts." +fi + +# ── Finalize: update mapping, push, create PR ── + +if [[ "$SKIP_MAPPING_UPDATE" == false ]]; then + update_mapping_and_readme +fi + +push_branch +create_pr + +echo "" +echo "Done. Sync branch: $SYNC_BRANCH" diff --git a/.github/workflows/sync-upstream.yml b/.github/workflows/sync-upstream.yml new file mode 100644 index 000000000..6941538db --- /dev/null +++ b/.github/workflows/sync-upstream.yml @@ -0,0 +1,66 @@ +name: Sync upstream with rebase + +on: + workflow_dispatch: + inputs: + upstream_url: + description: "Upstream repository URL" + required: true + default: "https://github.com/open-telemetry/opentelemetry-python-contrib.git" + upstream_branch: + description: "Upstream branch to sync from" + required: true + default: "main" + base_branch: + description: "Target base branch in this repo" + required: true + default: "main" + skip_pr: + description: "Skip creating pull request" + required: false + default: false + type: boolean + skip_mapping_update: + description: "Skip util-genai mapping update" + required: false + default: false + type: boolean + +permissions: + contents: write + pull-requests: write + +jobs: + sync: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Configure git identity + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + - name: Run upstream sync + env: + SKIP_PR: ${{ inputs.skip_pr }} + SKIP_MAPPING_UPDATE: ${{ inputs.skip_mapping_update }} + GH_TOKEN: ${{ github.token }} + run: | + chmod +x .github/scripts/sync-upstream-with-rebase.sh + cmd=".github/scripts/sync-upstream-with-rebase.sh \ + --upstream-url ${{ inputs.upstream_url }} \ + --upstream-branch ${{ inputs.upstream_branch }} \ + --base-branch ${{ inputs.base_branch }} \ + --sync-branch sync/upstream-${{ github.run_id }}" + + if [[ "$SKIP_PR" == "true" ]]; then + cmd="$cmd --skip-pr" + fi + if [[ "$SKIP_MAPPING_UPDATE" == "true" ]]; then + cmd="$cmd --skip-mapping-update" + fi + + eval "$cmd" diff --git a/instrumentation-loongsuite/loongsuite-instrumentation-agentscope/src/opentelemetry/instrumentation/agentscope/__init__.py b/instrumentation-loongsuite/loongsuite-instrumentation-agentscope/src/opentelemetry/instrumentation/agentscope/__init__.py index 7fc1c8bdb..ddbb18de9 100644 --- a/instrumentation-loongsuite/loongsuite-instrumentation-agentscope/src/opentelemetry/instrumentation/agentscope/__init__.py +++ b/instrumentation-loongsuite/loongsuite-instrumentation-agentscope/src/opentelemetry/instrumentation/agentscope/__init__.py @@ -203,7 +203,7 @@ def wrap_formatter_with_tracer(wrapped, instance, args, kwargs): logger.debug("Patched setup_tracing") except Exception as e: logger.warning(f"Failed to patch setup_tracing: {e}") - + # Patch _check_tracing_enabled to return False # We always want to disable tracing in native AgentScope library try: diff --git a/loongsuite-distro/src/loongsuite/distro/bootstrap.py b/loongsuite-distro/src/loongsuite/distro/bootstrap.py index 1470e1c0b..d0be5f040 100644 --- a/loongsuite-distro/src/loongsuite/distro/bootstrap.py +++ b/loongsuite-distro/src/loongsuite/distro/bootstrap.py @@ -62,13 +62,13 @@ def normalize_package_name(package_name: str) -> str: """ Normalize package name by converting underscores to hyphens. - + Package names in PyPI use hyphens, but wheel filenames may use underscores. This function ensures consistent package name format. - + Args: package_name: Package name (may contain underscores or hyphens) - + Returns: Normalized package name with hyphens """ @@ -78,13 +78,13 @@ def normalize_package_name(package_name: str) -> str: def get_package_name_variants(package_name: str) -> List[str]: """ Get all possible variants of a package name for lookup. - + This is useful when checking if a package is installed, as package names may be stored with either underscores or hyphens. - + Args: package_name: Package name - + Returns: List of package name variants to try """ @@ -102,15 +102,15 @@ def get_package_name_variants(package_name: str) -> List[str]: def extract_package_name_from_requirement(req_str: str) -> str: """ Extract package name from a requirement string. - + Examples: "redis >= 2.6" -> "redis" "opentelemetry-instrumentation==0.60b0" -> "opentelemetry-instrumentation" "package-name~=1.0" -> "package-name" - + Args: req_str: Requirement string - + Returns: Package name extracted from requirement """ @@ -127,11 +127,11 @@ def extract_package_name_from_requirement(req_str: str) -> str: def package_names_match(name1: str, name2: str) -> bool: """ Check if two package names match (considering normalization). - + Args: name1: First package name name2: Second package name - + Returns: True if names match (after normalization), False otherwise """ @@ -301,10 +301,10 @@ def get_python_requirement_from_whl(whl_path: Path) -> Optional[str]: def _try_get_package_version(package_name: str) -> Optional[str]: """ Try to get version of a package using pip show. - + Args: package_name: Package name to check - + Returns: Version string if found, None otherwise """ @@ -319,14 +319,16 @@ def _try_get_package_version(package_name: str) -> Optional[str]: except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as e: logger.debug(f"Failed to get version for {package_name}: {e}") except Exception as e: - logger.warning(f"Unexpected error getting version for {package_name}: {e}") + logger.warning( + f"Unexpected error getting version for {package_name}: {e}" + ) return None def get_installed_package_version(package_name: str) -> Optional[str]: """ Get installed version of a package. - + Tries multiple name variants (with underscores/hyphens) to handle different naming conventions. @@ -369,7 +371,9 @@ def _is_library_installed(req_str: str) -> bool: # Check if installed version satisfies requirement return req.specifier.contains(dist_version) except Exception as e: - logger.debug(f"Failed to check if library is installed for {req_str}: {e}") + logger.debug( + f"Failed to check if library is installed for {req_str}: {e}" + ) return False @@ -389,7 +393,9 @@ def _is_instrumentation_in_bootstrap_gen(package_name: str) -> bool: # Check default instrumentations for default_instr in gen_default_instrumentations: if isinstance(default_instr, str): - default_pkg_name = extract_package_name_from_requirement(default_instr) + default_pkg_name = extract_package_name_from_requirement( + default_instr + ) if package_names_match(default_pkg_name, package_name): return True @@ -397,7 +403,9 @@ def _is_instrumentation_in_bootstrap_gen(package_name: str) -> bool: for lib_mapping in gen_libraries: instrumentation = lib_mapping.get("instrumentation", "") if isinstance(instrumentation, str): - instr_pkg_name = extract_package_name_from_requirement(instrumentation) + instr_pkg_name = extract_package_name_from_requirement( + instrumentation + ) if package_names_match(instr_pkg_name, package_name): return True @@ -428,7 +436,9 @@ def get_target_libraries_from_bootstrap_gen( # Check if it's a default instrumentation for default_instr in gen_default_instrumentations: if isinstance(default_instr, str): - default_pkg_name = extract_package_name_from_requirement(default_instr) + default_pkg_name = extract_package_name_from_requirement( + default_instr + ) if package_names_match(default_pkg_name, package_name): return [], True @@ -437,7 +447,9 @@ def get_target_libraries_from_bootstrap_gen( for lib_mapping in gen_libraries: instrumentation = lib_mapping.get("instrumentation", "") if isinstance(instrumentation, str): - instr_pkg_name = extract_package_name_from_requirement(instrumentation) + instr_pkg_name = extract_package_name_from_requirement( + instrumentation + ) if package_names_match(instr_pkg_name, package_name): target_lib = lib_mapping.get("library", "") if target_lib and isinstance(target_lib, str): diff --git a/loongsuite-distro/src/loongsuite/distro/bootstrap_gen.py b/loongsuite-distro/src/loongsuite/distro/bootstrap_gen.py index 19e31b0eb..af117506e 100644 --- a/loongsuite-distro/src/loongsuite/distro/bootstrap_gen.py +++ b/loongsuite-distro/src/loongsuite/distro/bootstrap_gen.py @@ -1,4 +1,3 @@ - # Copyright The OpenTelemetry Authors # # Licensed under the Apache License, Version 2.0 (the "License"); @@ -17,61 +16,226 @@ # RUN `python scripts/generate_loongsuite_bootstrap.py` TO REGENERATE. libraries = [ - {"library": "openai >= 1.26.0", "instrumentation": "opentelemetry-instrumentation-openai-v2"}, - {"library": "google-cloud-aiplatform >= 1.64", "instrumentation": "opentelemetry-instrumentation-vertexai>=2.0b0"}, - {"library": "aio_pika >= 7.2.0, < 10.0.0", "instrumentation": "opentelemetry-instrumentation-aio-pika==0.61b0.dev"}, - {"library": "aiohttp ~= 3.0", "instrumentation": "opentelemetry-instrumentation-aiohttp-client==0.61b0.dev"}, - {"library": "aiohttp ~= 3.0", "instrumentation": "opentelemetry-instrumentation-aiohttp-server==0.61b0.dev"}, - {"library": "aiokafka >= 0.8, < 1.0", "instrumentation": "opentelemetry-instrumentation-aiokafka==0.61b0.dev"}, - {"library": "aiopg >= 0.13.0, < 2.0.0", "instrumentation": "opentelemetry-instrumentation-aiopg==0.61b0.dev"}, - {"library": "asgiref ~= 3.0", "instrumentation": "opentelemetry-instrumentation-asgi==0.61b0.dev"}, - {"library": "asyncclick ~= 8.0", "instrumentation": "opentelemetry-instrumentation-asyncclick==0.61b0.dev"}, - {"library": "asyncpg >= 0.12.0", "instrumentation": "opentelemetry-instrumentation-asyncpg==0.61b0.dev"}, - {"library": "boto~=2.0", "instrumentation": "opentelemetry-instrumentation-boto==0.61b0.dev"}, - {"library": "boto3 ~= 1.0", "instrumentation": "opentelemetry-instrumentation-boto3sqs==0.61b0.dev"}, - {"library": "botocore ~= 1.0", "instrumentation": "opentelemetry-instrumentation-botocore==0.61b0.dev"}, - {"library": "cassandra-driver ~= 3.25", "instrumentation": "opentelemetry-instrumentation-cassandra==0.61b0.dev"}, - {"library": "scylla-driver ~= 3.25", "instrumentation": "opentelemetry-instrumentation-cassandra==0.61b0.dev"}, - {"library": "celery >= 4.0, < 6.0", "instrumentation": "opentelemetry-instrumentation-celery==0.61b0.dev"}, - {"library": "click >= 8.1.3, < 9.0.0", "instrumentation": "opentelemetry-instrumentation-click==0.61b0.dev"}, - {"library": "confluent-kafka >= 1.8.2, <= 2.11.0", "instrumentation": "opentelemetry-instrumentation-confluent-kafka==0.61b0.dev"}, - {"library": "django >= 1.10", "instrumentation": "opentelemetry-instrumentation-django==0.61b0.dev"}, - {"library": "elasticsearch >= 6.0", "instrumentation": "opentelemetry-instrumentation-elasticsearch==0.61b0.dev"}, - {"library": "falcon >= 1.4.1, < 5.0.0", "instrumentation": "opentelemetry-instrumentation-falcon==0.61b0.dev"}, - {"library": "fastapi ~= 0.92", "instrumentation": "opentelemetry-instrumentation-fastapi==0.61b0.dev"}, - {"library": "flask >= 1.0", "instrumentation": "opentelemetry-instrumentation-flask==0.61b0.dev"}, - {"library": "grpcio >= 1.42.0", "instrumentation": "opentelemetry-instrumentation-grpc==0.61b0.dev"}, - {"library": "httpx >= 0.18.0", "instrumentation": "opentelemetry-instrumentation-httpx==0.61b0.dev"}, - {"library": "jinja2 >= 2.7, < 4.0", "instrumentation": "opentelemetry-instrumentation-jinja2==0.61b0.dev"}, - {"library": "kafka-python >= 2.0, < 3.0", "instrumentation": "opentelemetry-instrumentation-kafka-python==0.61b0.dev"}, - {"library": "kafka-python-ng >= 2.0, < 3.0", "instrumentation": "opentelemetry-instrumentation-kafka-python==0.61b0.dev"}, - {"library": "mysql-connector-python >= 8.0, < 10.0", "instrumentation": "opentelemetry-instrumentation-mysql==0.61b0.dev"}, - {"library": "mysqlclient < 3", "instrumentation": "opentelemetry-instrumentation-mysqlclient==0.61b0.dev"}, - {"library": "pika >= 0.12.0", "instrumentation": "opentelemetry-instrumentation-pika==0.61b0.dev"}, - {"library": "psycopg >= 3.1.0", "instrumentation": "opentelemetry-instrumentation-psycopg==0.61b0.dev"}, - {"library": "psycopg2 >= 2.7.3.1", "instrumentation": "opentelemetry-instrumentation-psycopg2==0.61b0.dev"}, - {"library": "psycopg2-binary >= 2.7.3.1", "instrumentation": "opentelemetry-instrumentation-psycopg2==0.61b0.dev"}, - {"library": "pymemcache >= 1.3.5, < 5", "instrumentation": "opentelemetry-instrumentation-pymemcache==0.61b0.dev"}, - {"library": "pymongo >= 3.1, < 5.0", "instrumentation": "opentelemetry-instrumentation-pymongo==0.61b0.dev"}, - {"library": "pymssql >= 2.1.5, < 3", "instrumentation": "opentelemetry-instrumentation-pymssql==0.61b0.dev"}, - {"library": "PyMySQL < 2", "instrumentation": "opentelemetry-instrumentation-pymysql==0.61b0.dev"}, - {"library": "pyramid >= 1.7", "instrumentation": "opentelemetry-instrumentation-pyramid==0.61b0.dev"}, - {"library": "redis >= 2.6", "instrumentation": "opentelemetry-instrumentation-redis==0.61b0.dev"}, - {"library": "remoulade >= 0.50", "instrumentation": "opentelemetry-instrumentation-remoulade==0.61b0.dev"}, - {"library": "requests ~= 2.0", "instrumentation": "opentelemetry-instrumentation-requests==0.61b0.dev"}, - {"library": "sqlalchemy >= 1.0.0, < 2.1.0", "instrumentation": "opentelemetry-instrumentation-sqlalchemy==0.61b0.dev"}, - {"library": "starlette >= 0.13", "instrumentation": "opentelemetry-instrumentation-starlette==0.61b0.dev"}, - {"library": "psutil >= 5", "instrumentation": "opentelemetry-instrumentation-system-metrics==0.61b0.dev"}, - {"library": "tornado >= 5.1.1", "instrumentation": "opentelemetry-instrumentation-tornado==0.61b0.dev"}, - {"library": "tortoise-orm >= 0.17.0", "instrumentation": "opentelemetry-instrumentation-tortoiseorm==0.61b0.dev"}, - {"library": "pydantic >= 1.10.2", "instrumentation": "opentelemetry-instrumentation-tortoiseorm==0.61b0.dev"}, - {"library": "urllib3 >= 1.0.0, < 3.0.0", "instrumentation": "opentelemetry-instrumentation-urllib3==0.61b0.dev"}, - {"library": "agentscope >= 1.0.0", "instrumentation": "loongsuite-instrumentation-agentscope==1.0.0"}, - {"library": "agno", "instrumentation": "loongsuite-instrumentation-agno==0.1b0.dev"}, - {"library": "dashscope >= 1.0.0", "instrumentation": "loongsuite-instrumentation-dashscope==0.1.0.dev0"}, - {"library": "langchain_core >= 0.1.0", "instrumentation": "loongsuite-instrumentation-langchain==1.0.0"}, - {"library": "mcp >= 1.3.0, <= 1.13.1", "instrumentation": "loongsuite-instrumentation-mcp==0.1.0"}, - {"library": "mem0ai >= 1.0.0", "instrumentation": "loongsuite-instrumentation-mem0==0.1.0"}, + { + "library": "openai >= 1.26.0", + "instrumentation": "opentelemetry-instrumentation-openai-v2", + }, + { + "library": "google-cloud-aiplatform >= 1.64", + "instrumentation": "opentelemetry-instrumentation-vertexai>=2.0b0", + }, + { + "library": "aio_pika >= 7.2.0, < 10.0.0", + "instrumentation": "opentelemetry-instrumentation-aio-pika==0.61b0.dev", + }, + { + "library": "aiohttp ~= 3.0", + "instrumentation": "opentelemetry-instrumentation-aiohttp-client==0.61b0.dev", + }, + { + "library": "aiohttp ~= 3.0", + "instrumentation": "opentelemetry-instrumentation-aiohttp-server==0.61b0.dev", + }, + { + "library": "aiokafka >= 0.8, < 1.0", + "instrumentation": "opentelemetry-instrumentation-aiokafka==0.61b0.dev", + }, + { + "library": "aiopg >= 0.13.0, < 2.0.0", + "instrumentation": "opentelemetry-instrumentation-aiopg==0.61b0.dev", + }, + { + "library": "asgiref ~= 3.0", + "instrumentation": "opentelemetry-instrumentation-asgi==0.61b0.dev", + }, + { + "library": "asyncclick ~= 8.0", + "instrumentation": "opentelemetry-instrumentation-asyncclick==0.61b0.dev", + }, + { + "library": "asyncpg >= 0.12.0", + "instrumentation": "opentelemetry-instrumentation-asyncpg==0.61b0.dev", + }, + { + "library": "boto~=2.0", + "instrumentation": "opentelemetry-instrumentation-boto==0.61b0.dev", + }, + { + "library": "boto3 ~= 1.0", + "instrumentation": "opentelemetry-instrumentation-boto3sqs==0.61b0.dev", + }, + { + "library": "botocore ~= 1.0", + "instrumentation": "opentelemetry-instrumentation-botocore==0.61b0.dev", + }, + { + "library": "cassandra-driver ~= 3.25", + "instrumentation": "opentelemetry-instrumentation-cassandra==0.61b0.dev", + }, + { + "library": "scylla-driver ~= 3.25", + "instrumentation": "opentelemetry-instrumentation-cassandra==0.61b0.dev", + }, + { + "library": "celery >= 4.0, < 6.0", + "instrumentation": "opentelemetry-instrumentation-celery==0.61b0.dev", + }, + { + "library": "click >= 8.1.3, < 9.0.0", + "instrumentation": "opentelemetry-instrumentation-click==0.61b0.dev", + }, + { + "library": "confluent-kafka >= 1.8.2, <= 2.11.0", + "instrumentation": "opentelemetry-instrumentation-confluent-kafka==0.61b0.dev", + }, + { + "library": "django >= 1.10", + "instrumentation": "opentelemetry-instrumentation-django==0.61b0.dev", + }, + { + "library": "elasticsearch >= 6.0", + "instrumentation": "opentelemetry-instrumentation-elasticsearch==0.61b0.dev", + }, + { + "library": "falcon >= 1.4.1, < 5.0.0", + "instrumentation": "opentelemetry-instrumentation-falcon==0.61b0.dev", + }, + { + "library": "fastapi ~= 0.92", + "instrumentation": "opentelemetry-instrumentation-fastapi==0.61b0.dev", + }, + { + "library": "flask >= 1.0", + "instrumentation": "opentelemetry-instrumentation-flask==0.61b0.dev", + }, + { + "library": "grpcio >= 1.42.0", + "instrumentation": "opentelemetry-instrumentation-grpc==0.61b0.dev", + }, + { + "library": "httpx >= 0.18.0", + "instrumentation": "opentelemetry-instrumentation-httpx==0.61b0.dev", + }, + { + "library": "jinja2 >= 2.7, < 4.0", + "instrumentation": "opentelemetry-instrumentation-jinja2==0.61b0.dev", + }, + { + "library": "kafka-python >= 2.0, < 3.0", + "instrumentation": "opentelemetry-instrumentation-kafka-python==0.61b0.dev", + }, + { + "library": "kafka-python-ng >= 2.0, < 3.0", + "instrumentation": "opentelemetry-instrumentation-kafka-python==0.61b0.dev", + }, + { + "library": "mysql-connector-python >= 8.0, < 10.0", + "instrumentation": "opentelemetry-instrumentation-mysql==0.61b0.dev", + }, + { + "library": "mysqlclient < 3", + "instrumentation": "opentelemetry-instrumentation-mysqlclient==0.61b0.dev", + }, + { + "library": "pika >= 0.12.0", + "instrumentation": "opentelemetry-instrumentation-pika==0.61b0.dev", + }, + { + "library": "psycopg >= 3.1.0", + "instrumentation": "opentelemetry-instrumentation-psycopg==0.61b0.dev", + }, + { + "library": "psycopg2 >= 2.7.3.1", + "instrumentation": "opentelemetry-instrumentation-psycopg2==0.61b0.dev", + }, + { + "library": "psycopg2-binary >= 2.7.3.1", + "instrumentation": "opentelemetry-instrumentation-psycopg2==0.61b0.dev", + }, + { + "library": "pymemcache >= 1.3.5, < 5", + "instrumentation": "opentelemetry-instrumentation-pymemcache==0.61b0.dev", + }, + { + "library": "pymongo >= 3.1, < 5.0", + "instrumentation": "opentelemetry-instrumentation-pymongo==0.61b0.dev", + }, + { + "library": "pymssql >= 2.1.5, < 3", + "instrumentation": "opentelemetry-instrumentation-pymssql==0.61b0.dev", + }, + { + "library": "PyMySQL < 2", + "instrumentation": "opentelemetry-instrumentation-pymysql==0.61b0.dev", + }, + { + "library": "pyramid >= 1.7", + "instrumentation": "opentelemetry-instrumentation-pyramid==0.61b0.dev", + }, + { + "library": "redis >= 2.6", + "instrumentation": "opentelemetry-instrumentation-redis==0.61b0.dev", + }, + { + "library": "remoulade >= 0.50", + "instrumentation": "opentelemetry-instrumentation-remoulade==0.61b0.dev", + }, + { + "library": "requests ~= 2.0", + "instrumentation": "opentelemetry-instrumentation-requests==0.61b0.dev", + }, + { + "library": "sqlalchemy >= 1.0.0, < 2.1.0", + "instrumentation": "opentelemetry-instrumentation-sqlalchemy==0.61b0.dev", + }, + { + "library": "starlette >= 0.13", + "instrumentation": "opentelemetry-instrumentation-starlette==0.61b0.dev", + }, + { + "library": "psutil >= 5", + "instrumentation": "opentelemetry-instrumentation-system-metrics==0.61b0.dev", + }, + { + "library": "tornado >= 5.1.1", + "instrumentation": "opentelemetry-instrumentation-tornado==0.61b0.dev", + }, + { + "library": "tortoise-orm >= 0.17.0", + "instrumentation": "opentelemetry-instrumentation-tortoiseorm==0.61b0.dev", + }, + { + "library": "pydantic >= 1.10.2", + "instrumentation": "opentelemetry-instrumentation-tortoiseorm==0.61b0.dev", + }, + { + "library": "urllib3 >= 1.0.0, < 3.0.0", + "instrumentation": "opentelemetry-instrumentation-urllib3==0.61b0.dev", + }, + { + "library": "agentscope >= 1.0.0", + "instrumentation": "loongsuite-instrumentation-agentscope==1.0.0", + }, + { + "library": "agno", + "instrumentation": "loongsuite-instrumentation-agno==0.1b0.dev", + }, + { + "library": "dashscope >= 1.0.0", + "instrumentation": "loongsuite-instrumentation-dashscope==0.1.0.dev0", + }, + { + "library": "langchain_core >= 0.1.0", + "instrumentation": "loongsuite-instrumentation-langchain==1.0.0", + }, + { + "library": "mcp >= 1.3.0, <= 1.13.1", + "instrumentation": "loongsuite-instrumentation-mcp==0.1.0", + }, + { + "library": "mem0ai >= 1.0.0", + "instrumentation": "loongsuite-instrumentation-mem0==0.1.0", + }, ] default_instrumentations = [ diff --git a/scripts/generate_loongsuite_bootstrap.py b/scripts/generate_loongsuite_bootstrap.py index 98848958f..ef4028c7f 100644 --- a/scripts/generate_loongsuite_bootstrap.py +++ b/scripts/generate_loongsuite_bootstrap.py @@ -24,7 +24,9 @@ independent_packages, packages_to_exclude, ) -from otel_packaging import get_instrumentation_packages as get_upstream_packages +from otel_packaging import ( + get_instrumentation_packages as get_upstream_packages, +) logging.basicConfig(level=logging.INFO) logger = logging.getLogger("loongsuite_bootstrap_generator") @@ -47,32 +49,45 @@ default_instrumentations = [] """ -gen_path = root_path / "loongsuite-distro" / "src" / "loongsuite" / "distro" / "bootstrap_gen.py" +gen_path = ( + root_path + / "loongsuite-distro" + / "src" + / "loongsuite" + / "distro" + / "bootstrap_gen.py" +) def get_instrumentation_packages(): """Get all instrumentation packages from various directories""" packages = [] - + logger.info("Scanning instrumentation packages...") - + # Get packages from upstream directories (instrumentation, instrumentation-genai) # using otel_packaging - logger.info("Processing upstream packages (instrumentation, instrumentation-genai)...") - for pkg in get_upstream_packages(independent_packages=independent_packages): + logger.info( + "Processing upstream packages (instrumentation, instrumentation-genai)..." + ) + for pkg in get_upstream_packages( + independent_packages=independent_packages + ): if pkg["name"] not in packages_to_exclude: packages.append(pkg) - + # Scan instrumentation-loongsuite directory (reuse same logic as otel_packaging) loongsuite_dir = root_path / "instrumentation-loongsuite" if loongsuite_dir.exists(): - logger.info("Processing loongsuite packages (instrumentation-loongsuite)...") + logger.info( + "Processing loongsuite packages (instrumentation-loongsuite)..." + ) pkg_dirs = sorted([d for d in loongsuite_dir.iterdir() if d.is_dir()]) for pkg_dir in pkg_dirs: pyproject_toml = pkg_dir / "pyproject.toml" if not pyproject_toml.exists(): continue - + try: # Get version using hatch command (same as otel_packaging) # Suppress hatch's verbose output @@ -82,43 +97,51 @@ def get_instrumentation_packages(): cwd=pkg_dir, universal_newlines=True, ).strip() - + # Read pyproject.toml with open(pyproject_toml, "rb") as f: pyproject = tomli.load(f) - + pkg_name = pyproject["project"]["name"] - + # Skip if this package is in the exclusion list if pkg_name in packages_to_exclude: continue - + # Get optional dependencies optional_deps = pyproject["project"]["optional-dependencies"] instruments = optional_deps.get("instruments", []) instruments_any = optional_deps.get("instruments-any", []) - + # Handle independent packages if pkg_name in independent_packages: specifier = independent_packages[pkg_name] - requirement = f"{pkg_name}{specifier}" if specifier else f"{pkg_name}=={version}" + requirement = ( + f"{pkg_name}{specifier}" + if specifier + else f"{pkg_name}=={version}" + ) else: requirement = f"{pkg_name}=={version}" - - packages.append({ - "name": pkg_name, - "version": version, - "instruments": instruments, - "instruments-any": instruments_any, - "requirement": requirement, - }) + + packages.append( + { + "name": pkg_name, + "version": version, + "instruments": instruments, + "instruments-any": instruments_any, + "requirement": requirement, + } + ) except subprocess.CalledProcessError as e: - logger.warning(f"Could not get hatch version from {pkg_dir.name}: {e}") + logger.warning( + f"Could not get hatch version from {pkg_dir.name}: {e}" + ) continue except Exception as e: logger.warning(f"Failed to process {pkg_dir.name}: {e}") continue - + return packages @@ -130,15 +153,15 @@ def main(): header = f.read() else: header = "# Copyright The OpenTelemetry Authors\n# Licensed under the Apache License, Version 2.0\n" - + # Get all packages packages = get_instrumentation_packages() logger.info(f"Found {len(packages)} instrumentation packages") - + # Build AST nodes default_instrumentations = ast.List(elts=[]) libraries = ast.List(elts=[]) - + logger.info("Building bootstrap configuration...") for pkg in packages: # If no instruments and no instruments-any, it's a default instrumentation @@ -161,7 +184,7 @@ def main(): ], ) ) - + # Add instruments-any (at least one must be installed) for target_pkg in pkg["instruments-any"]: libraries.elts.append( @@ -176,7 +199,7 @@ def main(): ], ) ) - + # Generate source code manually (avoiding astor dependency) logger.info("Generating source code...") # Build libraries list string @@ -188,20 +211,42 @@ def main(): instr_key = lib_mapping.keys[1] lib_val_node = lib_mapping.values[0] instr_val_node = lib_mapping.values[1] - + # Get string values - lib_key_str = lib_key.value if isinstance(lib_key, ast.Constant) else (lib_key.s if hasattr(lib_key, 's') else "library") - instr_key_str = instr_key.value if isinstance(instr_key, ast.Constant) else (instr_key.s if hasattr(instr_key, 's') else "instrumentation") - lib_val_str = lib_val_node.value if isinstance(lib_val_node, ast.Constant) else (lib_val_node.s if hasattr(lib_val_node, 's') else "") - instr_val_str = instr_val_node.value if isinstance(instr_val_node, ast.Constant) else (instr_val_node.s if hasattr(instr_val_node, 's') else "") - + lib_key_str = ( + lib_key.value + if isinstance(lib_key, ast.Constant) + else (lib_key.s if hasattr(lib_key, "s") else "library") + ) + instr_key_str = ( + instr_key.value + if isinstance(instr_key, ast.Constant) + else ( + instr_key.s + if hasattr(instr_key, "s") + else "instrumentation" + ) + ) + lib_val_str = ( + lib_val_node.value + if isinstance(lib_val_node, ast.Constant) + else (lib_val_node.s if hasattr(lib_val_node, "s") else "") + ) + instr_val_str = ( + instr_val_node.value + if isinstance(instr_val_node, ast.Constant) + else (instr_val_node.s if hasattr(instr_val_node, "s") else "") + ) + # Escape quotes in values lib_val_str = lib_val_str.replace('"', '\\"') instr_val_str = instr_val_str.replace('"', '\\"') - - libraries_lines.append(f' {{"{lib_key_str}": "{lib_val_str}", "{instr_key_str}": "{instr_val_str}"}},') + + libraries_lines.append( + f' {{"{lib_key_str}": "{lib_val_str}", "{instr_key_str}": "{instr_val_str}"}},' + ) libraries_lines.append("]") - + # Build default_instrumentations list string default_lines = ["default_instrumentations = ["] for default_instr in default_instrumentations.elts: @@ -209,23 +254,24 @@ def main(): instr_val = default_instr.value.replace('"', '\\"') default_lines.append(f' "{instr_val}",') default_lines.append("]") - + # Combine source source = "\n".join(libraries_lines) + "\n\n" + "\n".join(default_lines) - + # Format with header formatted_source = _template.format(header=header, source=source) - + # Write to file gen_path.parent.mkdir(parents=True, exist_ok=True) with open(gen_path, "w", encoding="utf-8") as f: f.write(formatted_source) - + logger.info("generated %s", gen_path) - logger.info(" - %d default instrumentations", len(default_instrumentations.elts)) + logger.info( + " - %d default instrumentations", len(default_instrumentations.elts) + ) logger.info(" - %d library mappings", len(libraries.elts)) if __name__ == "__main__": main() - diff --git a/scripts/generate_loongsuite_readme.py b/scripts/generate_loongsuite_readme.py index f89cdeae8..62cf70549 100644 --- a/scripts/generate_loongsuite_readme.py +++ b/scripts/generate_loongsuite_readme.py @@ -49,27 +49,27 @@ def main(base_instrumentation_path): try: with open(pyproject_toml, "rb") as f: pyproject = tomli.load(f) - + project = pyproject.get("project", {}) optional_deps = project.get("optional-dependencies", {}) instruments = optional_deps.get("instruments", []) instruments_any = optional_deps.get("instruments-any", []) - + # Extract package name from instrumentation directory name # e.g., "loongsuite-instrumentation-agentscope" -> "agentscope" name = instrumentation.replace(_prefix, "") - + instruments_all = () if not instruments and not instruments_any: instruments_all = (name,) else: instruments_all = tuple(instruments + instruments_any) - + # Try to get metrics support and semconv status from pyproject.toml # These might not be present, so use defaults supports_metrics = project.get("supports_metrics", False) semconv_status = project.get("semconv_status", "development") - + metric_column = "Yes" if supports_metrics else "No" table.append( @@ -87,9 +87,12 @@ def main(base_instrumentation_path): if __name__ == "__main__": root_path = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) - instrumentation_path = os.path.join(root_path, "instrumentation-loongsuite") + instrumentation_path = os.path.join( + root_path, "instrumentation-loongsuite" + ) if os.path.exists(instrumentation_path): main(instrumentation_path) else: - logger.warning(f"Instrumentation path does not exist: {instrumentation_path}") - + logger.warning( + f"Instrumentation path does not exist: {instrumentation_path}" + ) diff --git a/util/opentelemetry-util-genai/README-loongsuite.rst b/util/opentelemetry-util-genai/README-loongsuite.rst index e49ed4e7a..95434d98e 100644 --- a/util/opentelemetry-util-genai/README-loongsuite.rst +++ b/util/opentelemetry-util-genai/README-loongsuite.rst @@ -623,3 +623,36 @@ OpenTelemetry GenAI Utils 的设计文档: `Design Document `_ * `LoongSuite OpenTelemetry Python Agent `_ + +版本映射(自动生成) +--------------------- + +以下内容由 ``scripts/generate_version_mapping_table.py`` 根据 +``upstream_version_map.json`` 自动生成,请不要手动编辑该区块。 + +.. BEGIN AUTO-GENERATED VERSION MAPPING +.. This section is generated by scripts/generate_version_mapping_table.py + +- 组件: ``loongsuite-util-genai`` +- 上游仓库: ``https://github.com/open-telemetry/opentelemetry-python-contrib`` +- 上游路径: ``util/opentelemetry-util-genai`` +- 映射格式版本: ``1`` +- 生成时间(UTC): ``2026-02-13T09:41:34+00:00`` + +.. list-table:: 版本映射 + :header-rows: 1 + + * - LoongSuite 版本 + - OTel util-genai 版本 + - OTel commit + - 上游来源 + - 同步日期 + - 说明 + * - ``0.3b0.dev2026021101`` + - ``0.3b0.dev`` + - ``REPLACE_WITH_REAL_COMMIT_SHA`` + - ``branch:main`` + - ``2026-02-11`` + - Prototype baseline mapping. Replace commit SHA before first official release. + +.. END AUTO-GENERATED VERSION MAPPING diff --git a/util/opentelemetry-util-genai/scripts/generate_version_mapping_table.py b/util/opentelemetry-util-genai/scripts/generate_version_mapping_table.py new file mode 100644 index 000000000..d6baaed6b --- /dev/null +++ b/util/opentelemetry-util-genai/scripts/generate_version_mapping_table.py @@ -0,0 +1,122 @@ +#!/usr/bin/env python3 +"""Generate upstream version mapping table for README-loongsuite.rst.""" + +from __future__ import annotations + +import argparse +import datetime as dt +import json +from pathlib import Path +from typing import Any + +BEGIN_MARKER = ".. BEGIN AUTO-GENERATED VERSION MAPPING" +END_MARKER = ".. END AUTO-GENERATED VERSION MAPPING" + + +def _load_mapping(mapping_path: Path) -> dict[str, Any]: + with mapping_path.open("r", encoding="utf-8") as f: + return json.load(f) + + +def _format_cell(value: Any) -> str: + if value is None: + return "-" + text = str(value).strip() + return text if text else "-" + + +def _render_generated_block(data: dict[str, Any], generated_at: str) -> str: + component = _format_cell(data.get("component")) + upstream_repo = _format_cell(data.get("upstream_repository")) + upstream_path = _format_cell(data.get("upstream_path")) + schema_version = _format_cell(data.get("schema_version")) + mappings = data.get("mappings", []) + + lines: list[str] = [] + lines.append(BEGIN_MARKER) + lines.append(".. This section is generated by scripts/generate_version_mapping_table.py") + lines.append("") + lines.append(f"- 组件: ``{component}``") + lines.append(f"- 上游仓库: ``{upstream_repo}``") + lines.append(f"- 上游路径: ``{upstream_path}``") + lines.append(f"- 映射格式版本: ``{schema_version}``") + lines.append(f"- 生成时间(UTC): ``{generated_at}``") + lines.append("") + lines.append(".. list-table:: 版本映射") + lines.append(" :header-rows: 1") + lines.append("") + lines.append(" * - LoongSuite 版本") + lines.append(" - OTel util-genai 版本") + lines.append(" - OTel commit") + lines.append(" - 上游来源") + lines.append(" - 同步日期") + lines.append(" - 说明") + + if not mappings: + lines.append(" * - -") + lines.append(" - -") + lines.append(" - -") + lines.append(" - -") + lines.append(" - -") + lines.append(" - -") + else: + for item in mappings: + source = f"{_format_cell(item.get('upstream_ref_type'))}:{_format_cell(item.get('upstream_ref'))}" + lines.append(f" * - ``{_format_cell(item.get('loongsuite_version'))}``") + lines.append(f" - ``{_format_cell(item.get('upstream_version'))}``") + lines.append(f" - ``{_format_cell(item.get('upstream_commit'))}``") + lines.append(f" - ``{source}``") + lines.append(f" - ``{_format_cell(item.get('sync_date'))}``") + lines.append(f" - {_format_cell(item.get('notes'))}") + + lines.append("") + lines.append(END_MARKER) + return "\n".join(lines) + + +def _upsert_generated_block(readme_content: str, block: str) -> str: + if BEGIN_MARKER in readme_content and END_MARKER in readme_content: + before, rest = readme_content.split(BEGIN_MARKER, 1) + _, after = rest.split(END_MARKER, 1) + return f"{before}{block}{after}" + + if not readme_content.endswith("\n"): + readme_content += "\n" + + section = ( + "\n版本映射(自动生成)\n" + "---------------------\n\n" + "以下内容由 ``scripts/generate_version_mapping_table.py`` 根据\n" + "``upstream_version_map.json`` 自动生成,请不要手动编辑该区块。\n\n" + f"{block}\n" + ) + return readme_content + section + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--mapping", + type=Path, + default=Path(__file__).resolve().parent.parent / "upstream_version_map.json", + help="Path to upstream version mapping JSON file.", + ) + parser.add_argument( + "--readme", + type=Path, + default=Path(__file__).resolve().parent.parent / "README-loongsuite.rst", + help="Path to README-loongsuite.rst file.", + ) + args = parser.parse_args() + + data = _load_mapping(args.mapping) + generated_at = dt.datetime.now(dt.timezone.utc).replace(microsecond=0).isoformat() + block = _render_generated_block(data, generated_at) + + readme_content = args.readme.read_text(encoding="utf-8") + updated_content = _upsert_generated_block(readme_content, block) + args.readme.write_text(updated_content, encoding="utf-8") + + +if __name__ == "__main__": + main() diff --git a/util/opentelemetry-util-genai/scripts/update_upstream_version_map.py b/util/opentelemetry-util-genai/scripts/update_upstream_version_map.py new file mode 100644 index 000000000..ef3d660f2 --- /dev/null +++ b/util/opentelemetry-util-genai/scripts/update_upstream_version_map.py @@ -0,0 +1,110 @@ +#!/usr/bin/env python3 +"""Update util-genai upstream version mapping JSON.""" + +from __future__ import annotations + +import argparse +import datetime as dt +import json +import re +from pathlib import Path +from typing import Any + +VERSION_RE = re.compile(r'__version__\s*=\s*"([^"]+)"') + + +def _read_local_version(version_file: Path) -> str: + content = version_file.read_text(encoding="utf-8") + match = VERSION_RE.search(content) + if not match: + raise ValueError(f"cannot parse __version__ from {version_file}") + return match.group(1) + + +def _load_mapping(mapping_file: Path) -> dict[str, Any]: + if not mapping_file.exists(): + return {"schema_version": 1, "mappings": []} + with mapping_file.open("r", encoding="utf-8") as f: + return json.load(f) + + +def _upsert_mapping( + data: dict[str, Any], + loongsuite_version: str, + upstream_version: str, + upstream_commit: str, + upstream_ref_type: str, + upstream_ref: str, + notes: str, +) -> None: + today = dt.date.today().isoformat() + entry = { + "loongsuite_version": loongsuite_version, + "upstream_version": upstream_version, + "upstream_commit": upstream_commit, + "upstream_ref_type": upstream_ref_type, + "upstream_ref": upstream_ref, + "sync_date": today, + "notes": notes, + } + + mappings = data.setdefault("mappings", []) + for index, mapping in enumerate(mappings): + if mapping.get("loongsuite_version") == loongsuite_version: + mappings[index] = entry + break + else: + mappings.insert(0, entry) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--mapping-file", type=Path, required=True) + parser.add_argument("--version-file", type=Path, required=True) + parser.add_argument("--component", type=str, default="loongsuite-util-genai") + parser.add_argument( + "--upstream-repository", + type=str, + default="https://github.com/open-telemetry/opentelemetry-python-contrib", + ) + parser.add_argument( + "--upstream-path", + type=str, + default="util/opentelemetry-util-genai", + ) + parser.add_argument("--upstream-version", type=str, required=True) + parser.add_argument("--upstream-commit", type=str, required=True) + parser.add_argument("--upstream-ref-type", type=str, default="branch") + parser.add_argument("--upstream-ref", type=str, default="main") + parser.add_argument( + "--notes", + type=str, + default="Synced from upstream via rebase workflow.", + ) + args = parser.parse_args() + + loongsuite_version = _read_local_version(args.version_file) + data = _load_mapping(args.mapping_file) + data["component"] = args.component + data["upstream_repository"] = args.upstream_repository + data["upstream_path"] = args.upstream_path + data["schema_version"] = 1 + + _upsert_mapping( + data=data, + loongsuite_version=loongsuite_version, + upstream_version=args.upstream_version, + upstream_commit=args.upstream_commit, + upstream_ref_type=args.upstream_ref_type, + upstream_ref=args.upstream_ref, + notes=args.notes, + ) + + args.mapping_file.write_text( + json.dumps(data, ensure_ascii=False, indent=2) + "\n", + encoding="utf-8", + ) + + +if __name__ == "__main__": + main() diff --git a/util/opentelemetry-util-genai/upstream_version_map.json b/util/opentelemetry-util-genai/upstream_version_map.json new file mode 100644 index 000000000..93fa1df5e --- /dev/null +++ b/util/opentelemetry-util-genai/upstream_version_map.json @@ -0,0 +1,17 @@ +{ + "component": "loongsuite-util-genai", + "upstream_repository": "https://github.com/open-telemetry/opentelemetry-python-contrib", + "upstream_path": "util/opentelemetry-util-genai", + "schema_version": 1, + "mappings": [ + { + "loongsuite_version": "0.3b0.dev2026021101", + "upstream_version": "0.3b0.dev", + "upstream_commit": "REPLACE_WITH_REAL_COMMIT_SHA", + "upstream_ref_type": "branch", + "upstream_ref": "main", + "sync_date": "2026-02-11", + "notes": "Prototype baseline mapping. Replace commit SHA before first official release." + } + ] +} From d4d3126085f07a6da2ebc685d7fe8965f5e36d07 Mon Sep 17 00:00:00 2001 From: cirilla-zmh Date: Sat, 14 Feb 2026 11:09:40 +0800 Subject: [PATCH 11/16] Fix sync script Change-Id: I7a1fb8d2deebbc3ae12bd60629a0510d7159641f Co-developed-by: Cursor --- .github/scripts/sync-upstream-with-rebase.sh | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/.github/scripts/sync-upstream-with-rebase.sh b/.github/scripts/sync-upstream-with-rebase.sh index 60232fc11..f41de4427 100755 --- a/.github/scripts/sync-upstream-with-rebase.sh +++ b/.github/scripts/sync-upstream-with-rebase.sh @@ -206,8 +206,10 @@ create_pr() { upstream_desc="commit $(git rev-parse --short "$UPSTREAM_COMMIT") (from ${UPSTREAM_REMOTE}/${UPSTREAM_BRANCH})" fi title="chore: sync ${upstream_desc} into ${BASE_BRANCH}" - body=$( - cat <"$body_file" ## Summary - Merge upstream \`${upstream_desc}\` into \`${BASE_BRANCH}\` - Preserve upstream commit history for incremental future syncs @@ -217,14 +219,13 @@ create_pr() { - This PR can be merged into \`${BASE_BRANCH}\` without conflicts (conflicts were resolved during the upstream merge) - Use **merge commit** (not squash) to preserve upstream commit granularity EOF - ) if gh pr view "$SYNC_BRANCH" >/dev/null 2>&1; then echo "PR for branch $SYNC_BRANCH already exists, skipping creation." return fi - gh pr create --base "$BASE_BRANCH" --head "$SYNC_BRANCH" --title "$title" --body "$body" + gh pr create --base "$BASE_BRANCH" --head "$SYNC_BRANCH" --title "$title" --body-file "$body_file" } # ── Main ────────────────────────────────────────────────────────────── From 0746872927ab2170b609b1d43cc199e8035f5630 Mon Sep 17 00:00:00 2001 From: cirilla-zmh Date: Sat, 14 Feb 2026 15:05:25 +0800 Subject: [PATCH 12/16] Add commit hash and loongsuite version Change-Id: Id049b5bee7eacec5286299c0053412a327ab4972 Co-developed-by: Cursor --- util/opentelemetry-util-genai/upstream_version_map.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/util/opentelemetry-util-genai/upstream_version_map.json b/util/opentelemetry-util-genai/upstream_version_map.json index 93fa1df5e..37bf87457 100644 --- a/util/opentelemetry-util-genai/upstream_version_map.json +++ b/util/opentelemetry-util-genai/upstream_version_map.json @@ -5,12 +5,12 @@ "schema_version": 1, "mappings": [ { - "loongsuite_version": "0.3b0.dev2026021101", + "loongsuite_version": "0.1.0", "upstream_version": "0.3b0.dev", - "upstream_commit": "REPLACE_WITH_REAL_COMMIT_SHA", + "upstream_commit": "61641aa3837fe6598ad04772cbe0d3d4c15932be", "upstream_ref_type": "branch", "upstream_ref": "main", - "sync_date": "2026-02-11", + "sync_date": "2025-12-04", "notes": "Prototype baseline mapping. Replace commit SHA before first official release." } ] From b9bd6ad1a3d685b51a096d901fa22e0ce1d67e44 Mon Sep 17 00:00:00 2001 From: cirilla-zmh Date: Tue, 24 Feb 2026 09:43:31 +0800 Subject: [PATCH 13/16] Add release workflows and dry run scripts Change-Id: Ib0d4aa36a776339f0e97c6c25ea6101396b23b81 Co-developed-by: Cursor --- .github/workflows/loongsuite-release.yml | 240 +++++++ .gitignore | 3 +- docs/loongsuite-release.md | 352 ++++++++++ .../instrumentation/agentscope/__init__.py | 12 - loongsuite-distro/pyproject.toml | 1 + .../src/loongsuite/distro/bootstrap.py | 178 +++++- .../src/loongsuite/distro/bootstrap_gen.py | 132 ++-- pkg-requirements.txt | 8 + scripts/build_loongsuite_package.py | 599 ++++++++++++++---- scripts/dry_run_loongsuite_release.sh | 348 ++++++++++ scripts/generate_loongsuite_bootstrap.py | 214 ++++++- scripts/loongsuite-build-config.json | 10 +- .../scripts/generate_version_mapping_table.py | 26 +- .../scripts/update_upstream_version_map.py | 4 +- 14 files changed, 1837 insertions(+), 290 deletions(-) create mode 100644 .github/workflows/loongsuite-release.yml create mode 100644 docs/loongsuite-release.md create mode 100755 scripts/dry_run_loongsuite_release.sh diff --git a/.github/workflows/loongsuite-release.yml b/.github/workflows/loongsuite-release.yml new file mode 100644 index 000000000..6aca82130 --- /dev/null +++ b/.github/workflows/loongsuite-release.yml @@ -0,0 +1,240 @@ +# LoongSuite Release Workflow +# +# This workflow handles the complete LoongSuite release process: +# +# 1. PyPI packages (loongsuite-util-genai, loongsuite-distro) +# 2. GitHub Release (instrumentation-genai/*, instrumentation-loongsuite/* as tar.gz) +# +# Trigger: +# - workflow_dispatch: Manual trigger with version inputs +# - push tags: Automatic trigger on v* tags +# +# Required secrets: +# - PYPI_API_TOKEN: PyPI API token for publishing (optional, can skip PyPI publish) +# +name: LoongSuite Release + +run-name: "LoongSuite Release ${{ github.event.inputs.loongsuite_version || github.ref_name }}" + +on: + workflow_dispatch: + inputs: + loongsuite_version: + description: 'LoongSuite version (e.g., 0.1.0) - for loongsuite-* packages' + required: true + upstream_version: + description: 'Upstream OTel version (e.g., 0.60b1) - for opentelemetry-* packages in bootstrap_gen.py' + required: true + release_notes: + description: 'Release notes (optional, uses CHANGELOG-loongsuite.md Unreleased if empty)' + required: false + skip_pypi: + description: 'Skip PyPI publish (for testing)' + type: boolean + default: false + push: + tags: + - 'v*' + +permissions: + contents: read + +env: + PYTHON_VERSION: '3.11' + +jobs: + # Build all packages + build: + runs-on: ubuntu-latest + outputs: + loongsuite_version: ${{ steps.version.outputs.loongsuite_version }} + upstream_version: ${{ steps.version.outputs.upstream_version }} + steps: + - uses: actions/checkout@v4 + + - name: Set versions from tag or input + id: version + run: | + if [[ "${{ github.event_name }}" == "push" && "${{ github.ref_type }}" == "tag" ]]; then + # Tag-based release: extract version from tag (v0.1.0 -> 0.1.0) + tag="${GITHUB_REF#refs/tags/}" + loongsuite_version="${tag#v}" + # For tag-based release, upstream_version must be set via environment or default + upstream_version="${UPSTREAM_VERSION:-0.60b1}" + else + # Manual release: use inputs + loongsuite_version="${{ github.event.inputs.loongsuite_version }}" + upstream_version="${{ github.event.inputs.upstream_version }}" + fi + + if [[ -z "$loongsuite_version" ]]; then + echo "ERROR: loongsuite_version is required" + exit 1 + fi + if [[ -z "$upstream_version" ]]; then + echo "ERROR: upstream_version is required" + exit 1 + fi + + echo "loongsuite_version=$loongsuite_version" >> $GITHUB_OUTPUT + echo "upstream_version=$upstream_version" >> $GITHUB_OUTPUT + echo "LOONGSUITE_VERSION=$loongsuite_version" >> $GITHUB_ENV + echo "UPSTREAM_VERSION=$upstream_version" >> $GITHUB_ENV + + echo "LoongSuite version: $loongsuite_version" + echo "Upstream version: $upstream_version" + + - uses: actions/setup-python@v5 + with: + python-version: ${{ env.PYTHON_VERSION }} + + - name: Install build dependencies + run: | + python -m pip install --upgrade pip + python -m pip install -r pkg-requirements.txt + + - name: Generate bootstrap_gen.py with versions + run: | + python scripts/generate_loongsuite_bootstrap.py \ + --upstream-version ${{ steps.version.outputs.upstream_version }} \ + --loongsuite-version ${{ steps.version.outputs.loongsuite_version }} + + echo "Generated bootstrap_gen.py:" + head -30 loongsuite-distro/src/loongsuite/distro/bootstrap_gen.py + + - name: Build PyPI packages + run: | + python scripts/build_loongsuite_package.py \ + --build-pypi \ + --version ${{ steps.version.outputs.loongsuite_version }} + + echo "PyPI packages built:" + ls -la dist/*.whl + + - name: Build GitHub Release packages + run: | + python scripts/build_loongsuite_package.py \ + --build-github-release \ + --version ${{ steps.version.outputs.loongsuite_version }} + + echo "GitHub Release tar:" + ls -la dist/*.tar.gz + + - name: Upload PyPI artifacts + uses: actions/upload-artifact@v4 + with: + name: pypi-packages + path: | + dist/loongsuite_util_genai-*.whl + dist/loongsuite_distro-*.whl + + - name: Upload GitHub Release artifact + uses: actions/upload-artifact@v4 + with: + name: github-release + path: dist/loongsuite-python-agent-*.tar.gz + + # Publish to PyPI + publish-pypi: + needs: build + runs-on: ubuntu-latest + if: ${{ !inputs.skip_pypi }} + environment: + name: pypi + url: https://pypi.org/project/loongsuite-distro/ + permissions: + id-token: write # OIDC publishing + steps: + - name: Download PyPI artifacts + uses: actions/download-artifact@v4 + with: + name: pypi-packages + path: dist/ + + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + # Uses OIDC trusted publishing (no API token needed if configured) + # Or set PYPI_API_TOKEN secret + skip-existing: true + + # Create GitHub Release + github-release: + needs: build + runs-on: ubuntu-latest + permissions: + contents: write # Required for creating releases + steps: + - uses: actions/checkout@v4 + + - name: Download GitHub Release artifact + uses: actions/download-artifact@v4 + with: + name: github-release + path: dist/ + + - name: Generate release notes + id: release_notes + run: | + LOONGSUITE_VERSION="${{ needs.build.outputs.loongsuite_version }}" + UPSTREAM_VERSION="${{ needs.build.outputs.upstream_version }}" + + if [[ -n "${{ github.event.inputs.release_notes }}" ]]; then + echo "${{ github.event.inputs.release_notes }}" > /tmp/release-notes.txt + else + # Start with header + cat > /tmp/release-notes.txt << EOF + # LoongSuite Python Agent v${LOONGSUITE_VERSION} + + ## Installation + + \`\`\`bash + pip install loongsuite-distro==${LOONGSUITE_VERSION} + loongsuite-bootstrap -a install --version ${LOONGSUITE_VERSION} + \`\`\` + + ## Package Versions + + - loongsuite-* packages: ${LOONGSUITE_VERSION} + - opentelemetry-* packages: ${UPSTREAM_VERSION} + + --- + + EOF + + # Collect from root CHANGELOG-loongsuite.md + if [[ -f CHANGELOG-loongsuite.md ]]; then + echo "## loongsuite-distro" >> /tmp/release-notes.txt + echo "" >> /tmp/release-notes.txt + sed -n '/^## \[*Unreleased\]*$/,/^## /p' CHANGELOG-loongsuite.md | sed '/^## /d' >> /tmp/release-notes.txt + echo "" >> /tmp/release-notes.txt + fi + + # Collect from instrumentation-loongsuite/*/CHANGELOG.md + for changelog in instrumentation-loongsuite/*/CHANGELOG.md; do + if [[ -f "$changelog" ]]; then + pkg_dir=$(dirname "$changelog") + pkg_name=$(basename "$pkg_dir") + unreleased_content=$(sed -n '/^## \[*Unreleased\]*$/,/^## /p' "$changelog" | sed '/^## /d') + if [[ -n "$unreleased_content" && "$unreleased_content" =~ [^[:space:]] ]]; then + echo "## $pkg_name" >> /tmp/release-notes.txt + echo "" >> /tmp/release-notes.txt + echo "$unreleased_content" >> /tmp/release-notes.txt + echo "" >> /tmp/release-notes.txt + fi + fi + done + fi + + echo "Release notes:" + cat /tmp/release-notes.txt + + - name: Create GitHub release + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + VERSION="${{ needs.build.outputs.loongsuite_version }}" + gh release create "v${VERSION}" \ + --title "LoongSuite Python Agent v${VERSION}" \ + --notes-file /tmp/release-notes.txt \ + dist/loongsuite-python-agent-${VERSION}.tar.gz diff --git a/.gitignore b/.gitignore index 841e7f158..02c6c8c43 100644 --- a/.gitignore +++ b/.gitignore @@ -67,5 +67,6 @@ pyrightconfig.json # LoongSuite Extension .cursor/ +dist-* upload/ -upload_*_test/ \ No newline at end of file +upload_*_test/ diff --git a/docs/loongsuite-release.md b/docs/loongsuite-release.md new file mode 100644 index 000000000..edaa5df50 --- /dev/null +++ b/docs/loongsuite-release.md @@ -0,0 +1,352 @@ +# LoongSuite 发布指南 + +本文档描述 LoongSuite Python Agent 的完整发布流程,包括本地验证(Dry Run)和正式发布两部分。 + +## 目录 + +- [发布策略概述](#发布策略概述) +- [版本号策略](#版本号策略) +- [Part 1: 本地验证 (Dry Run)](#part-1-本地验证-dry-run) +- [Part 2: 正式发布](#part-2-正式发布) +- [用户安装指南](#用户安装指南) +- [故障排查](#故障排查) + +--- + +## 发布策略概述 + +LoongSuite 采用**双轨发布策略**,将包分发到不同的目标: + +| 模块 | 源目录 | 发布后包名 | 发布目标 | 说明 | +|------|--------|-----------|----------|------| +| util-genai | `util/opentelemetry-util-genai` | `loongsuite-util-genai` | **PyPI** | GenAI 工具库,更名发布 | +| distro | `loongsuite-distro` | `loongsuite-distro` | **PyPI** | 引导器,提供 bootstrap 命令 | +| GenAI instrumentations | `instrumentation-genai/*` | `loongsuite-instrumentation-*` | **GitHub Release** | 大模型 instrumentations,更名发布 | +| LoongSuite instrumentations | `instrumentation-loongsuite/*` | `loongsuite-instrumentation-*` | **GitHub Release** | LoongSuite 自有 instrumentations | +| 标准 instrumentations | `instrumentation/*` | `opentelemetry-instrumentation-*` | **PyPI (上游)** | 由上游 OpenTelemetry 发布 | + +### 依赖关系 + +``` +用户环境 +├── loongsuite-distro (PyPI) +│ └── provides: loongsuite-bootstrap, loongsuite-instrument +├── loongsuite-util-genai (PyPI) +│ └── GenAI 工具库,被 instrumentation 依赖 +├── loongsuite-instrumentation-* (GitHub Release tar.gz) +│ └── 依赖 loongsuite-util-genai +└── opentelemetry-instrumentation-* (PyPI 上游) + └── 标准微服务 instrumentations +``` + +--- + +## 版本号策略 + +发布时需要指定两个版本号: + +| 参数 | 说明 | 示例 | +|------|------|------| +| `--loongsuite-version` | LoongSuite 包版本号 | `0.1.0`, `1.0.0` | +| `--upstream-version` | 上游 OpenTelemetry 包版本号 | `0.60b1`, `0.61b0` | + +### 版本号应用规则 + +- **loongsuite-version** 应用于: + - `loongsuite-util-genai` + - `loongsuite-distro` + - `loongsuite-instrumentation-*` (所有 GenAI/LoongSuite instrumentations) + +- **upstream-version** 应用于: + - `bootstrap_gen.py` 中的 `opentelemetry-instrumentation-*` 版本声明 + - 用户执行 `loongsuite-bootstrap -a install` 时从 PyPI 安装的上游包版本 + +--- + +## Part 1: 本地验证 (Dry Run) + +在正式发布前,使用 dry run 脚本在本地验证整个发布流程。 + +### 前置条件 + +```bash +# 确保在项目根目录 +cd /path/to/loongsuite-python-agent + +# 确保 Python 环境可用 +python --version # >= 3.9 + +# 脚本会自动从 pkg-requirements.txt 安装构建依赖 +# 或手动安装: +pip install -r pkg-requirements.txt +``` + +### 基本用法 + +```bash +# 完整验证(推荐首次使用) +./scripts/dry_run_loongsuite_release.sh \ + --loongsuite-version 0.1.0 \ + --upstream-version 0.60b1 +``` + +### 命令行参数 + +| 参数 | 简写 | 说明 | +|------|------|------| +| `--loongsuite-version` | `-l` | LoongSuite 包版本(必填) | +| `--upstream-version` | `-u` | 上游 OTel 包版本(必填) | +| `--skip-install` | - | 跳过安装验证步骤 | +| `--skip-pypi` | - | 跳过 PyPI 包构建 | +| `--help` | `-h` | 显示帮助信息 | + +### 快速验证选项 + +```bash +# 跳过安装验证(加快速度) +./scripts/dry_run_loongsuite_release.sh \ + -l 0.1.0 -u 0.60b1 --skip-install + +# 只验证 GitHub Release 包 +./scripts/dry_run_loongsuite_release.sh \ + -l 0.1.0 -u 0.60b1 --skip-pypi --skip-install +``` + +### Dry Run 执行步骤 + +脚本会依次执行以下步骤: + +| 步骤 | 说明 | 验证内容 | +|------|------|----------| +| 1 | 安装构建依赖 | 从 `pkg-requirements.txt` 安装 | +| 2 | 生成 bootstrap_gen.py | 版本号替换、包名映射 | +| 3 | 构建 PyPI 包 | `loongsuite-util-genai`, `loongsuite-distro` | +| 4 | 构建 GitHub Release 包 | tar.gz 包含正确的 instrumentations | +| 5 | 验证 tar 内容 | 包名、依赖正确性 | +| 6 | 生成 release notes | 从 CHANGELOG 提取 | +| 7 | 安装验证 | 在临时 venv 中测试两阶段安装 | + +### 产物说明 + +成功执行后,产物分布在两个目录: + +``` +dist-pypi/ # PyPI 包目录 +├── loongsuite_util_genai-0.1.0-py3-none-any.whl +└── loongsuite_distro-0.1.0-py3-none-any.whl + +dist/ # GitHub Release 包目录 +├── loongsuite-python-agent-0.1.0.tar.gz +└── release-notes-dryrun.txt +``` + +### 验证要点 + +Dry run 会自动验证以下内容: + +- ✅ `loongsuite-util-genai` 不在 tar.gz 中(应在 PyPI) +- ✅ `opentelemetry-util-genai` 不在 tar.gz 中(避免冲突) +- ✅ `loongsuite-instrumentation-*` 在 tar.gz 中 +- ✅ `opentelemetry-instrumentation-flask` 等不在 tar.gz 中(从 PyPI 安装) +- ✅ 安装后 `loongsuite-util-genai` 可用 +- ✅ 安装后 `opentelemetry-util-genai` 未被安装(无冲突) + +--- + +## Part 2: 正式发布 + +正式发布通过 GitHub Actions workflow 执行。 + +### 发布方式 + +#### 方式 1: 手动触发 (推荐) + +1. 进入 GitHub 仓库的 **Actions** 页面 +2. 选择 **LoongSuite Release** workflow +3. 点击 **Run workflow** +4. 填写参数: + - `loongsuite_version`: 如 `0.1.0` + - `upstream_version`: 如 `0.60b1` + - `release_notes`: 可选,留空则从 CHANGELOG 提取 + - `skip_pypi`: 是否跳过 PyPI 发布(测试用) +5. 点击 **Run workflow** 执行 + +#### 方式 2: Tag 触发 + +```bash +# 创建并推送 tag +git tag v0.1.0 +git push origin v0.1.0 +``` + +> ⚠️ Tag 触发时,`upstream_version` 使用默认值或环境变量,建议使用手动触发以确保版本号正确。 + +### Workflow 执行流程 + +``` +┌─────────────────────────────────────────────────────────────┐ +│ loongsuite-release.yml │ +├─────────────────────────────────────────────────────────────┤ +│ │ +│ ┌─────────────────────────────────────────────────────┐ │ +│ │ Job: build │ │ +│ │ 1. Checkout 代码 │ │ +│ │ 2. 设置版本号 │ │ +│ │ 3. 生成 bootstrap_gen.py │ │ +│ │ 4. 构建 PyPI 包 │ │ +│ │ 5. 构建 GitHub Release 包 │ │ +│ │ 6. 上传 artifacts │ │ +│ └─────────────────────────────────────────────────────┘ │ +│ │ │ +│ ┌────────────┴────────────┐ │ +│ ▼ ▼ │ +│ ┌─────────────────────┐ ┌─────────────────────┐ │ +│ │ Job: publish-pypi │ │ Job: github-release │ │ +│ │ - 下载 PyPI 包 │ │ - 下载 tar.gz │ │ +│ │ - twine upload │ │ - 生成 release notes│ │ +│ │ - 发布到 PyPI │ │ - 创建 GitHub Release│ │ +│ └─────────────────────┘ └─────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────┘ +``` + +### PyPI 发布配置 + +Workflow 使用 OIDC trusted publishing 发布到 PyPI。需要在 PyPI 项目设置中配置: + +1. 进入 PyPI 项目设置 → Publishing +2. 添加 trusted publisher: + - Owner: `alibaba` + - Repository: `loongsuite-python-agent` + - Workflow: `loongsuite-release.yml` + - Environment: `pypi` + +或者配置 `PYPI_API_TOKEN` secret 使用 API token 发布。 + +### 发布检查清单 + +发布前确认: + +- [ ] 本地 dry run 通过 +- [ ] CHANGELOG-loongsuite.md 已更新 +- [ ] 版本号格式正确(如 `0.1.0`,不带 `v` 前缀) +- [ ] upstream_version 与当前上游稳定版本匹配 +- [ ] PyPI trusted publishing 或 API token 已配置 + +--- + +## 用户安装指南 + +发布完成后,用户可通过以下方式安装: + +### 基本安装 + +```bash +# 1. 安装 loongsuite-distro (从 PyPI) +pip install loongsuite-distro==0.1.0 + +# 2. 安装所有 instrumentations +loongsuite-bootstrap -a install --version 0.1.0 +``` + +### 按需安装 (Auto-detect) + +```bash +# 只安装当前环境中检测到的库对应的 instrumentations +loongsuite-bootstrap -a install --version 0.1.0 --auto-detect +``` + +### 使用白名单 + +```bash +# 创建白名单文件 +cat > whitelist.txt << EOF +loongsuite-instrumentation-dashscope +opentelemetry-instrumentation-flask +opentelemetry-instrumentation-redis +EOF + +# 只安装白名单中的 instrumentations +loongsuite-bootstrap -a install --version 0.1.0 --whitelist whitelist.txt +``` + +### 安装流程说明 + +`loongsuite-bootstrap` 执行两阶段安装: + +``` +Phase 1: 从 GitHub Release tar.gz 安装 + - loongsuite-instrumentation-dashscope + - loongsuite-instrumentation-langchain + - loongsuite-instrumentation-google-genai + - ... + +Phase 2: 从 PyPI 安装 + - opentelemetry-instrumentation-flask==0.60b1 + - opentelemetry-instrumentation-redis==0.60b1 + - opentelemetry-instrumentation-django==0.60b1 + - ... +``` + +--- + +## 故障排查 + +### Dry Run 常见问题 + +**问题**: `hatch version` 失败 + +``` +解决: 确保安装了 hatch +pip install hatch +``` + +**问题**: 构建失败,找不到依赖 + +``` +解决: 安装构建依赖 +pip install build tomli +``` + +**问题**: 安装验证失败 + +``` +解决: 检查 loongsuite-distro 是否正确安装 +pip install -e ./loongsuite-distro +``` + +### 发布常见问题 + +**问题**: PyPI 发布失败 (403 Forbidden) + +``` +解决: +1. 检查 OIDC trusted publishing 配置 +2. 或配置 PYPI_API_TOKEN secret +``` + +**问题**: GitHub Release 创建失败 + +``` +解决: 确保 workflow 有 contents: write 权限 +``` + +**问题**: 版本号冲突 + +``` +解决: PyPI 不允许覆盖已发布版本,需要使用新版本号 +``` + +--- + +## 相关文件 + +| 文件 | 说明 | +|------|------| +| `scripts/build_loongsuite_package.py` | 构建脚本 | +| `scripts/generate_loongsuite_bootstrap.py` | 生成 bootstrap_gen.py | +| `scripts/dry_run_loongsuite_release.sh` | 本地验证脚本 | +| `.github/workflows/loongsuite-release.yml` | GitHub Actions workflow | +| `loongsuite-distro/src/loongsuite/distro/bootstrap.py` | Bootstrap 安装逻辑 | +| `loongsuite-distro/src/loongsuite/distro/bootstrap_gen.py` | 生成的包映射配置 | +| `CHANGELOG-loongsuite.md` | 变更日志 | diff --git a/instrumentation-loongsuite/loongsuite-instrumentation-agentscope/src/opentelemetry/instrumentation/agentscope/__init__.py b/instrumentation-loongsuite/loongsuite-instrumentation-agentscope/src/opentelemetry/instrumentation/agentscope/__init__.py index ddbb18de9..de6799666 100644 --- a/instrumentation-loongsuite/loongsuite-instrumentation-agentscope/src/opentelemetry/instrumentation/agentscope/__init__.py +++ b/instrumentation-loongsuite/loongsuite-instrumentation-agentscope/src/opentelemetry/instrumentation/agentscope/__init__.py @@ -216,18 +216,6 @@ def wrap_formatter_with_tracer(wrapped, instance, args, kwargs): except Exception as e: logger.warning(f"Failed to patch _check_tracing_enabled: {e}") - # Patch _check_tracing_enabled to return False - # We always want to disable tracing in native AgentScope library - try: - wrap_function_wrapper( - module="agentscope.tracing._trace", - name="_check_tracing_enabled", - wrapper=self._check_tracing_enabled_patch, - ) - logger.debug("Patched _check_tracing_enabled") - except Exception as e: - logger.warning(f"Failed to patch _check_tracing_enabled: {e}") - def _uninstrument(self, **kwargs: Any) -> None: """Disable AgentScope instrumentation.""" try: diff --git a/loongsuite-distro/pyproject.toml b/loongsuite-distro/pyproject.toml index 0cdc6e522..f7cba84f8 100644 --- a/loongsuite-distro/pyproject.toml +++ b/loongsuite-distro/pyproject.toml @@ -29,6 +29,7 @@ dependencies = [ "opentelemetry-api ~= 1.12", "opentelemetry-sdk ~= 1.13", "opentelemetry-instrumentation >= 0.58b0", + "packaging", ] [project.optional-dependencies] diff --git a/loongsuite-distro/src/loongsuite/distro/bootstrap.py b/loongsuite-distro/src/loongsuite/distro/bootstrap.py index d0be5f040..ab3126ac2 100644 --- a/loongsuite-distro/src/loongsuite/distro/bootstrap.py +++ b/loongsuite-distro/src/loongsuite/distro/bootstrap.py @@ -15,8 +15,15 @@ """ LoongSuite Bootstrap Tool -Install all components of loongsuite Python Agent from tar.gz package. -Supports blacklist/whitelist to control which instrumentations to install. +Two-phase installation strategy: +1. Install loongsuite-* packages from GitHub Release tar.gz (GenAI instrumentations) +2. Install opentelemetry-* packages from PyPI (standard instrumentations) + +The installation source is determined by package name prefix: +- loongsuite-* -> GitHub Release tar.gz +- opentelemetry-* -> PyPI + +loongsuite-util-genai is installed from PyPI as a base dependency. """ import argparse @@ -41,12 +48,13 @@ logger = logging.getLogger(__name__) -# Base dependency packages (must be installed) -BASE_DEPENDENCIES = { +# Base dependency packages installed from PyPI +# loongsuite-util-genai is published to PyPI and required by GenAI instrumentations +BASE_DEPENDENCIES_PYPI = { "opentelemetry-api", "opentelemetry-sdk", "opentelemetry-instrumentation", - "opentelemetry-util-genai", + "loongsuite-util-genai", "opentelemetry-semantic-conventions", } @@ -412,6 +420,71 @@ def _is_instrumentation_in_bootstrap_gen(package_name: str) -> bool: return False +def _is_loongsuite_package(package_name: str) -> bool: + """Check if package is a loongsuite package (installed from GitHub Release tar)""" + return package_name.startswith("loongsuite-") + + +def _get_desired_instrumentation_requirements( + blacklist: Optional[Set[str]] = None, + whitelist: Optional[Set[str]] = None, + auto_detect: bool = False, +) -> Tuple[List[Tuple[str, str]], List[Tuple[str, str]]]: + """ + Get desired instrumentation packages from bootstrap_gen with filtering. + + Returns: + (tar_packages, pypi_packages) + - tar_packages: loongsuite-* packages to install from GitHub Release tar.gz + - pypi_packages: opentelemetry-* packages to install from PyPI + """ + blacklist = blacklist or set() + whitelist = whitelist or set() + tar_packages: List[Tuple[str, str]] = [] + pypi_packages: List[Tuple[str, str]] = [] + + def _should_include( + pkg_name: str, target_libraries: List[str], is_default: bool + ) -> bool: + if blacklist and pkg_name in blacklist: + return False + if whitelist and pkg_name not in whitelist: + return False + if is_default: + return True + if auto_detect and target_libraries: + return any(_is_library_installed(lib) for lib in target_libraries) + return not auto_detect + + seen: Set[str] = set() + for default_instr in gen_default_instrumentations: + if isinstance(default_instr, str): + pkg_name = extract_package_name_from_requirement(default_instr) + if pkg_name not in seen and _should_include(pkg_name, [], True): + seen.add(pkg_name) + if _is_loongsuite_package(pkg_name): + tar_packages.append((pkg_name, default_instr)) + else: + pypi_packages.append((pkg_name, default_instr)) + + for lib_mapping in gen_libraries: + instrumentation = lib_mapping.get("instrumentation", "") + if isinstance(instrumentation, str): + pkg_name = extract_package_name_from_requirement(instrumentation) + target_lib = lib_mapping.get("library", "") + target_libraries = [target_lib] if target_lib else [] + if pkg_name not in seen and _should_include( + pkg_name, target_libraries, False + ): + seen.add(pkg_name) + if _is_loongsuite_package(pkg_name): + tar_packages.append((pkg_name, instrumentation)) + else: + pypi_packages.append((pkg_name, instrumentation)) + + return tar_packages, pypi_packages + + def get_target_libraries_from_bootstrap_gen( package_name: str, ) -> Tuple[List[str], bool]: @@ -635,7 +708,7 @@ def filter_packages( # Check dependency version compatibility (only for base dependencies) # Instrumentation packages will be checked by pip during installation - if package_name in BASE_DEPENDENCIES: + if package_name in BASE_DEPENDENCIES_PYPI: is_dep_compatible, conflict_msg = check_dependency_compatibility( whl_file, skip_version_check ) @@ -646,7 +719,7 @@ def filter_packages( continue # Classify: base dependencies vs instrumentation - if package_name in BASE_DEPENDENCIES: + if package_name in BASE_DEPENDENCIES_PYPI: base_packages.append(whl_file) else: # For instrumentation packages, check if auto-detect is enabled @@ -706,10 +779,13 @@ def filter_packages( def install_packages( - whl_files: List[Path], find_links_dir: Path, upgrade: bool = False + whl_files: List[Path], + find_links_dir: Path, + upgrade: bool = False, + extra_requirements: Optional[List[str]] = None, ): - """Install packages using pip""" - if not whl_files: + """Install packages using pip. extra_requirements are installed from PyPI.""" + if not whl_files and not extra_requirements: logger.warning("No packages to install") return @@ -725,8 +801,10 @@ def install_packages( if upgrade: cmd.append("--upgrade") - # Add all whl files + # Add whl files (from tar) and extra requirements (from PyPI) cmd.extend([str(whl) for whl in whl_files]) + if extra_requirements: + cmd.extend(extra_requirements) logger.info(f"Executing install command: {' '.join(cmd)}") try: @@ -886,7 +964,9 @@ def install_from_tar( auto_detect: bool = False, ): """ - Install loongsuite packages from tar package + Two-phase installation from tar package: + 1. Install loongsuite-* packages from GitHub Release tar.gz (GenAI instrumentations) + 2. Install opentelemetry-* packages from PyPI (standard instrumentations) Args: tar_path: tar file path or URI (can be Path or str) @@ -913,42 +993,72 @@ def install_from_tar( logger.info(f"Found {len(whl_files)} packages in tar file") - # Filter packages + # Filter packages from tar (loongsuite-* packages) logger.info("Filtering packages...") base_packages, instrumentation_packages = filter_packages( whl_files, blacklist, whitelist, skip_version_check, auto_detect ) - # Ensure base dependencies must be installed - if not base_packages: - logger.warning( - "Warning: No base dependency packages found, this may cause installation to fail" - ) + # Get desired packages from bootstrap_gen + tar_desired, pypi_desired = _get_desired_instrumentation_requirements( + blacklist, whitelist, auto_detect + ) + + # Build package name set from tar + tar_package_names = { + normalize_package_name(get_package_name_from_whl(w)) + for w in whl_files + } - # Merge all packages to install - all_packages = base_packages + instrumentation_packages + # loongsuite-* packages from tar (already filtered) + tar_packages = base_packages + instrumentation_packages - if not all_packages: + # opentelemetry-* packages from PyPI (use requirement string with version) + pypi_requirements: List[str] = [] + for pkg_name, req_str in pypi_desired: + norm_name = normalize_package_name(pkg_name) + if norm_name not in tar_package_names: + # Use full requirement string (e.g., "opentelemetry-instrumentation-flask==0.60b1") + pypi_requirements.append(req_str) + + if not tar_packages and not pypi_requirements: logger.warning("No packages to install after filtering") return - logger.info( - f"Will install {len(base_packages)} base dependency packages" - ) - logger.info( - f"Will install {len(instrumentation_packages)} instrumentation packages" - ) - - if instrumentation_packages: - logger.info("Instrumentation packages to install:") - for pkg in instrumentation_packages: + # Phase 1: Install from tar.gz (loongsuite-* packages) + logger.info("=" * 50) + logger.info("Phase 1: Installing loongsuite-* packages from tar...") + logger.info("=" * 50) + if tar_packages: + logger.info(f"Will install {len(tar_packages)} packages from tar:") + for pkg in tar_packages: pkg_name = get_package_name_from_whl(pkg) logger.info(f" - {pkg_name}") + install_packages(tar_packages, temp_dir, upgrade) + else: + logger.info("No loongsuite-* packages to install from tar") - # Install - logger.info("Installing packages...") - install_packages(all_packages, temp_dir, upgrade) + # Phase 2: Install from PyPI (opentelemetry-* packages) + logger.info("=" * 50) + logger.info( + "Phase 2: Installing opentelemetry-* packages from PyPI..." + ) + logger.info("=" * 50) + if pypi_requirements: + logger.info( + f"Will install {len(pypi_requirements)} packages from PyPI:" + ) + for req in pypi_requirements: + logger.info(f" - {req}") + install_packages( + [], temp_dir, upgrade, extra_requirements=pypi_requirements + ) + else: + logger.info("No opentelemetry-* packages to install from PyPI") + + logger.info("=" * 50) logger.info("Installation completed successfully!") + logger.info("=" * 50) finally: if not keep_temp: diff --git a/loongsuite-distro/src/loongsuite/distro/bootstrap_gen.py b/loongsuite-distro/src/loongsuite/distro/bootstrap_gen.py index af117506e..9facb7ea3 100644 --- a/loongsuite-distro/src/loongsuite/distro/bootstrap_gen.py +++ b/loongsuite-distro/src/loongsuite/distro/bootstrap_gen.py @@ -14,237 +14,241 @@ # DO NOT EDIT. THIS FILE WAS AUTOGENERATED FROM INSTRUMENTATION PACKAGES. # RUN `python scripts/generate_loongsuite_bootstrap.py` TO REGENERATE. +# +# Generated with options: +# --upstream-version: 0.60b1 +# --loongsuite-version: 0.1b0 libraries = [ { "library": "openai >= 1.26.0", - "instrumentation": "opentelemetry-instrumentation-openai-v2", + "instrumentation": "loongsuite-instrumentation-openai-v2==0.1b0", }, { "library": "google-cloud-aiplatform >= 1.64", - "instrumentation": "opentelemetry-instrumentation-vertexai>=2.0b0", + "instrumentation": "loongsuite-instrumentation-vertexai==0.1b0", }, { "library": "aio_pika >= 7.2.0, < 10.0.0", - "instrumentation": "opentelemetry-instrumentation-aio-pika==0.61b0.dev", + "instrumentation": "opentelemetry-instrumentation-aio-pika==0.60b1", }, { "library": "aiohttp ~= 3.0", - "instrumentation": "opentelemetry-instrumentation-aiohttp-client==0.61b0.dev", + "instrumentation": "opentelemetry-instrumentation-aiohttp-client==0.60b1", }, { "library": "aiohttp ~= 3.0", - "instrumentation": "opentelemetry-instrumentation-aiohttp-server==0.61b0.dev", + "instrumentation": "opentelemetry-instrumentation-aiohttp-server==0.60b1", }, { "library": "aiokafka >= 0.8, < 1.0", - "instrumentation": "opentelemetry-instrumentation-aiokafka==0.61b0.dev", + "instrumentation": "opentelemetry-instrumentation-aiokafka==0.60b1", }, { "library": "aiopg >= 0.13.0, < 2.0.0", - "instrumentation": "opentelemetry-instrumentation-aiopg==0.61b0.dev", + "instrumentation": "opentelemetry-instrumentation-aiopg==0.60b1", }, { "library": "asgiref ~= 3.0", - "instrumentation": "opentelemetry-instrumentation-asgi==0.61b0.dev", + "instrumentation": "opentelemetry-instrumentation-asgi==0.60b1", }, { "library": "asyncclick ~= 8.0", - "instrumentation": "opentelemetry-instrumentation-asyncclick==0.61b0.dev", + "instrumentation": "opentelemetry-instrumentation-asyncclick==0.60b1", }, { "library": "asyncpg >= 0.12.0", - "instrumentation": "opentelemetry-instrumentation-asyncpg==0.61b0.dev", + "instrumentation": "opentelemetry-instrumentation-asyncpg==0.60b1", }, { "library": "boto~=2.0", - "instrumentation": "opentelemetry-instrumentation-boto==0.61b0.dev", + "instrumentation": "opentelemetry-instrumentation-boto==0.60b1", }, { "library": "boto3 ~= 1.0", - "instrumentation": "opentelemetry-instrumentation-boto3sqs==0.61b0.dev", + "instrumentation": "opentelemetry-instrumentation-boto3sqs==0.60b1", }, { "library": "botocore ~= 1.0", - "instrumentation": "opentelemetry-instrumentation-botocore==0.61b0.dev", + "instrumentation": "opentelemetry-instrumentation-botocore==0.60b1", }, { "library": "cassandra-driver ~= 3.25", - "instrumentation": "opentelemetry-instrumentation-cassandra==0.61b0.dev", + "instrumentation": "opentelemetry-instrumentation-cassandra==0.60b1", }, { "library": "scylla-driver ~= 3.25", - "instrumentation": "opentelemetry-instrumentation-cassandra==0.61b0.dev", + "instrumentation": "opentelemetry-instrumentation-cassandra==0.60b1", }, { "library": "celery >= 4.0, < 6.0", - "instrumentation": "opentelemetry-instrumentation-celery==0.61b0.dev", + "instrumentation": "opentelemetry-instrumentation-celery==0.60b1", }, { "library": "click >= 8.1.3, < 9.0.0", - "instrumentation": "opentelemetry-instrumentation-click==0.61b0.dev", + "instrumentation": "opentelemetry-instrumentation-click==0.60b1", }, { "library": "confluent-kafka >= 1.8.2, <= 2.11.0", - "instrumentation": "opentelemetry-instrumentation-confluent-kafka==0.61b0.dev", + "instrumentation": "opentelemetry-instrumentation-confluent-kafka==0.60b1", }, { "library": "django >= 1.10", - "instrumentation": "opentelemetry-instrumentation-django==0.61b0.dev", + "instrumentation": "opentelemetry-instrumentation-django==0.60b1", }, { "library": "elasticsearch >= 6.0", - "instrumentation": "opentelemetry-instrumentation-elasticsearch==0.61b0.dev", + "instrumentation": "opentelemetry-instrumentation-elasticsearch==0.60b1", }, { "library": "falcon >= 1.4.1, < 5.0.0", - "instrumentation": "opentelemetry-instrumentation-falcon==0.61b0.dev", + "instrumentation": "opentelemetry-instrumentation-falcon==0.60b1", }, { "library": "fastapi ~= 0.92", - "instrumentation": "opentelemetry-instrumentation-fastapi==0.61b0.dev", + "instrumentation": "opentelemetry-instrumentation-fastapi==0.60b1", }, { "library": "flask >= 1.0", - "instrumentation": "opentelemetry-instrumentation-flask==0.61b0.dev", + "instrumentation": "opentelemetry-instrumentation-flask==0.60b1", }, { "library": "grpcio >= 1.42.0", - "instrumentation": "opentelemetry-instrumentation-grpc==0.61b0.dev", + "instrumentation": "opentelemetry-instrumentation-grpc==0.60b1", }, { "library": "httpx >= 0.18.0", - "instrumentation": "opentelemetry-instrumentation-httpx==0.61b0.dev", + "instrumentation": "opentelemetry-instrumentation-httpx==0.60b1", }, { "library": "jinja2 >= 2.7, < 4.0", - "instrumentation": "opentelemetry-instrumentation-jinja2==0.61b0.dev", + "instrumentation": "opentelemetry-instrumentation-jinja2==0.60b1", }, { "library": "kafka-python >= 2.0, < 3.0", - "instrumentation": "opentelemetry-instrumentation-kafka-python==0.61b0.dev", + "instrumentation": "opentelemetry-instrumentation-kafka-python==0.60b1", }, { "library": "kafka-python-ng >= 2.0, < 3.0", - "instrumentation": "opentelemetry-instrumentation-kafka-python==0.61b0.dev", + "instrumentation": "opentelemetry-instrumentation-kafka-python==0.60b1", }, { "library": "mysql-connector-python >= 8.0, < 10.0", - "instrumentation": "opentelemetry-instrumentation-mysql==0.61b0.dev", + "instrumentation": "opentelemetry-instrumentation-mysql==0.60b1", }, { "library": "mysqlclient < 3", - "instrumentation": "opentelemetry-instrumentation-mysqlclient==0.61b0.dev", + "instrumentation": "opentelemetry-instrumentation-mysqlclient==0.60b1", }, { "library": "pika >= 0.12.0", - "instrumentation": "opentelemetry-instrumentation-pika==0.61b0.dev", + "instrumentation": "opentelemetry-instrumentation-pika==0.60b1", }, { "library": "psycopg >= 3.1.0", - "instrumentation": "opentelemetry-instrumentation-psycopg==0.61b0.dev", + "instrumentation": "opentelemetry-instrumentation-psycopg==0.60b1", }, { "library": "psycopg2 >= 2.7.3.1", - "instrumentation": "opentelemetry-instrumentation-psycopg2==0.61b0.dev", + "instrumentation": "opentelemetry-instrumentation-psycopg2==0.60b1", }, { "library": "psycopg2-binary >= 2.7.3.1", - "instrumentation": "opentelemetry-instrumentation-psycopg2==0.61b0.dev", + "instrumentation": "opentelemetry-instrumentation-psycopg2==0.60b1", }, { "library": "pymemcache >= 1.3.5, < 5", - "instrumentation": "opentelemetry-instrumentation-pymemcache==0.61b0.dev", + "instrumentation": "opentelemetry-instrumentation-pymemcache==0.60b1", }, { "library": "pymongo >= 3.1, < 5.0", - "instrumentation": "opentelemetry-instrumentation-pymongo==0.61b0.dev", + "instrumentation": "opentelemetry-instrumentation-pymongo==0.60b1", }, { "library": "pymssql >= 2.1.5, < 3", - "instrumentation": "opentelemetry-instrumentation-pymssql==0.61b0.dev", + "instrumentation": "opentelemetry-instrumentation-pymssql==0.60b1", }, { "library": "PyMySQL < 2", - "instrumentation": "opentelemetry-instrumentation-pymysql==0.61b0.dev", + "instrumentation": "opentelemetry-instrumentation-pymysql==0.60b1", }, { "library": "pyramid >= 1.7", - "instrumentation": "opentelemetry-instrumentation-pyramid==0.61b0.dev", + "instrumentation": "opentelemetry-instrumentation-pyramid==0.60b1", }, { "library": "redis >= 2.6", - "instrumentation": "opentelemetry-instrumentation-redis==0.61b0.dev", + "instrumentation": "opentelemetry-instrumentation-redis==0.60b1", }, { "library": "remoulade >= 0.50", - "instrumentation": "opentelemetry-instrumentation-remoulade==0.61b0.dev", + "instrumentation": "opentelemetry-instrumentation-remoulade==0.60b1", }, { "library": "requests ~= 2.0", - "instrumentation": "opentelemetry-instrumentation-requests==0.61b0.dev", + "instrumentation": "opentelemetry-instrumentation-requests==0.60b1", }, { "library": "sqlalchemy >= 1.0.0, < 2.1.0", - "instrumentation": "opentelemetry-instrumentation-sqlalchemy==0.61b0.dev", + "instrumentation": "opentelemetry-instrumentation-sqlalchemy==0.60b1", }, { "library": "starlette >= 0.13", - "instrumentation": "opentelemetry-instrumentation-starlette==0.61b0.dev", + "instrumentation": "opentelemetry-instrumentation-starlette==0.60b1", }, { "library": "psutil >= 5", - "instrumentation": "opentelemetry-instrumentation-system-metrics==0.61b0.dev", + "instrumentation": "opentelemetry-instrumentation-system-metrics==0.60b1", }, { "library": "tornado >= 5.1.1", - "instrumentation": "opentelemetry-instrumentation-tornado==0.61b0.dev", + "instrumentation": "opentelemetry-instrumentation-tornado==0.60b1", }, { "library": "tortoise-orm >= 0.17.0", - "instrumentation": "opentelemetry-instrumentation-tortoiseorm==0.61b0.dev", + "instrumentation": "opentelemetry-instrumentation-tortoiseorm==0.60b1", }, { "library": "pydantic >= 1.10.2", - "instrumentation": "opentelemetry-instrumentation-tortoiseorm==0.61b0.dev", + "instrumentation": "opentelemetry-instrumentation-tortoiseorm==0.60b1", }, { "library": "urllib3 >= 1.0.0, < 3.0.0", - "instrumentation": "opentelemetry-instrumentation-urllib3==0.61b0.dev", + "instrumentation": "opentelemetry-instrumentation-urllib3==0.60b1", }, { "library": "agentscope >= 1.0.0", - "instrumentation": "loongsuite-instrumentation-agentscope==1.0.0", + "instrumentation": "loongsuite-instrumentation-agentscope==0.1b0", }, { "library": "agno", - "instrumentation": "loongsuite-instrumentation-agno==0.1b0.dev", + "instrumentation": "loongsuite-instrumentation-agno==0.1b0", }, { "library": "dashscope >= 1.0.0", - "instrumentation": "loongsuite-instrumentation-dashscope==0.1.0.dev0", + "instrumentation": "loongsuite-instrumentation-dashscope==0.1b0", }, { "library": "langchain_core >= 0.1.0", - "instrumentation": "loongsuite-instrumentation-langchain==1.0.0", + "instrumentation": "loongsuite-instrumentation-langchain==0.1b0", }, { - "library": "mcp >= 1.3.0, <= 1.13.1", - "instrumentation": "loongsuite-instrumentation-mcp==0.1.0", + "library": "mcp >= 1.3.0, <= 1.25.0", + "instrumentation": "loongsuite-instrumentation-mcp==0.1b0", }, { "library": "mem0ai >= 1.0.0", - "instrumentation": "loongsuite-instrumentation-mem0==0.1.0", + "instrumentation": "loongsuite-instrumentation-mem0==0.1b0", }, ] default_instrumentations = [ - "opentelemetry-instrumentation-asyncio==0.61b0.dev", - "opentelemetry-instrumentation-dbapi==0.61b0.dev", - "opentelemetry-instrumentation-logging==0.61b0.dev", - "opentelemetry-instrumentation-sqlite3==0.61b0.dev", - "opentelemetry-instrumentation-threading==0.61b0.dev", - "opentelemetry-instrumentation-urllib==0.61b0.dev", - "opentelemetry-instrumentation-wsgi==0.61b0.dev", - "loongsuite-instrumentation-dify==1.1.0", + "opentelemetry-instrumentation-asyncio==0.60b1", + "opentelemetry-instrumentation-dbapi==0.60b1", + "opentelemetry-instrumentation-logging==0.60b1", + "opentelemetry-instrumentation-sqlite3==0.60b1", + "opentelemetry-instrumentation-threading==0.60b1", + "opentelemetry-instrumentation-urllib==0.60b1", + "opentelemetry-instrumentation-wsgi==0.60b1", + "loongsuite-instrumentation-dify==0.1b0", ] diff --git a/pkg-requirements.txt b/pkg-requirements.txt index 67491c71d..460f1ac63 100644 --- a/pkg-requirements.txt +++ b/pkg-requirements.txt @@ -1,3 +1,11 @@ +# LoongSuite build dependencies build>=1.0.0 setuptools>=65.0.0 wheel>=0.40.0 + +# LoongSuite release dependencies +tomli>=2.0.0 +tomlkit>=0.12.0 +hatch>=1.7.0 +astor>=0.8.0 +packaging>=21.0 diff --git a/scripts/build_loongsuite_package.py b/scripts/build_loongsuite_package.py index c31875105..924938288 100755 --- a/scripts/build_loongsuite_package.py +++ b/scripts/build_loongsuite_package.py @@ -1,27 +1,42 @@ #!/usr/bin/env python3 """ -Build script: Package all required whl files into tar.gz - -This script will: -1. Build all packages under instrumentation/ -2. Build all packages under instrumentation-genai/ -3. Build all packages under instrumentation-loongsuite/ -4. Build util/opentelemetry-util-genai/ -5. Build processor/loongsuite-processor-baggage/ -6. Skip duplicate packages according to config file -7. Package all whl files into tar.gz - -Note: loongsuite-distro is not included as it is published separately to PyPI. +LoongSuite Release Build Script + +This script supports the following release modes: + +1. --build-pypi: Build packages for PyPI publishing + - loongsuite-util-genai (renamed from opentelemetry-util-genai) + - loongsuite-distro + +2. --build-github-release: Build packages for GitHub Release (tar.gz) + - instrumentation-genai/ packages (renamed to loongsuite-*, depends on loongsuite-util-genai) + - instrumentation-loongsuite/ packages (depends on loongsuite-util-genai) + - processor/loongsuite-processor-baggage/ + +Version replacement: +- --version: Sets version for all packages being built +- --upstream-version: Sets version for upstream opentelemetry-instrumentation-* packages + (used in bootstrap_gen.py) + +Dependency replacement: +- opentelemetry-util-genai -> loongsuite-util-genai (with ~= version spec) + +Package name replacement (for instrumentation-genai/): +- opentelemetry-instrumentation-* -> loongsuite-instrumentation-* """ import argparse import json import logging +import re import subprocess import sys import tarfile +from contextlib import contextmanager from pathlib import Path -from typing import List, Set +from typing import Any, Dict, List, Optional, Set + +import tomlkit logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s") logger = logging.getLogger(__name__) @@ -30,51 +45,145 @@ def load_skip_config(config_path: Path) -> Set[str]: """Load package names to skip from config file""" if not config_path.exists(): - logger.warning( - f"Config file {config_path} does not exist, using default config" - ) return set() with open(config_path, "r", encoding="utf-8") as f: config = json.load(f) - skip_packages = set(config.get("skip_packages", [])) - logger.info( - f"Loaded {len(skip_packages)} packages to skip from config file: {skip_packages}" - ) - return skip_packages + return set(config.get("skip_packages", [])) + + +def depends_on_util_genai(pyproject_path: Path) -> bool: + """Check if a package depends on opentelemetry-util-genai by reading pyproject.toml.""" + if not pyproject_path.exists(): + return False + + content = pyproject_path.read_text(encoding="utf-8") + return "opentelemetry-util-genai" in content + + +def should_rename_package(package_dir: Path) -> bool: + """ + Determine if a package should be renamed from opentelemetry-* to loongsuite-*. + + Rule: All packages under instrumentation-genai/ with opentelemetry-* prefix + should be renamed to loongsuite-* prefix. + """ + # Check if under instrumentation-genai directory + if "instrumentation-genai" not in str(package_dir): + return False + + # Check if package name starts with opentelemetry- + return package_dir.name.startswith("opentelemetry-") def get_package_name_from_whl(whl_path: Path) -> str: - """Extract package name from whl filename""" - # Format: package_name-version-py3-none-any.whl - # Or: package_name-version-cp39-cp39-linux_x86_64.whl - name = whl_path.stem # Remove .whl - # Find the part before the first number (version number) after the first - + """Extract package name from whl filename. + + Wheel filename format: {package}-{version}-{python}-{abi}-{platform}.whl + Example: loongsuite_instrumentation_openai_v2-0.1.0-py3-none-any.whl + + Note: Package names may contain version-like parts (e.g., 'v2', 'agents-v2') + that should NOT be treated as version numbers. + """ + name = whl_path.stem parts = name.split("-") if len(parts) >= 2: - # Package name is all parts before version number, joined with - - # Example: opentelemetry-instrumentation-langchain-2.0.0b0-py3-none-any - # Package name is: opentelemetry-instrumentation-langchain - # Need to find the position of version number (first part that looks like a version) package_parts = [] for part in parts: - # Version numbers usually contain digits and dots, or contain b0, dev, etc. - if any(c.isdigit() for c in part) or part in ( - "dev", - "b0", - "b1", - "rc0", - "rc1", - ): + # Check if this looks like a version number: + # - Starts with digit and contains dot (e.g., "0.1.0", "1.2.3") + # - Or is a known build tag + is_version = ( + (part and part[0].isdigit() and "." in part) # e.g., "0.1.0" + or part in ("dev", "b0", "b1", "rc0", "rc1") + or part.startswith("py") # Python tag: py3, py2 + or part in ("none", "any") # ABI/platform tags + ) + if is_version: break package_parts.append(part) - return "-".join(package_parts) - return name + return "-".join(package_parts).replace("_", "-") + return name.replace("_", "-") + + +@contextmanager +def _patch_pyproject(pyproject_path: Path, modifications: Dict[str, Any]): + """ + Temporarily patch pyproject.toml using TOML parsing, restore on exit. + + Args: + pyproject_path: Path to pyproject.toml + modifications: Dict with optional keys: + - "name": New package name (str) + - "replace_dependency": Dict with "old_pattern" and "new_value" + e.g., {"old_pattern": "opentelemetry-util-genai", "new_value": "loongsuite-util-genai ~= 0.1.0"} + """ + original_content = pyproject_path.read_text(encoding="utf-8") + try: + doc = tomlkit.parse(original_content) + + # Modify package name if specified + if "name" in modifications: + doc["project"]["name"] = modifications["name"] + + # Replace dependency if specified + if "replace_dependency" in modifications: + old_pattern = modifications["replace_dependency"]["old_pattern"] + new_value = modifications["replace_dependency"]["new_value"] + + if "dependencies" in doc["project"]: + deps = doc["project"]["dependencies"] + new_deps = [] + for dep in deps: + # Check if this dependency matches the pattern (package name prefix) + # e.g., "opentelemetry-util-genai >= 0.2b0" matches "opentelemetry-util-genai" + dep_str = str(dep).strip() + dep_name = re.split(r"[<>=~!\s\[]", dep_str)[0].strip() + if dep_name == old_pattern: + new_deps.append(new_value) + else: + new_deps.append(dep) + doc["project"]["dependencies"] = new_deps + + pyproject_path.write_text(tomlkit.dumps(doc), encoding="utf-8") + yield + finally: + pyproject_path.write_text(original_content, encoding="utf-8") + + +@contextmanager +def _patch_version_py(version_py_path: Path, new_version: str): + """Temporarily patch version.py, restore on exit.""" + if not version_py_path.exists(): + yield + return + + content = version_py_path.read_text(encoding="utf-8") + try: + patched = re.sub( + r'__version__\s*=\s*["\'][^"\']*["\']', + f'__version__ = "{new_version}"', + content, + ) + version_py_path.write_text(patched, encoding="utf-8") + yield + finally: + version_py_path.write_text(content, encoding="utf-8") + + +def find_version_py(package_dir: Path) -> Optional[Path]: + """Find version.py file in package directory""" + for version_py in package_dir.rglob("version.py"): + if "site-packages" not in str(version_py): + return version_py + return None def build_package( - package_dir: Path, dist_dir: Path, existing_whl_files: Set[Path] + package_dir: Path, + dist_dir: Path, + existing_whl_files: Set[Path], ) -> List[Path]: """Build whl file for a single package""" pyproject_toml = package_dir / "pyproject.toml" @@ -84,7 +193,6 @@ def build_package( logger.info(f"Building package: {package_dir}") try: - # Record whl files before build before_whl_files = set(dist_dir.glob("*.whl")) result = subprocess.run( @@ -102,7 +210,6 @@ def build_package( text=True, ) - # Find newly generated whl files (exist after build but not before) after_whl_files = set(dist_dir.glob("*.whl")) new_whl_files = [ f for f in after_whl_files - before_whl_files if f.suffix == ".whl" @@ -127,72 +234,199 @@ def build_package( return [] -def collect_packages( +def build_pypi_packages( base_dir: Path, dist_dir: Path, - skip_packages: Set[str], + version: str, + util_genai_version: Optional[str] = None, ) -> List[Path]: - """Collect all packages that need to be built""" + """ + Build packages for PyPI: + - loongsuite-util-genai (renamed from opentelemetry-util-genai) + - loongsuite-distro + """ all_whl_files = [] existing_whl_files = set(dist_dir.glob("*.whl")) - # 1. Build packages under instrumentation/ - instrumentation_dir = base_dir / "instrumentation" - if instrumentation_dir.exists(): - logger.info("Building packages under instrumentation/...") - for package_dir in sorted(instrumentation_dir.iterdir()): - if ( - package_dir.is_dir() - and (package_dir / "pyproject.toml").exists() + util_ver = util_genai_version or version + + # 1. Build util/opentelemetry-util-genai as loongsuite-util-genai + util_genai_dir = base_dir / "util" / "opentelemetry-util-genai" + if ( + util_genai_dir.exists() + and (util_genai_dir / "pyproject.toml").exists() + ): + logger.info(f"Building loongsuite-util-genai (version {util_ver})...") + version_py = find_version_py(util_genai_dir) + + modifications = { + "name": "loongsuite-util-genai", + } + + with _patch_pyproject( + util_genai_dir / "pyproject.toml", modifications + ): + with ( + _patch_version_py(version_py, util_ver) + if version_py + else nullcontext() ): whl_files = build_package( - package_dir, dist_dir, existing_whl_files + util_genai_dir, dist_dir, existing_whl_files ) all_whl_files.extend(whl_files) existing_whl_files.update(whl_files) - # 2. Build packages under instrumentation-genai/ + # 2. Build loongsuite-distro + distro_dir = base_dir / "loongsuite-distro" + if distro_dir.exists() and (distro_dir / "pyproject.toml").exists(): + logger.info(f"Building loongsuite-distro (version {version})...") + version_py = find_version_py(distro_dir) + + with ( + _patch_version_py(version_py, version) + if version_py + else nullcontext() + ): + whl_files = build_package(distro_dir, dist_dir, existing_whl_files) + all_whl_files.extend(whl_files) + existing_whl_files.update(whl_files) + + return all_whl_files + + +def build_github_release_packages( + base_dir: Path, + dist_dir: Path, + version: str, + util_genai_version: Optional[str] = None, + skip_packages: Optional[Set[str]] = None, +) -> List[Path]: + """ + Build packages for GitHub Release (tar.gz): + - instrumentation-genai/ (renamed to loongsuite-*, depends on loongsuite-util-genai) + - instrumentation-loongsuite/ (depends on loongsuite-util-genai) + - processor/loongsuite-processor-baggage/ + """ + all_whl_files = [] + existing_whl_files = set(dist_dir.glob("*.whl")) + skip_packages = skip_packages or set() + + util_ver = util_genai_version or version + util_dep_spec = f"loongsuite-util-genai ~= {util_ver}" + + def _get_modifications(package_dir: Path) -> Dict[str, Any]: + """ + Get pyproject.toml modifications for a package based on rules: + + Rules: + 1. Dependency replacement: If package depends on opentelemetry-util-genai, + replace it with loongsuite-util-genai (detected by reading pyproject.toml) + 2. Name replacement: If package is under instrumentation-genai/ and has + opentelemetry-* prefix, rename to loongsuite-* prefix + + Returns: + Dict with modifications to apply, e.g.: + { + "name": "loongsuite-instrumentation-foo", + "replace_dependency": { + "old_pattern": "opentelemetry-util-genai", + "new_value": "loongsuite-util-genai ~= 0.1.0" + } + } + """ + modifications: Dict[str, Any] = {} + pyproject_path = package_dir / "pyproject.toml" + + # Rule 1: Dependency replacement (dynamic detection) + # Replace any version of opentelemetry-util-genai with loongsuite-util-genai + if depends_on_util_genai(pyproject_path): + modifications["replace_dependency"] = { + "old_pattern": "opentelemetry-util-genai", + "new_value": util_dep_spec, + } + + # Rule 2: Name replacement (instrumentation-genai/ packages with opentelemetry-* prefix) + if should_rename_package(package_dir): + pkg_name = package_dir.name + new_name = pkg_name.replace("opentelemetry-", "loongsuite-") + modifications["name"] = new_name + + return modifications + + # 1. Build instrumentation-genai/ packages instrumentation_genai_dir = base_dir / "instrumentation-genai" if instrumentation_genai_dir.exists(): logger.info("Building packages under instrumentation-genai/...") for package_dir in sorted(instrumentation_genai_dir.iterdir()): if ( - package_dir.is_dir() - and (package_dir / "pyproject.toml").exists() + not package_dir.is_dir() + or not (package_dir / "pyproject.toml").exists() ): - whl_files = build_package( - package_dir, dist_dir, existing_whl_files - ) - all_whl_files.extend(whl_files) - existing_whl_files.update(whl_files) + continue + + pkg_name = package_dir.name + if pkg_name in skip_packages: + logger.info(f"Skipping {pkg_name} (in skip list)") + continue + + modifications = _get_modifications(package_dir) + version_py = find_version_py(package_dir) - # 3. Build packages under instrumentation-loongsuite/ + logger.info(f"Building {pkg_name} (version {version})...") + with ( + _patch_pyproject(package_dir / "pyproject.toml", modifications) + if modifications + else nullcontext() + ): + with ( + _patch_version_py(version_py, version) + if version_py + else nullcontext() + ): + whl_files = build_package( + package_dir, dist_dir, existing_whl_files + ) + all_whl_files.extend(whl_files) + existing_whl_files.update(whl_files) + + # 2. Build instrumentation-loongsuite/ packages instrumentation_loongsuite_dir = base_dir / "instrumentation-loongsuite" if instrumentation_loongsuite_dir.exists(): logger.info("Building packages under instrumentation-loongsuite/...") for package_dir in sorted(instrumentation_loongsuite_dir.iterdir()): if ( - package_dir.is_dir() - and (package_dir / "pyproject.toml").exists() + not package_dir.is_dir() + or not (package_dir / "pyproject.toml").exists() ): - whl_files = build_package( - package_dir, dist_dir, existing_whl_files - ) - all_whl_files.extend(whl_files) - existing_whl_files.update(whl_files) + continue - # 4. Build util/opentelemetry-util-genai/ - util_genai_dir = base_dir / "util" / "opentelemetry-util-genai" - if ( - util_genai_dir.exists() - and (util_genai_dir / "pyproject.toml").exists() - ): - logger.info("Building util/opentelemetry-util-genai/...") - whl_files = build_package(util_genai_dir, dist_dir, existing_whl_files) - all_whl_files.extend(whl_files) - existing_whl_files.update(whl_files) + pkg_name = package_dir.name + if pkg_name in skip_packages: + logger.info(f"Skipping {pkg_name} (in skip list)") + continue - # 5. Build processor/loongsuite-processor-baggage/ + modifications = _get_modifications(package_dir) + version_py = find_version_py(package_dir) + + logger.info(f"Building {pkg_name} (version {version})...") + with ( + _patch_pyproject(package_dir / "pyproject.toml", modifications) + if modifications + else nullcontext() + ): + with ( + _patch_version_py(version_py, version) + if version_py + else nullcontext() + ): + whl_files = build_package( + package_dir, dist_dir, existing_whl_files + ) + all_whl_files.extend(whl_files) + existing_whl_files.update(whl_files) + + # 3. Build processor/loongsuite-processor-baggage/ processor_baggage_dir = ( base_dir / "processor" / "loongsuite-processor-baggage" ) @@ -200,55 +434,59 @@ def collect_packages( processor_baggage_dir.exists() and (processor_baggage_dir / "pyproject.toml").exists() ): - logger.info("Building processor/loongsuite-processor-baggage/...") - whl_files = build_package( - processor_baggage_dir, dist_dir, existing_whl_files - ) - all_whl_files.extend(whl_files) - existing_whl_files.update(whl_files) + pkg_name = processor_baggage_dir.name + if pkg_name not in skip_packages: + version_py = find_version_py(processor_baggage_dir) + logger.info(f"Building {pkg_name} (version {version})...") + with ( + _patch_version_py(version_py, version) + if version_py + else nullcontext() + ): + whl_files = build_package( + processor_baggage_dir, dist_dir, existing_whl_files + ) + all_whl_files.extend(whl_files) + existing_whl_files.update(whl_files) + + return all_whl_files + - # 6. Filter out packages that need to be skipped +def _filter_and_dedupe_whl_files( + all_whl_files: List[Path], + skip_packages: Set[str], +) -> List[Path]: + """Filter skip list and deduplicate whl files.""" filtered_whl_files = [] skipped_count = 0 - seen_packages = {} # Used to detect duplicate packages + seen_packages = {} for whl_file in all_whl_files: package_name = get_package_name_from_whl(whl_file) - # Check if in skip list if package_name in skip_packages: - logger.info( - f"Skipping package: {package_name} (according to config file)" - ) + logger.info(f"Skipping package: {package_name} (in skip list)") skipped_count += 1 - # Delete skipped whl file whl_file.unlink() continue - # Check for duplicate packages (same package may have multiple whl files, e.g., different platforms) if package_name in seen_packages: - # Keep the newest file existing_file = seen_packages[package_name] if whl_file.stat().st_mtime > existing_file.stat().st_mtime: - logger.debug( - f"Replacing duplicate package {package_name}: {existing_file.name} -> {whl_file.name}" - ) + logger.debug(f"Replacing duplicate {package_name}") existing_file.unlink() seen_packages[package_name] = whl_file filtered_whl_files.remove(existing_file) filtered_whl_files.append(whl_file) else: - logger.debug( - f"Skipping older version {package_name}: {whl_file.name}" - ) whl_file.unlink() else: seen_packages[package_name] = whl_file filtered_whl_files.append(whl_file) - logger.info(f"Built {len(all_whl_files)} whl files in total") + logger.info(f"Built {len(all_whl_files)} whl files") logger.info(f"Skipped {skipped_count} packages") - logger.info(f"Final package contains {len(filtered_whl_files)} whl files") + logger.info(f"Final: {len(filtered_whl_files)} whl files") return filtered_whl_files @@ -259,24 +497,46 @@ def create_tar_archive(whl_files: List[Path], output_path: Path): with tarfile.open(output_path, "w:gz") as tar: for whl_file in sorted(whl_files): - # Only save filename, not path tar.add(whl_file, arcname=whl_file.name) - logger.debug(f"Added to archive: {whl_file.name}") + logger.debug(f"Added: {whl_file.name}") + + size_mb = output_path.stat().st_size / 1024 / 1024 + logger.info(f"Created: {output_path} ({size_mb:.2f} MB)") - logger.info( - f"Successfully created archive: {output_path} ({output_path.stat().st_size / 1024 / 1024:.2f} MB)" - ) + +# Python 3.9 compatibility: nullcontext +try: + from contextlib import nullcontext +except ImportError: + from contextlib import contextmanager + + @contextmanager + def nullcontext(): + yield def main(): parser = argparse.ArgumentParser( - description="Build loongsuite Python Agent release package" + description="LoongSuite Release Build Script", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + # Build for PyPI (loongsuite-util-genai + loongsuite-distro) + python build_loongsuite_package.py --build-pypi --version 0.1.0 + + # Build for GitHub Release (instrumentation packages) + python build_loongsuite_package.py --build-github-release --version 0.1.0 + + # Build both + python build_loongsuite_package.py --build-pypi --build-github-release --version 0.1.0 + """, ) + parser.add_argument( "--base-dir", type=Path, default=Path(__file__).parent.parent, - help="Project root directory (default: script's parent directory)", + help="Project root directory", ) parser.add_argument( "--dist-dir", @@ -288,19 +548,48 @@ def main(): "--config", type=Path, default=Path(__file__).parent / "loongsuite-build-config.json", - help="Config file path (default: scripts/loongsuite-build-config.json)", + help="Config file for skip packages", ) + + # Build mode parser.add_argument( - "--output", - type=Path, - default=None, - help="Output tar.gz file path (default: dist/loongsuite-python-agent-.tar.gz)", + "--build-pypi", + action="store_true", + help="Build packages for PyPI (loongsuite-util-genai, loongsuite-distro)", ) + parser.add_argument( + "--build-github-release", + action="store_true", + help="Build packages for GitHub Release (instrumentation-genai, instrumentation-loongsuite)", + ) + + # Legacy mode (for backward compatibility) + parser.add_argument( + "--loongsuite-release", + action="store_true", + help="(Legacy) Same as --build-github-release", + ) + + # Version settings parser.add_argument( "--version", type=str, - default="dev", - help="Version number (for output filename)", + required=True, + help="Version for all packages", + ) + parser.add_argument( + "--util-genai-version", + type=str, + default=None, + help="Version for loongsuite-util-genai (default: same as --version)", + ) + + # Output + parser.add_argument( + "--output", + type=Path, + default=None, + help="Output tar.gz path (for GitHub Release)", ) args = parser.parse_args() @@ -314,24 +603,72 @@ def main(): for old_file in dist_dir.glob("*.whl"): old_file.unlink() - # Load skip config skip_packages = load_skip_config(args.config) - # Collect and build all packages - whl_files = collect_packages(base_dir, dist_dir, skip_packages) + # Handle legacy mode + if args.loongsuite_release: + args.build_github_release = True - if not whl_files: - logger.error("No whl files found, build failed") - sys.exit(1) + if not args.build_pypi and not args.build_github_release: + parser.error( + "Must specify at least one of --build-pypi or --build-github-release" + ) - # Create tar archive - output_path = args.output or ( - dist_dir / f"loongsuite-python-agent-{args.version}.tar.gz" - ) - create_tar_archive(whl_files, output_path) + pypi_whl_files = [] + github_whl_files = [] + + # Build PyPI packages + if args.build_pypi: + logger.info("=" * 50) + logger.info("Building PyPI packages...") + logger.info("=" * 50) + pypi_whl_files = build_pypi_packages( + base_dir, + dist_dir, + args.version, + args.util_genai_version, + ) + logger.info(f"PyPI packages built: {len(pypi_whl_files)}") + for whl in pypi_whl_files: + logger.info(f" - {whl.name}") + + # Build GitHub Release packages + if args.build_github_release: + logger.info("=" * 50) + logger.info("Building GitHub Release packages...") + logger.info("=" * 50) + github_whl_files = build_github_release_packages( + base_dir, + dist_dir, + args.version, + args.util_genai_version, + skip_packages, + ) + github_whl_files = _filter_and_dedupe_whl_files( + github_whl_files, skip_packages + ) + + if github_whl_files: + output_path = args.output or ( + dist_dir / f"loongsuite-python-agent-{args.version}.tar.gz" + ) + create_tar_archive(github_whl_files, output_path) + logger.info(f"GitHub Release tar: {output_path}") + + logger.info("=" * 50) logger.info("Build completed!") - logger.info(f"Output file: {output_path}") + logger.info("=" * 50) + + if pypi_whl_files: + logger.info("PyPI packages (upload with twine):") + for whl in pypi_whl_files: + logger.info(f" {whl}") + + if github_whl_files: + logger.info( + f"GitHub Release tar ready: {dist_dir}/loongsuite-python-agent-{args.version}.tar.gz" + ) if __name__ == "__main__": diff --git a/scripts/dry_run_loongsuite_release.sh b/scripts/dry_run_loongsuite_release.sh new file mode 100755 index 000000000..5e200b439 --- /dev/null +++ b/scripts/dry_run_loongsuite_release.sh @@ -0,0 +1,348 @@ +#!/usr/bin/env bash +# +# LoongSuite Release Dry Run Script +# +# Simulates the GitHub Actions loongsuite-release workflow locally to verify: +# 1. bootstrap_gen.py generation with version overrides +# 2. PyPI package build (loongsuite-util-genai, loongsuite-distro) +# 3. GitHub Release package build (instrumentation-genai, instrumentation-loongsuite) +# 4. Package content verification +# 5. Optional: Installation test in temporary venv +# +# Usage: +# ./scripts/dry_run_loongsuite_release.sh --loongsuite-version 0.1.0 --upstream-version 0.60b1 +# ./scripts/dry_run_loongsuite_release.sh -l 0.1.0 -u 0.60b1 --skip-install +# ./scripts/dry_run_loongsuite_release.sh -l 0.1.0 -u 0.60b1 --skip-pypi +# +set -e + +# Default values +LOONGSUITE_VERSION="" +UPSTREAM_VERSION="" +SKIP_INSTALL=false +SKIP_PYPI=false + +# Parse arguments +while [[ $# -gt 0 ]]; do + case $1 in + -l|--loongsuite-version) + LOONGSUITE_VERSION="$2" + shift 2 + ;; + -u|--upstream-version) + UPSTREAM_VERSION="$2" + shift 2 + ;; + --skip-install) + SKIP_INSTALL=true + shift + ;; + --skip-pypi) + SKIP_PYPI=true + shift + ;; + -h|--help) + echo "Usage: $0 --loongsuite-version --upstream-version [options]" + echo "" + echo "Required:" + echo " -l, --loongsuite-version Version for loongsuite-* packages (e.g., 0.1.0)" + echo " -u, --upstream-version Version for opentelemetry-* packages (e.g., 0.60b1)" + echo "" + echo "Options:" + echo " --skip-install Skip installation verification" + echo " --skip-pypi Skip PyPI package build" + echo " -h, --help Show this help message" + exit 0 + ;; + *) + echo "Unknown option: $1" + exit 1 + ;; + esac +done + +# Validate required arguments +if [[ -z "$LOONGSUITE_VERSION" ]]; then + echo "ERROR: --loongsuite-version is required" + exit 1 +fi +if [[ -z "$UPSTREAM_VERSION" ]]; then + echo "ERROR: --upstream-version is required" + exit 1 +fi + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$REPO_ROOT" + +TAR_NAME="loongsuite-python-agent-${LOONGSUITE_VERSION}.tar.gz" +TAR_PATH="${REPO_ROOT}/dist/${TAR_NAME}" +RELEASE_NOTES_FILE="${REPO_ROOT}/dist/release-notes-dryrun.txt" +DRYRUN_VENV="${REPO_ROOT}/.venv_loongsuite_dryrun" + +echo "==========================================" +echo "LoongSuite Release Dry Run" +echo "==========================================" +echo "LoongSuite version: $LOONGSUITE_VERSION" +echo "Upstream version: $UPSTREAM_VERSION" +echo "Repo root: $REPO_ROOT" +echo "" + +# Step 1: Install build dependencies +echo ">>> Step 1: Installing build dependencies..." +python -m pip install -q -r pkg-requirements.txt 2>/dev/null || { + echo " Installing dependencies from pkg-requirements.txt..." + python -m pip install -r pkg-requirements.txt +} +echo " OK" +echo "" + +# Step 2: Generate bootstrap_gen.py +echo ">>> Step 2: Generating bootstrap_gen.py..." +python scripts/generate_loongsuite_bootstrap.py \ + --upstream-version "$UPSTREAM_VERSION" \ + --loongsuite-version "$LOONGSUITE_VERSION" + +echo " OK: Generated bootstrap_gen.py" +echo " Preview (first 20 lines):" +head -20 loongsuite-distro/src/loongsuite/distro/bootstrap_gen.py | sed 's/^/ /' +echo "" + +# Step 3: Build PyPI packages +PYPI_DIST_DIR="${REPO_ROOT}/dist-pypi" +rm -rf "$PYPI_DIST_DIR" +mkdir -p "$PYPI_DIST_DIR" + +if [[ "$SKIP_PYPI" != "true" ]]; then + echo ">>> Step 3: Building PyPI packages..." + python scripts/build_loongsuite_package.py \ + --build-pypi \ + --version "$LOONGSUITE_VERSION" + + # Save PyPI packages to separate directory (before step 4 cleans dist/) + cp dist/*.whl "$PYPI_DIST_DIR/" 2>/dev/null || true + + echo " OK: PyPI packages built" + echo " Packages:" + ls "$PYPI_DIST_DIR"/*.whl 2>/dev/null | while read f; do echo " - $(basename "$f")"; done + echo "" +else + echo ">>> Step 3: Skipped (--skip-pypi)" + echo "" +fi + +# Step 4: Build GitHub Release packages +echo ">>> Step 4: Building GitHub Release packages..." +python scripts/build_loongsuite_package.py \ + --build-github-release \ + --version "$LOONGSUITE_VERSION" + +if [[ ! -f "$TAR_PATH" ]]; then + echo " ERROR: Build failed, $TAR_PATH not found" + exit 1 +fi +echo " OK: $TAR_PATH ($(du -h "$TAR_PATH" | cut -f1))" +echo "" + +# Step 5: Verify tar contents +echo ">>> Step 5: Verifying tar contents..." + +# Check for loongsuite-util-genai (should NOT be in tar, it's on PyPI) +if tar -tzf "$TAR_PATH" | grep -q "loongsuite_util_genai"; then + echo " WARN: loongsuite-util-genai in tar (should be on PyPI only)" +else + echo " OK: loongsuite-util-genai not in tar (correct, on PyPI)" +fi + +# Check for opentelemetry-util-genai (should NOT be in tar) +if tar -tzf "$TAR_PATH" | grep -q "opentelemetry_util_genai"; then + echo " ERROR: opentelemetry-util-genai should NOT be in tar" + exit 1 +else + echo " OK: opentelemetry-util-genai not in tar" +fi + +# Check for loongsuite-instrumentation-* packages +if tar -tzf "$TAR_PATH" | grep -q "loongsuite_instrumentation"; then + echo " OK: loongsuite-instrumentation-* packages in tar" +else + echo " WARN: No loongsuite-instrumentation-* packages found in tar" +fi + +# Check that opentelemetry-instrumentation-flask is NOT in tar +if tar -tzf "$TAR_PATH" | grep -q "opentelemetry_instrumentation_flask"; then + echo " WARN: opentelemetry-instrumentation-flask in tar (should be from PyPI)" +else + echo " OK: opentelemetry-instrumentation-flask not in tar (from PyPI)" +fi + +echo " Package count: $(tar -tzf "$TAR_PATH" | wc -l | tr -d ' ')" +echo " Contents:" +tar -tzf "$TAR_PATH" | head -20 | sed 's/^/ /' +echo "" + +# Step 6: Generate release notes +echo ">>> Step 6: Generating release notes..." + +# Start with header +cat > "$RELEASE_NOTES_FILE" << EOF +# LoongSuite Python Agent v$LOONGSUITE_VERSION + +## Installation + +\`\`\`bash +pip install loongsuite-distro==$LOONGSUITE_VERSION +loongsuite-bootstrap -a install --version $LOONGSUITE_VERSION +\`\`\` + +## Package Versions + +- loongsuite-* packages: $LOONGSUITE_VERSION +- opentelemetry-* packages: $UPSTREAM_VERSION + +--- + +EOF + +# Collect from root CHANGELOG-loongsuite.md +if [[ -f CHANGELOG-loongsuite.md ]]; then + echo "## loongsuite-distro" >> "$RELEASE_NOTES_FILE" + echo "" >> "$RELEASE_NOTES_FILE" + # Extract Unreleased section (handle both "## Unreleased" and "## [Unreleased]") + sed -n '/^## \[*Unreleased\]*$/,/^## /p' CHANGELOG-loongsuite.md | sed '/^## /d' >> "$RELEASE_NOTES_FILE" + echo "" >> "$RELEASE_NOTES_FILE" +fi + +# Collect from instrumentation-loongsuite/*/CHANGELOG.md +for changelog in instrumentation-loongsuite/*/CHANGELOG.md; do + if [[ -f "$changelog" ]]; then + # Extract package name from path + pkg_dir=$(dirname "$changelog") + pkg_name=$(basename "$pkg_dir") + + # Extract Unreleased section + unreleased_content=$(sed -n '/^## \[*Unreleased\]*$/,/^## /p' "$changelog" | sed '/^## /d') + + if [[ -n "$unreleased_content" && "$unreleased_content" =~ [^[:space:]] ]]; then + echo "## $pkg_name" >> "$RELEASE_NOTES_FILE" + echo "" >> "$RELEASE_NOTES_FILE" + echo "$unreleased_content" >> "$RELEASE_NOTES_FILE" + echo "" >> "$RELEASE_NOTES_FILE" + fi + fi +done + +echo " OK: $RELEASE_NOTES_FILE" +echo " Preview:" +head -30 "$RELEASE_NOTES_FILE" | sed 's/^/ /' +echo "" + +# Step 7: Install verification (optional) +if [[ "$SKIP_INSTALL" == "true" ]]; then + echo ">>> Step 7: Skipped (--skip-install)" +else + echo ">>> Step 7: Install verification (temp venv)..." + rm -rf "$DRYRUN_VENV" + python -m venv "$DRYRUN_VENV" + source "$DRYRUN_VENV/bin/activate" + + # Install loongsuite-distro from local (has loongsuite-bootstrap) + echo " Installing loongsuite-distro from local..." + pip install -q -e ./loongsuite-distro + + # Pre-install loongsuite-util-genai from local build (simulating PyPI) + # In production, this is installed as a transitive dependency of instrumentation packages + # In dry run, we need to install it first because it's not yet on PyPI + if [[ "$SKIP_PYPI" != "true" ]]; then + UTIL_WHL=$(ls "$PYPI_DIST_DIR"/loongsuite_util_genai-*.whl 2>/dev/null | head -1) + if [[ -n "$UTIL_WHL" ]]; then + echo " Pre-installing loongsuite-util-genai from local build (simulating PyPI)..." + pip install -q "$UTIL_WHL" + else + echo " ERROR: loongsuite-util-genai wheel not found in $PYPI_DIST_DIR" + echo " Cannot proceed - instrumentation packages depend on it" + deactivate + rm -rf "$DRYRUN_VENV" + exit 1 + fi + else + echo " ERROR: PyPI build skipped, loongsuite-util-genai not available" + echo " Cannot proceed - instrumentation packages depend on it" + deactivate + rm -rf "$DRYRUN_VENV" + exit 1 + fi + + # Create whitelist for minimal test + WHITELIST_FILE=$(mktemp) + cat > "$WHITELIST_FILE" << 'WL' +loongsuite-instrumentation-dashscope +WL + + echo " Running: loongsuite-bootstrap -a install --tar $TAR_PATH --whitelist $WHITELIST_FILE" + if loongsuite-bootstrap -a install --tar "$TAR_PATH" --whitelist "$WHITELIST_FILE" 2>&1; then + echo "" + echo " Verifying installed packages..." + + # Check loongsuite-util-genai (from PyPI/local build) + if pip show loongsuite-util-genai &>/dev/null; then + echo " OK: loongsuite-util-genai installed ($(pip show loongsuite-util-genai | grep Version:))" + else + echo " WARN: loongsuite-util-genai not installed" + fi + + # Check opentelemetry-util-genai should NOT be installed + if pip show opentelemetry-util-genai &>/dev/null; then + echo " WARN: opentelemetry-util-genai installed (may conflict)" + else + echo " OK: opentelemetry-util-genai not installed (correct)" + fi + + # Check loongsuite-instrumentation-dashscope + if pip show loongsuite-instrumentation-dashscope &>/dev/null; then + echo " OK: loongsuite-instrumentation-dashscope installed" + else + echo " WARN: loongsuite-instrumentation-dashscope not installed" + fi + + rm -f "$WHITELIST_FILE" + deactivate + rm -rf "$DRYRUN_VENV" + echo " OK: Install verification passed" + else + echo " ERROR: loongsuite-bootstrap install failed" + rm -f "$WHITELIST_FILE" + deactivate + rm -rf "$DRYRUN_VENV" + exit 1 + fi +fi +echo "" + +echo "==========================================" +echo "Dry Run Complete" +echo "==========================================" +echo "" +echo "Artifacts:" +if [[ "$SKIP_PYPI" != "true" ]]; then + echo " PyPI packages (in $PYPI_DIST_DIR):" + ls "$PYPI_DIST_DIR"/*.whl 2>/dev/null | while read f; do echo " - $f"; done +fi +echo " GitHub Release:" +echo " - $TAR_PATH" +echo " Release notes:" +echo " - $RELEASE_NOTES_FILE" +echo "" +echo "Simulated GitHub release commands:" +echo "" +echo " # PyPI publish (with twine):" +if [[ "$SKIP_PYPI" != "true" ]]; then + echo " twine upload $PYPI_DIST_DIR/loongsuite_util_genai-${LOONGSUITE_VERSION}-*.whl" + echo " twine upload $PYPI_DIST_DIR/loongsuite_distro-${LOONGSUITE_VERSION}-*.whl" +fi +echo "" +echo " # GitHub Release:" +echo " gh release create v$LOONGSUITE_VERSION \\" +echo " --title \"LoongSuite Python Agent v$LOONGSUITE_VERSION\" \\" +echo " --notes-file $RELEASE_NOTES_FILE \\" +echo " $TAR_PATH" +echo "" diff --git a/scripts/generate_loongsuite_bootstrap.py b/scripts/generate_loongsuite_bootstrap.py index ef4028c7f..ea655d90d 100644 --- a/scripts/generate_loongsuite_bootstrap.py +++ b/scripts/generate_loongsuite_bootstrap.py @@ -14,10 +14,37 @@ # See the License for the specific language governing permissions and # limitations under the License. +""" +Generate bootstrap_gen.py for loongsuite-distro. + +This script generates the libraries and default_instrumentations lists +used by loongsuite-bootstrap to install instrumentations. + +Package naming and version strategy: +- instrumentation-genai/* packages: renamed to loongsuite-* prefix +- instrumentation-loongsuite/* packages: keep loongsuite-* prefix +- instrumentation/* packages: keep opentelemetry-* prefix (from upstream PyPI) + +Version strategy: +- --upstream-version: Version for upstream opentelemetry-instrumentation-* packages +- --loongsuite-version: Version for loongsuite-* packages + +Usage: + # Generate with default versions from source + python scripts/generate_loongsuite_bootstrap.py + + # Generate with specific versions for release + python scripts/generate_loongsuite_bootstrap.py \\ + --upstream-version 0.60b1 \\ + --loongsuite-version 0.1.0 +""" + +import argparse import ast import logging import subprocess from pathlib import Path +from typing import Optional import tomli from generate_instrumentation_bootstrap import ( @@ -40,15 +67,14 @@ # DO NOT EDIT. THIS FILE WAS AUTOGENERATED FROM INSTRUMENTATION PACKAGES. # RUN `python scripts/generate_loongsuite_bootstrap.py` TO REGENERATE. +# +# Generated with options: +# --upstream-version: {upstream_version} +# --loongsuite-version: {loongsuite_version} {source} """ -_source_tmpl = """ -libraries = [] -default_instrumentations = [] -""" - gen_path = ( root_path / "loongsuite-distro" @@ -59,24 +85,108 @@ ) -def get_instrumentation_packages(): - """Get all instrumentation packages from various directories""" +def get_genai_packages_to_rename() -> dict[str, str]: + """ + Dynamically discover instrumentation-genai packages that need renaming. + + Rule: All packages under instrumentation-genai/ with opentelemetry-* prefix + should be renamed to loongsuite-* prefix, except those in packages_to_exclude. + + Returns: + Dict mapping original name to new name, e.g.: + {"opentelemetry-instrumentation-vertexai": "loongsuite-instrumentation-vertexai"} + """ + result = {} + genai_dir = root_path / "instrumentation-genai" + + if not genai_dir.exists(): + return result + + for pkg_dir in genai_dir.iterdir(): + if not pkg_dir.is_dir(): + continue + + pkg_name = pkg_dir.name + + # Rule: opentelemetry-* prefix packages should be renamed to loongsuite-* + if pkg_name.startswith("opentelemetry-"): + # Skip packages in upstream exclude list + if pkg_name in packages_to_exclude: + continue + + new_name = pkg_name.replace("opentelemetry-", "loongsuite-") + result[pkg_name] = new_name + + return result + + +def get_instrumentation_packages( + upstream_version: Optional[str] = None, + loongsuite_version: Optional[str] = None, +): + """ + Get all instrumentation packages from various directories. + + Args: + upstream_version: Override version for upstream opentelemetry-* packages + loongsuite_version: Override version for loongsuite-* packages + + Note: + When the same package name exists in both instrumentation-genai and + instrumentation-loongsuite, the version from instrumentation-loongsuite + takes precedence (overwrites the earlier one). + """ packages = [] + seen_packages: dict[str, int] = {} # name -> index in packages list logger.info("Scanning instrumentation packages...") + # Dynamically get packages that need renaming + genai_packages_to_rename = get_genai_packages_to_rename() + # Get packages from upstream directories (instrumentation, instrumentation-genai) - # using otel_packaging logger.info( "Processing upstream packages (instrumentation, instrumentation-genai)..." ) for pkg in get_upstream_packages( independent_packages=independent_packages ): - if pkg["name"] not in packages_to_exclude: + pkg_name = pkg["name"] + + # Skip packages in exclude list (follow upstream behavior) + if pkg_name in packages_to_exclude: + continue + + # Check if this package should be renamed (instrumentation-genai with opentelemetry-* prefix) + if pkg_name in genai_packages_to_rename: + new_name = genai_packages_to_rename[pkg_name] + logger.info(f"Renaming {pkg_name} -> {new_name}") + pkg["name"] = new_name + + # Use loongsuite version for renamed packages + if loongsuite_version: + pkg["requirement"] = f"{new_name}=={loongsuite_version}" + else: + # Keep original version but update name + pkg["requirement"] = pkg["requirement"].replace( + pkg_name, new_name + ) + else: + # Use upstream version for opentelemetry-* packages + if upstream_version and pkg_name.startswith("opentelemetry-"): + pkg["requirement"] = f"{pkg_name}=={upstream_version}" + + # Track package by name (for deduplication) + final_name = pkg["name"] + if final_name in seen_packages: + # Replace existing package (instrumentation-loongsuite should win) + idx = seen_packages[final_name] + packages[idx] = pkg + else: + seen_packages[final_name] = len(packages) packages.append(pkg) - # Scan instrumentation-loongsuite directory (reuse same logic as otel_packaging) + # Scan instrumentation-loongsuite directory loongsuite_dir = root_path / "instrumentation-loongsuite" if loongsuite_dir.exists(): logger.info( @@ -89,13 +199,13 @@ def get_instrumentation_packages(): continue try: - # Get version using hatch command (same as otel_packaging) - # Suppress hatch's verbose output + # Get version using hatch command version = subprocess.check_output( "hatch version", shell=True, cwd=pkg_dir, universal_newlines=True, + stderr=subprocess.DEVNULL, ).strip() # Read pyproject.toml @@ -104,17 +214,20 @@ def get_instrumentation_packages(): pkg_name = pyproject["project"]["name"] - # Skip if this package is in the exclusion list if pkg_name in packages_to_exclude: continue # Get optional dependencies - optional_deps = pyproject["project"]["optional-dependencies"] + optional_deps = pyproject["project"].get( + "optional-dependencies", {} + ) instruments = optional_deps.get("instruments", []) instruments_any = optional_deps.get("instruments-any", []) - # Handle independent packages - if pkg_name in independent_packages: + # Use loongsuite version if specified + if loongsuite_version: + requirement = f"{pkg_name}=={loongsuite_version}" + elif pkg_name in independent_packages: specifier = independent_packages[pkg_name] requirement = ( f"{pkg_name}{specifier}" @@ -124,15 +237,24 @@ def get_instrumentation_packages(): else: requirement = f"{pkg_name}=={version}" - packages.append( - { - "name": pkg_name, - "version": version, - "instruments": instruments, - "instruments-any": instruments_any, - "requirement": requirement, - } - ) + new_pkg = { + "name": pkg_name, + "version": version, + "instruments": instruments, + "instruments-any": instruments_any, + "requirement": requirement, + } + + # Deduplication: instrumentation-loongsuite takes precedence + if pkg_name in seen_packages: + idx = seen_packages[pkg_name] + logger.info( + f"Overwriting {pkg_name} with instrumentation-loongsuite version" + ) + packages[idx] = new_pkg + else: + seen_packages[pkg_name] = len(packages) + packages.append(new_pkg) except subprocess.CalledProcessError as e: logger.warning( f"Could not get hatch version from {pkg_dir.name}: {e}" @@ -146,6 +268,23 @@ def get_instrumentation_packages(): def main(): + parser = argparse.ArgumentParser( + description="Generate bootstrap_gen.py for loongsuite-distro" + ) + parser.add_argument( + "--upstream-version", + type=str, + default=None, + help="Override version for upstream opentelemetry-instrumentation-* packages (e.g., 0.60b1)", + ) + parser.add_argument( + "--loongsuite-version", + type=str, + default=None, + help="Override version for loongsuite-* packages (e.g., 0.1.0)", + ) + args = parser.parse_args() + # Read license header header_path = scripts_path / "license_header.txt" if header_path.exists(): @@ -155,7 +294,10 @@ def main(): header = "# Copyright The OpenTelemetry Authors\n# Licensed under the Apache License, Version 2.0\n" # Get all packages - packages = get_instrumentation_packages() + packages = get_instrumentation_packages( + upstream_version=args.upstream_version, + loongsuite_version=args.loongsuite_version, + ) logger.info(f"Found {len(packages)} instrumentation packages") # Build AST nodes @@ -165,13 +307,13 @@ def main(): logger.info("Building bootstrap configuration...") for pkg in packages: # If no instruments and no instruments-any, it's a default instrumentation - if not pkg["instruments"] and not pkg["instruments-any"]: + if not pkg.get("instruments") and not pkg.get("instruments-any"): default_instrumentations.elts.append( ast.Constant(value=pkg["requirement"]) ) else: # Add instruments (all must be installed) - for target_pkg in pkg["instruments"]: + for target_pkg in pkg.get("instruments", []): libraries.elts.append( ast.Dict( keys=[ @@ -186,7 +328,7 @@ def main(): ) # Add instruments-any (at least one must be installed) - for target_pkg in pkg["instruments-any"]: + for target_pkg in pkg.get("instruments-any", []): libraries.elts.append( ast.Dict( keys=[ @@ -200,19 +342,17 @@ def main(): ) ) - # Generate source code manually (avoiding astor dependency) + # Generate source code logger.info("Generating source code...") # Build libraries list string libraries_lines = ["libraries = ["] for lib_mapping in libraries.elts: if isinstance(lib_mapping, ast.Dict): - # Extract keys and values lib_key = lib_mapping.keys[0] instr_key = lib_mapping.keys[1] lib_val_node = lib_mapping.values[0] instr_val_node = lib_mapping.values[1] - # Get string values lib_key_str = ( lib_key.value if isinstance(lib_key, ast.Constant) @@ -238,7 +378,6 @@ def main(): else (instr_val_node.s if hasattr(instr_val_node, "s") else "") ) - # Escape quotes in values lib_val_str = lib_val_str.replace('"', '\\"') instr_val_str = instr_val_str.replace('"', '\\"') @@ -258,8 +397,13 @@ def main(): # Combine source source = "\n".join(libraries_lines) + "\n\n" + "\n".join(default_lines) - # Format with header - formatted_source = _template.format(header=header, source=source) + # Format with header and version info + formatted_source = _template.format( + header=header, + source=source, + upstream_version=args.upstream_version or "(from source)", + loongsuite_version=args.loongsuite_version or "(from source)", + ) # Write to file gen_path.parent.mkdir(parents=True, exist_ok=True) diff --git a/scripts/loongsuite-build-config.json b/scripts/loongsuite-build-config.json index 4b567e738..2eb533711 100644 --- a/scripts/loongsuite-build-config.json +++ b/scripts/loongsuite-build-config.json @@ -1,11 +1,11 @@ { - "skip_packages": [ - "opentelemetry-instrumentation-langchain" - ], - "description": "Build configuration file, defines packages to skip (to avoid duplicates)", + "skip_packages": [], + "description": "Build configuration file, defines packages to skip during build", "notes": [ + "Packages in upstream packages_to_exclude are already excluded automatically.", + "This config is for additional LoongSuite-specific skip rules if needed.", "When there are packages with the same name in instrumentation-genai and instrumentation-loongsuite,", - "prefer the version in instrumentation-loongsuite and skip the version in instrumentation-genai" + "prefer the version in instrumentation-loongsuite and skip the version in instrumentation-genai." ] } diff --git a/util/opentelemetry-util-genai/scripts/generate_version_mapping_table.py b/util/opentelemetry-util-genai/scripts/generate_version_mapping_table.py index d6baaed6b..27c421abd 100644 --- a/util/opentelemetry-util-genai/scripts/generate_version_mapping_table.py +++ b/util/opentelemetry-util-genai/scripts/generate_version_mapping_table.py @@ -34,7 +34,9 @@ def _render_generated_block(data: dict[str, Any], generated_at: str) -> str: lines: list[str] = [] lines.append(BEGIN_MARKER) - lines.append(".. This section is generated by scripts/generate_version_mapping_table.py") + lines.append( + ".. This section is generated by scripts/generate_version_mapping_table.py" + ) lines.append("") lines.append(f"- 组件: ``{component}``") lines.append(f"- 上游仓库: ``{upstream_repo}``") @@ -62,9 +64,15 @@ def _render_generated_block(data: dict[str, Any], generated_at: str) -> str: else: for item in mappings: source = f"{_format_cell(item.get('upstream_ref_type'))}:{_format_cell(item.get('upstream_ref'))}" - lines.append(f" * - ``{_format_cell(item.get('loongsuite_version'))}``") - lines.append(f" - ``{_format_cell(item.get('upstream_version'))}``") - lines.append(f" - ``{_format_cell(item.get('upstream_commit'))}``") + lines.append( + f" * - ``{_format_cell(item.get('loongsuite_version'))}``" + ) + lines.append( + f" - ``{_format_cell(item.get('upstream_version'))}``" + ) + lines.append( + f" - ``{_format_cell(item.get('upstream_commit'))}``" + ) lines.append(f" - ``{source}``") lines.append(f" - ``{_format_cell(item.get('sync_date'))}``") lines.append(f" - {_format_cell(item.get('notes'))}") @@ -98,19 +106,23 @@ def main() -> None: parser.add_argument( "--mapping", type=Path, - default=Path(__file__).resolve().parent.parent / "upstream_version_map.json", + default=Path(__file__).resolve().parent.parent + / "upstream_version_map.json", help="Path to upstream version mapping JSON file.", ) parser.add_argument( "--readme", type=Path, - default=Path(__file__).resolve().parent.parent / "README-loongsuite.rst", + default=Path(__file__).resolve().parent.parent + / "README-loongsuite.rst", help="Path to README-loongsuite.rst file.", ) args = parser.parse_args() data = _load_mapping(args.mapping) - generated_at = dt.datetime.now(dt.timezone.utc).replace(microsecond=0).isoformat() + generated_at = ( + dt.datetime.now(dt.timezone.utc).replace(microsecond=0).isoformat() + ) block = _render_generated_block(data, generated_at) readme_content = args.readme.read_text(encoding="utf-8") diff --git a/util/opentelemetry-util-genai/scripts/update_upstream_version_map.py b/util/opentelemetry-util-genai/scripts/update_upstream_version_map.py index ef3d660f2..1eeb90917 100644 --- a/util/opentelemetry-util-genai/scripts/update_upstream_version_map.py +++ b/util/opentelemetry-util-genai/scripts/update_upstream_version_map.py @@ -61,7 +61,9 @@ def main() -> None: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--mapping-file", type=Path, required=True) parser.add_argument("--version-file", type=Path, required=True) - parser.add_argument("--component", type=str, default="loongsuite-util-genai") + parser.add_argument( + "--component", type=str, default="loongsuite-util-genai" + ) parser.add_argument( "--upstream-repository", type=str, From 63a3d40c9dfca5857c79a5b477f79796817ae768 Mon Sep 17 00:00:00 2001 From: cirilla-zmh Date: Tue, 24 Feb 2026 16:27:22 +0800 Subject: [PATCH 14/16] Fix unit tests and add documents for release Change-Id: I9fc6996b63f179bba6b78b8f0f0f404b29df2a3c Co-developed-by: Cursor --- .github/scripts/sync-upstream-with-rebase.sh | 6 +- .github/workflows/loongsuite_lint_0.yml | 19 + .github/workflows/loongsuite_misc_0.yml | 53 ++ .github/workflows/loongsuite_test_0.yml | 95 +++ .gitignore | 2 +- .../loongsuite_distro-0.1b0-py3-none-any.whl | Bin 17314 -> 0 bytes ...ongsuite_util_genai-0.1b0-py3-none-any.whl | Bin 46934 -> 0 bytes docs/loongsuite-release.md | 687 ++++++++++++------ .../src/loongsuite/distro/bootstrap_gen.py | 282 ++----- .../scripts/generate_version_mapping_table.py | 4 +- .../scripts/update_upstream_version_map.py | 4 +- 11 files changed, 706 insertions(+), 446 deletions(-) create mode 100644 .github/workflows/loongsuite_misc_0.yml delete mode 100644 dist-pypi/loongsuite_distro-0.1b0-py3-none-any.whl delete mode 100644 dist-pypi/loongsuite_util_genai-0.1b0-py3-none-any.whl diff --git a/.github/scripts/sync-upstream-with-rebase.sh b/.github/scripts/sync-upstream-with-rebase.sh index f41de4427..6d6aa979d 100755 --- a/.github/scripts/sync-upstream-with-rebase.sh +++ b/.github/scripts/sync-upstream-with-rebase.sh @@ -1,4 +1,4 @@ -#!/usr/bin/env bash + #!/usr/bin/env bash # # Sync upstream changes into a local branch via "git merge" (not rebase). # @@ -200,7 +200,7 @@ create_pr() { return fi require_command gh - local title body upstream_desc + local title upstream_desc upstream_desc="${UPSTREAM_REMOTE}/${UPSTREAM_BRANCH}" if [[ -n "$UPSTREAM_COMMIT" ]]; then upstream_desc="commit $(git rev-parse --short "$UPSTREAM_COMMIT") (from ${UPSTREAM_REMOTE}/${UPSTREAM_BRANCH})" @@ -208,7 +208,7 @@ create_pr() { title="chore: sync ${upstream_desc} into ${BASE_BRANCH}" local body_file body_file=$(mktemp) - trap "rm -f '$body_file'" EXIT + trap 'rm -f "$body_file"' EXIT cat <"$body_file" ## Summary - Merge upstream \`${upstream_desc}\` into \`${BASE_BRANCH}\` diff --git a/.github/workflows/loongsuite_lint_0.yml b/.github/workflows/loongsuite_lint_0.yml index ec19cde2a..0ef1edf39 100644 --- a/.github/workflows/loongsuite_lint_0.yml +++ b/.github/workflows/loongsuite_lint_0.yml @@ -127,3 +127,22 @@ jobs: - name: Run tests run: tox -c tox-loongsuite.ini -e lint-loongsuite-instrumentation-mem0 + lint-loongsuite-processor-baggage: + name: LoongSuite loongsuite-processor-baggage + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout repo @ SHA - ${{ github.sha }} + uses: actions/checkout@v4 + + - name: Set up Python 3.13 + uses: actions/setup-python@v5 + with: + python-version: "3.13" + + - name: Install tox + run: pip install tox-uv + + - name: Run tests + run: tox -c tox-loongsuite.ini -e lint-loongsuite-processor-baggage + diff --git a/.github/workflows/loongsuite_misc_0.yml b/.github/workflows/loongsuite_misc_0.yml new file mode 100644 index 000000000..e728e253e --- /dev/null +++ b/.github/workflows/loongsuite_misc_0.yml @@ -0,0 +1,53 @@ +# Do not edit this file. +# This file is generated automatically by executing tox -e generate-workflows + +name: LoongSuite Misc 0 + +on: + push: + branches-ignore: + - 'release/*' + - 'otelbot/*' + pull_request: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +env: + # Set the SHA to the branch name if the PR has a label 'prepare-release' or 'backport' otherwise, set it to 'main' + # For PRs you can change the inner fallback ('main') + # For pushes you change the outer fallback ('main') + # The logic below is used during releases and depends on having an equivalent branch name in the core repo. + CORE_REPO_SHA: ${{ github.event_name == 'pull_request' && ( + contains(github.event.pull_request.labels.*.name, 'prepare-release') && github.event.pull_request.head.ref || + contains(github.event.pull_request.labels.*.name, 'backport') && github.event.pull_request.base.ref || + 'main' + ) || 'main' }} + CONTRIB_REPO_SHA: main + PIP_EXISTS_ACTION: w + +jobs: + + generate-loongsuite: + name: LoongSuite generate-loongsuite + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout repo @ SHA - ${{ github.sha }} + uses: actions/checkout@v4 + + - name: Set up Python 3.11 + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install tox + run: pip install tox-uv + + - name: Run tests + run: tox -c tox-loongsuite.ini -e generate-loongsuite + diff --git a/.github/workflows/loongsuite_test_0.yml b/.github/workflows/loongsuite_test_0.yml index 2ef853c67..43ba02ee4 100644 --- a/.github/workflows/loongsuite_test_0.yml +++ b/.github/workflows/loongsuite_test_0.yml @@ -868,3 +868,98 @@ jobs: - name: Run tests run: tox -c tox-loongsuite.ini -e py313-test-loongsuite-instrumentation-mem0-latest -- -ra + py39-test-loongsuite-processor-baggage_ubuntu-latest: + name: LoongSuite loongsuite-processor-baggage 3.9 Ubuntu + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout repo @ SHA - ${{ github.sha }} + uses: actions/checkout@v4 + + - name: Set up Python 3.9 + uses: actions/setup-python@v5 + with: + python-version: "3.9" + + - name: Install tox + run: pip install tox-uv + + - name: Run tests + run: tox -c tox-loongsuite.ini -e py39-test-loongsuite-processor-baggage -- -ra + + py310-test-loongsuite-processor-baggage_ubuntu-latest: + name: LoongSuite loongsuite-processor-baggage 3.10 Ubuntu + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout repo @ SHA - ${{ github.sha }} + uses: actions/checkout@v4 + + - name: Set up Python 3.10 + uses: actions/setup-python@v5 + with: + python-version: "3.10" + + - name: Install tox + run: pip install tox-uv + + - name: Run tests + run: tox -c tox-loongsuite.ini -e py310-test-loongsuite-processor-baggage -- -ra + + py311-test-loongsuite-processor-baggage_ubuntu-latest: + name: LoongSuite loongsuite-processor-baggage 3.11 Ubuntu + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout repo @ SHA - ${{ github.sha }} + uses: actions/checkout@v4 + + - name: Set up Python 3.11 + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install tox + run: pip install tox-uv + + - name: Run tests + run: tox -c tox-loongsuite.ini -e py311-test-loongsuite-processor-baggage -- -ra + + py312-test-loongsuite-processor-baggage_ubuntu-latest: + name: LoongSuite loongsuite-processor-baggage 3.12 Ubuntu + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout repo @ SHA - ${{ github.sha }} + uses: actions/checkout@v4 + + - name: Set up Python 3.12 + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install tox + run: pip install tox-uv + + - name: Run tests + run: tox -c tox-loongsuite.ini -e py312-test-loongsuite-processor-baggage -- -ra + + py313-test-loongsuite-processor-baggage_ubuntu-latest: + name: LoongSuite loongsuite-processor-baggage 3.13 Ubuntu + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout repo @ SHA - ${{ github.sha }} + uses: actions/checkout@v4 + + - name: Set up Python 3.13 + uses: actions/setup-python@v5 + with: + python-version: "3.13" + + - name: Install tox + run: pip install tox-uv + + - name: Run tests + run: tox -c tox-loongsuite.ini -e py313-test-loongsuite-processor-baggage -- -ra + diff --git a/.gitignore b/.gitignore index 02c6c8c43..47f8ff508 100644 --- a/.gitignore +++ b/.gitignore @@ -67,6 +67,6 @@ pyrightconfig.json # LoongSuite Extension .cursor/ -dist-* +dist-*/ upload/ upload_*_test/ diff --git a/dist-pypi/loongsuite_distro-0.1b0-py3-none-any.whl b/dist-pypi/loongsuite_distro-0.1b0-py3-none-any.whl deleted file mode 100644 index 954cb08f0d076a55482678ca4b321916b7246bef..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 17314 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 KwHElt)&BuK?dmZA diff --git a/dist-pypi/loongsuite_util_genai-0.1b0-py3-none-any.whl b/dist-pypi/loongsuite_util_genai-0.1b0-py3-none-any.whl deleted file mode 100644 index 7cf3e95fcd13309a292599352d495066cce10c4d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 46934 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 EFOm=}DgXcg diff --git a/docs/loongsuite-release.md b/docs/loongsuite-release.md index edaa5df50..c1590c39e 100644 --- a/docs/loongsuite-release.md +++ b/docs/loongsuite-release.md @@ -1,352 +1,607 @@ -# LoongSuite 发布指南 +# LoongSuite Python Agent 发布完整指南 -本文档描述 LoongSuite Python Agent 的完整发布流程,包括本地验证(Dry Run)和正式发布两部分。 +本文档涵盖 LoongSuite Python Agent 的架构设计、发布流程、用户安装、开发调试及维护指南。 ## 目录 -- [发布策略概述](#发布策略概述) -- [版本号策略](#版本号策略) -- [Part 1: 本地验证 (Dry Run)](#part-1-本地验证-dry-run) -- [Part 2: 正式发布](#part-2-正式发布) -- [用户安装指南](#用户安装指南) -- [故障排查](#故障排查) +- [1. 项目概述](#1-项目概述) + - [1.1 项目背景](#11-项目背景) + - [1.2 模块介绍](#12-模块介绍) + - [1.3 发布渠道](#13-发布渠道) +- [2. 发布原理](#2-发布原理) + - [2.1 发布架构](#21-发布架构) + - [2.2 核心脚本](#22-核心脚本) + - [2.3 构建流程详解](#23-构建流程详解) +- [3. 终端用户指南](#3-终端用户指南) + - [3.1 快速开始](#31-快速开始) + - [3.2 安装选项](#32-安装选项) + - [3.3 使用探针](#33-使用探针) +- [4. 开发者指南](#4-开发者指南) + - [4.1 环境准备](#41-环境准备) + - [4.2 本地开发](#42-本地开发) + - [4.3 运行测试](#43-运行测试) +- [5. 维护者指南:发布新版本](#5-维护者指南发布新版本) + - [5.1 版本号策略](#51-版本号策略) + - [5.2 本地验证 (Dry Run)](#52-本地验证-dry-run) + - [5.3 正式发布](#53-正式发布) +- [6. 维护者指南:同步上游代码](#6-维护者指南同步上游代码) + - [6.1 同步原理](#61-同步原理) + - [6.2 同步步骤](#62-同步步骤) + - [6.3 冲突处理](#63-冲突处理) +- [7. 故障排查](#7-故障排查) +- [8. 相关文件索引](#8-相关文件索引) --- -## 发布策略概述 +## 1. 项目概述 -LoongSuite 采用**双轨发布策略**,将包分发到不同的目标: +### 1.1 项目背景 -| 模块 | 源目录 | 发布后包名 | 发布目标 | 说明 | -|------|--------|-----------|----------|------| -| util-genai | `util/opentelemetry-util-genai` | `loongsuite-util-genai` | **PyPI** | GenAI 工具库,更名发布 | -| distro | `loongsuite-distro` | `loongsuite-distro` | **PyPI** | 引导器,提供 bootstrap 命令 | -| GenAI instrumentations | `instrumentation-genai/*` | `loongsuite-instrumentation-*` | **GitHub Release** | 大模型 instrumentations,更名发布 | -| LoongSuite instrumentations | `instrumentation-loongsuite/*` | `loongsuite-instrumentation-*` | **GitHub Release** | LoongSuite 自有 instrumentations | -| 标准 instrumentations | `instrumentation/*` | `opentelemetry-instrumentation-*` | **PyPI (上游)** | 由上游 OpenTelemetry 发布 | +LoongSuite Python Agent 是基于 [OpenTelemetry Python Contrib](https://github.com/open-telemetry/opentelemetry-python-contrib) 的 Fork 项目,主要扩展了对大模型(GenAI)框架的可观测性支持。 -### 依赖关系 +**为什么需要 Fork?** + +- 上游 `opentelemetry-util-genai` 功能有限,我们需要扩展它 +- 我们新增的 `instrumentation-loongsuite/*` 依赖扩展后的 `util-genai` +- 为避免依赖冲突,我们将扩展后的包重命名为 `loongsuite-*` 前缀发布 + +### 1.2 模块介绍 + +| 模块类型 | 源目录 | 说明 | +|---------|--------|------| +| **util-genai** | `util/opentelemetry-util-genai` | GenAI 工具库,提供通用的 span 处理、token 计算等功能 | +| **distro** | `loongsuite-distro` | 发行版入口,提供 `loongsuite-bootstrap` 和 `loongsuite-instrument` 命令 | +| **GenAI instrumentations** | `instrumentation-genai/*` | 来自上游的 GenAI 插桩,如 OpenAI、VertexAI 等 | +| **LoongSuite instrumentations** | `instrumentation-loongsuite/*` | LoongSuite 自研插桩,如 DashScope、AgentScope、MCP 等 | +| **标准 instrumentations** | `instrumentation/*` | 标准微服务插桩(Flask、Django、Redis 等),由上游发布 | +| **processor** | `processor/loongsuite-processor-baggage` | Baggage 处理器 | + +### 1.3 发布渠道 + +LoongSuite 采用**双轨发布策略**: + +| 发布后包名 | 发布目标 | 来源 | 说明 | +|-----------|----------|------|------| +| `loongsuite-util-genai` | **PyPI** | `util/opentelemetry-util-genai` | 重命名后发布 | +| `loongsuite-distro` | **PyPI** | `loongsuite-distro` | 引导器 | +| `loongsuite-instrumentation-*` | **GitHub Release** | `instrumentation-genai/*` + `instrumentation-loongsuite/*` | 打包为 tar.gz | +| `opentelemetry-instrumentation-*` | **PyPI (上游)** | `instrumentation/*` | 由上游 OpenTelemetry 发布 | + +**依赖关系图:** ``` 用户环境 ├── loongsuite-distro (PyPI) -│ └── provides: loongsuite-bootstrap, loongsuite-instrument +│ ├── provides: loongsuite-bootstrap, loongsuite-instrument +│ └── depends: opentelemetry-api, opentelemetry-sdk ├── loongsuite-util-genai (PyPI) -│ └── GenAI 工具库,被 instrumentation 依赖 -├── loongsuite-instrumentation-* (GitHub Release tar.gz) -│ └── 依赖 loongsuite-util-genai +│ └── GenAI 通用工具库 +├── loongsuite-instrumentation-* (GitHub Release) +│ ├── loongsuite-instrumentation-dashscope +│ ├── loongsuite-instrumentation-vertexai (renamed from opentelemetry-*) +│ └── ... (依赖 loongsuite-util-genai) └── opentelemetry-instrumentation-* (PyPI 上游) - └── 标准微服务 instrumentations + ├── opentelemetry-instrumentation-flask + ├── opentelemetry-instrumentation-redis + └── ... ``` --- -## 版本号策略 +## 2. 发布原理 -发布时需要指定两个版本号: +### 2.1 发布架构 -| 参数 | 说明 | 示例 | -|------|------|------| -| `--loongsuite-version` | LoongSuite 包版本号 | `0.1.0`, `1.0.0` | -| `--upstream-version` | 上游 OpenTelemetry 包版本号 | `0.60b1`, `0.61b0` | +``` + ┌─────────────────────────────────────┐ + │ Release Trigger │ + │ (Manual dispatch / Git tag push) │ + └───────────────┬─────────────────────┘ + │ + ┌───────────────▼─────────────────────┐ + │ loongsuite-release.yml │ + │ (GitHub Actions Workflow) │ + └───────────────┬─────────────────────┘ + │ + ┌─────────────────────┼─────────────────────┐ + │ │ │ + ┌─────────▼───────────┐ ┌───────▼──────────┐ ┌────────▼─────────┐ + │ generate_loongsuite │ │ build_loongsuite │ │ build_loongsuite │ + │ _bootstrap.py │ │ _package.py │ │ _package.py │ + │ │ │ --build-pypi │ │ --build-github │ + └─────────┬───────────┘ └────────┬─────────┘ └────────┬─────────┘ + │ │ │ + ┌─────────▼────────────┐ ┌───────▼────────┐ ┌─────────▼───────┐ + │ bootstrap_gen.py │ │ dist-pypi/ │ │ dist/ │ + │ (version mapping) │ │ *.whl │ │ *.tar.gz │ + └──────────────────────┘ └───────┬────────┘ └────────┬────────┘ + │ │ + ┌────────▼────────┐ ┌────────▼────────┐ + │ PyPI │ │ GitHub Release │ + └─────────────────┘ └─────────────────┘ +``` + +### 2.2 核心脚本 + +| 脚本 | 作用 | +|------|------| +| `scripts/generate_loongsuite_bootstrap.py` | 生成 `bootstrap_gen.py`,定义包名映射和版本 | +| `scripts/build_loongsuite_package.py` | 构建 wheel 包,处理包名重命名和依赖替换 | +| `scripts/dry_run_loongsuite_release.sh` | 本地验证脚本,模拟完整发布流程 | +| `.github/workflows/loongsuite-release.yml` | GitHub Actions 发布工作流 | + +### 2.3 构建流程详解 + +#### Step 1: 生成 bootstrap_gen.py + +```bash +python scripts/generate_loongsuite_bootstrap.py \ + --upstream-version 0.60b1 \ + --loongsuite-version 0.1.0 +``` + +**处理逻辑:** +- 扫描 `instrumentation/`、`instrumentation-genai/`、`instrumentation-loongsuite/` 目录 +- 对 `instrumentation-genai/opentelemetry-*` 包进行重命名(→ `loongsuite-*`) +- 生成包名到版本的映射表 -### 版本号应用规则 +#### Step 2: 构建 PyPI 包 -- **loongsuite-version** 应用于: - - `loongsuite-util-genai` - - `loongsuite-distro` - - `loongsuite-instrumentation-*` (所有 GenAI/LoongSuite instrumentations) +```bash +python scripts/build_loongsuite_package.py --build-pypi \ + --version 0.1.0 --util-genai-version 0.1.0 +``` + +**处理逻辑:** +- 构建 `util/opentelemetry-util-genai` → 输出 `loongsuite_util_genai-*.whl` + - 使用 TOML 解析修改 `pyproject.toml` 中的 `name` 字段 +- 构建 `loongsuite-distro` → 输出 `loongsuite_distro-*.whl` -- **upstream-version** 应用于: - - `bootstrap_gen.py` 中的 `opentelemetry-instrumentation-*` 版本声明 - - 用户执行 `loongsuite-bootstrap -a install` 时从 PyPI 安装的上游包版本 +#### Step 3: 构建 GitHub Release 包 + +```bash +python scripts/build_loongsuite_package.py --build-github-release \ + --version 0.1.0 --util-genai-version 0.1.0 +``` + +**处理逻辑:** +- 遍历 `instrumentation-genai/` 目录: + - **规则 1**:`opentelemetry-*` 前缀的包重命名为 `loongsuite-*` + - **规则 2**:动态检测依赖,将 `opentelemetry-util-genai` 替换为 `loongsuite-util-genai` +- 遍历 `instrumentation-loongsuite/` 目录: + - 仅应用依赖替换规则 +- 遍历 `processor/loongsuite-processor-baggage/` +- 所有 `.whl` 打包为 `loongsuite-python-agent-{version}.tar.gz` + +**规则匹配(无需硬编码包名):** + +```python +# 重命名规则:instrumentation-genai/ 下的 opentelemetry-* 包 +def should_rename_package(package_dir: Path) -> bool: + return "instrumentation-genai" in str(package_dir) and \ + package_dir.name.startswith("opentelemetry-") + +# 依赖替换规则:检测 pyproject.toml 中是否包含 opentelemetry-util-genai +def depends_on_util_genai(pyproject_path: Path) -> bool: + content = pyproject_path.read_text() + return "opentelemetry-util-genai" in content +``` --- -## Part 1: 本地验证 (Dry Run) +## 3. 终端用户指南 -在正式发布前,使用 dry run 脚本在本地验证整个发布流程。 +### 3.1 快速开始 -### 前置条件 +```bash +# 1. 安装 loongsuite-distro (从 PyPI) +pip install loongsuite-distro + +# 2. 安装所有 instrumentations +loongsuite-bootstrap -a install --version 0.1.0 + +# 3. 运行你的应用(自动注入探针) +loongsuite-instrument python app.py +``` + +### 3.2 安装选项 + +#### 完整安装 ```bash -# 确保在项目根目录 -cd /path/to/loongsuite-python-agent +# 安装所有可用的 instrumentations +loongsuite-bootstrap -a install --version 0.1.0 +``` -# 确保 Python 环境可用 -python --version # >= 3.9 +#### 按需安装 (推荐) -# 脚本会自动从 pkg-requirements.txt 安装构建依赖 -# 或手动安装: -pip install -r pkg-requirements.txt +```bash +# 只安装当前环境中已安装库对应的 instrumentations +loongsuite-bootstrap -a install --version 0.1.0 --auto-detect ``` -### 基本用法 +#### 白名单安装 ```bash -# 完整验证(推荐首次使用) -./scripts/dry_run_loongsuite_release.sh \ - --loongsuite-version 0.1.0 \ - --upstream-version 0.60b1 +# 创建白名单 +cat > whitelist.txt << EOF +loongsuite-instrumentation-dashscope +loongsuite-instrumentation-langchain +opentelemetry-instrumentation-flask +opentelemetry-instrumentation-redis +EOF + +# 只安装白名单中的包 +loongsuite-bootstrap -a install --version 0.1.0 --whitelist whitelist.txt ``` -### 命令行参数 +### 3.3 使用探针 -| 参数 | 简写 | 说明 | -|------|------|------| -| `--loongsuite-version` | `-l` | LoongSuite 包版本(必填) | -| `--upstream-version` | `-u` | 上游 OTel 包版本(必填) | -| `--skip-install` | - | 跳过安装验证步骤 | -| `--skip-pypi` | - | 跳过 PyPI 包构建 | -| `--help` | `-h` | 显示帮助信息 | +#### 方式 1: 命令行自动注入 + +```bash +# 自动加载所有已安装的 instrumentations +loongsuite-instrument python app.py + +# 指定 exporter +OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317 \ +loongsuite-instrument python app.py +``` + +#### 方式 2: 代码中手动集成 + +```python +from opentelemetry import trace +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import BatchSpanProcessor +from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter + +# 配置 TracerProvider +provider = TracerProvider() +processor = BatchSpanProcessor(OTLPSpanExporter()) +provider.add_span_processor(processor) +trace.set_tracer_provider(provider) + +# 手动启用特定 instrumentation +from opentelemetry.instrumentation.dashscope import DashScopeInstrumentor +DashScopeInstrumentor().instrument() +``` -### 快速验证选项 +### 安装流程说明 + +`loongsuite-bootstrap -a install` 执行两阶段安装: + +``` +Phase 1: 从 GitHub Release tar.gz 安装 loongsuite-* 包 + └── pip install --find-links loongsuite-instrumentation-* + +Phase 2: 从 PyPI 安装 opentelemetry-* 包 + └── pip install opentelemetry-instrumentation-flask==0.60b1 ... +``` + +--- + +## 4. 开发者指南 + +### 4.1 环境准备 + +开发环境**不需要**进行包名重命名,因为所有代码都在本地,可以直接使用 `opentelemetry-util-genai` 等原始包名。 ```bash -# 跳过安装验证(加快速度) -./scripts/dry_run_loongsuite_release.sh \ - -l 0.1.0 -u 0.60b1 --skip-install +# 克隆仓库 +git clone https://github.com/alibaba/loongsuite-python-agent.git +cd loongsuite-python-agent -# 只验证 GitHub Release 包 -./scripts/dry_run_loongsuite_release.sh \ - -l 0.1.0 -u 0.60b1 --skip-pypi --skip-install +# 创建虚拟环境 +python -m venv .venv +source .venv/bin/activate + +# 安装开发依赖 +pip install -r dev-requirements.txt ``` -### Dry Run 执行步骤 +### 4.2 本地开发 -脚本会依次执行以下步骤: +#### 安装核心包(editable 模式) -| 步骤 | 说明 | 验证内容 | -|------|------|----------| -| 1 | 安装构建依赖 | 从 `pkg-requirements.txt` 安装 | -| 2 | 生成 bootstrap_gen.py | 版本号替换、包名映射 | -| 3 | 构建 PyPI 包 | `loongsuite-util-genai`, `loongsuite-distro` | -| 4 | 构建 GitHub Release 包 | tar.gz 包含正确的 instrumentations | -| 5 | 验证 tar 内容 | 包名、依赖正确性 | -| 6 | 生成 release notes | 从 CHANGELOG 提取 | -| 7 | 安装验证 | 在临时 venv 中测试两阶段安装 | +```bash +# 安装 opentelemetry 核心包(从上游) +pip install opentelemetry-api opentelemetry-sdk opentelemetry-semantic-conventions -### 产物说明 +# 安装本地 util-genai(使用原始包名,无需重命名) +pip install -e ./util/opentelemetry-util-genai -成功执行后,产物分布在两个目录: +# 安装你要开发的 instrumentation +pip install -e ./instrumentation-loongsuite/loongsuite-instrumentation-dashscope +# 安装 loongsuite-distro(用于测试 bootstrap) +pip install -e ./loongsuite-distro ``` -dist-pypi/ # PyPI 包目录 -├── loongsuite_util_genai-0.1.0-py3-none-any.whl -└── loongsuite_distro-0.1.0-py3-none-any.whl -dist/ # GitHub Release 包目录 -├── loongsuite-python-agent-0.1.0.tar.gz -└── release-notes-dryrun.txt +#### 开发新的 instrumentation + +```bash +# 复制模板 +cp -r instrumentation-loongsuite/_template \ + instrumentation-loongsuite/loongsuite-instrumentation-mylib + +# 修改 pyproject.toml 中的包名、依赖等 +# 实现 __init__.py 中的 Instrumentor 类 ``` -### 验证要点 +### 4.3 运行测试 -Dry run 会自动验证以下内容: +#### 使用 tox(推荐) -- ✅ `loongsuite-util-genai` 不在 tar.gz 中(应在 PyPI) -- ✅ `opentelemetry-util-genai` 不在 tar.gz 中(避免冲突) -- ✅ `loongsuite-instrumentation-*` 在 tar.gz 中 -- ✅ `opentelemetry-instrumentation-flask` 等不在 tar.gz 中(从 PyPI 安装) -- ✅ 安装后 `loongsuite-util-genai` 可用 -- ✅ 安装后 `opentelemetry-util-genai` 未被安装(无冲突) +```bash +# 激活 conda 环境(如果使用 conda) +conda activate loongsuite + +# 运行特定模块的测试 +tox -c tox-loongsuite.ini -e py312-test-loongsuite-instrumentation-dashscope-latest + +# 运行 lint +tox -c tox-loongsuite.ini -e lint-loongsuite-instrumentation-dashscope +``` + +#### 直接运行 pytest + +```bash +# 安装测试依赖 +pip install pytest pytest-cov + +# 安装被测模块 +pip install -e ./instrumentation-loongsuite/loongsuite-instrumentation-dashscope +pip install -r ./instrumentation-loongsuite/loongsuite-instrumentation-dashscope/tests/requirements.latest.txt + +# 运行测试 +pytest instrumentation-loongsuite/loongsuite-instrumentation-dashscope/tests/ +``` + +#### 测试环境配置参考 + +参考 `tox-loongsuite.ini` 中的配置,每个模块可以有两套测试依赖: + +- `requirements.oldest.txt`: 最低支持版本的依赖 +- `requirements.latest.txt`: 最新版本的依赖 --- -## Part 2: 正式发布 +## 5. 维护者指南:发布新版本 -正式发布通过 GitHub Actions workflow 执行。 +### 5.1 版本号策略 -### 发布方式 +发布时需要指定两个版本号: -#### 方式 1: 手动触发 (推荐) +| 参数 | 说明 | 示例 | +|------|------|------| +| `--loongsuite-version` | LoongSuite 包版本 | `0.1.0`, `0.2.0b0` | +| `--upstream-version` | 上游 OpenTelemetry 包版本 | `0.60b1`, `0.61b0` | -1. 进入 GitHub 仓库的 **Actions** 页面 -2. 选择 **LoongSuite Release** workflow -3. 点击 **Run workflow** -4. 填写参数: - - `loongsuite_version`: 如 `0.1.0` - - `upstream_version`: 如 `0.60b1` - - `release_notes`: 可选,留空则从 CHANGELOG 提取 - - `skip_pypi`: 是否跳过 PyPI 发布(测试用) -5. 点击 **Run workflow** 执行 +**应用规则:** +- `loongsuite-version` → `loongsuite-util-genai`, `loongsuite-distro`, `loongsuite-instrumentation-*` +- `upstream-version` → `bootstrap_gen.py` 中的 `opentelemetry-instrumentation-*` 版本 -#### 方式 2: Tag 触发 +### 5.2 本地验证 (Dry Run) ```bash -# 创建并推送 tag -git tag v0.1.0 -git push origin v0.1.0 +# 完整验证 +./scripts/dry_run_loongsuite_release.sh \ + --loongsuite-version 0.1.0 \ + --upstream-version 0.60b1 + +# 快速验证(跳过安装测试) +./scripts/dry_run_loongsuite_release.sh \ + -l 0.1.0 -u 0.60b1 --skip-install ``` -> ⚠️ Tag 触发时,`upstream_version` 使用默认值或环境变量,建议使用手动触发以确保版本号正确。 +**验证步骤:** -### Workflow 执行流程 +| 步骤 | 说明 | 产物 | +|------|------|------| +| 1 | 安装构建依赖 | - | +| 2 | 生成 bootstrap_gen.py | `loongsuite-distro/src/.../bootstrap_gen.py` | +| 3 | 构建 PyPI 包 | `dist-pypi/*.whl` | +| 4 | 构建 GitHub Release 包 | `dist/*.tar.gz` | +| 5 | 验证 tar 内容 | - | +| 6 | 生成 release notes | `dist/release-notes-dryrun.txt` | +| 7 | 安装验证 | 临时 venv 中测试 | -``` -┌─────────────────────────────────────────────────────────────┐ -│ loongsuite-release.yml │ -├─────────────────────────────────────────────────────────────┤ -│ │ -│ ┌─────────────────────────────────────────────────────┐ │ -│ │ Job: build │ │ -│ │ 1. Checkout 代码 │ │ -│ │ 2. 设置版本号 │ │ -│ │ 3. 生成 bootstrap_gen.py │ │ -│ │ 4. 构建 PyPI 包 │ │ -│ │ 5. 构建 GitHub Release 包 │ │ -│ │ 6. 上传 artifacts │ │ -│ └─────────────────────────────────────────────────────┘ │ -│ │ │ -│ ┌────────────┴────────────┐ │ -│ ▼ ▼ │ -│ ┌─────────────────────┐ ┌─────────────────────┐ │ -│ │ Job: publish-pypi │ │ Job: github-release │ │ -│ │ - 下载 PyPI 包 │ │ - 下载 tar.gz │ │ -│ │ - twine upload │ │ - 生成 release notes│ │ -│ │ - 发布到 PyPI │ │ - 创建 GitHub Release│ │ -│ └─────────────────────┘ └─────────────────────┘ │ -│ │ -└─────────────────────────────────────────────────────────────┘ +**验证要点:** + +- ✅ `loongsuite-util-genai` 在 `dist-pypi/` 中(发布到 PyPI) +- ✅ `loongsuite-util-genai` 不在 `tar.gz` 中 +- ✅ `loongsuite-instrumentation-*` 在 `tar.gz` 中 +- ✅ `opentelemetry-util-genai` 不在任何产物中(避免冲突) +- ✅ 安装后依赖关系正确 + +### 5.3 正式发布 + +#### 方式 1: 手动触发 (推荐) + +1. 进入 GitHub 仓库 → **Actions** → **LoongSuite Release** +2. 点击 **Run workflow** +3. 填写参数: + - `loongsuite_version`: `0.1.0` + - `upstream_version`: `0.60b1` + - `release_notes`: 可选 + - `skip_pypi`: 测试时可勾选 +4. 执行 + +#### 方式 2: Tag 触发 + +```bash +git tag v0.1.0 +git push origin v0.1.0 ``` -### PyPI 发布配置 +#### PyPI 发布配置 -Workflow 使用 OIDC trusted publishing 发布到 PyPI。需要在 PyPI 项目设置中配置: +使用 OIDC Trusted Publishing(推荐)或 API Token: -1. 进入 PyPI 项目设置 → Publishing +**OIDC 配置:** +1. PyPI 项目设置 → Publishing 2. 添加 trusted publisher: - Owner: `alibaba` - Repository: `loongsuite-python-agent` - Workflow: `loongsuite-release.yml` - Environment: `pypi` -或者配置 `PYPI_API_TOKEN` secret 使用 API token 发布。 - -### 发布检查清单 - -发布前确认: +#### 发布检查清单 - [ ] 本地 dry run 通过 -- [ ] CHANGELOG-loongsuite.md 已更新 -- [ ] 版本号格式正确(如 `0.1.0`,不带 `v` 前缀) -- [ ] upstream_version 与当前上游稳定版本匹配 -- [ ] PyPI trusted publishing 或 API token 已配置 +- [ ] `CHANGELOG-loongsuite.md` 已更新 +- [ ] 版本号格式正确(不带 `v` 前缀) +- [ ] `upstream_version` 与当前上游稳定版本匹配 +- [ ] PyPI 权限已配置 --- -## 用户安装指南 +## 6. 维护者指南:同步上游代码 -发布完成后,用户可通过以下方式安装: +### 6.1 同步原理 -### 基本安装 +本项目 Fork 自 [opentelemetry-python-contrib](https://github.com/open-telemetry/opentelemetry-python-contrib),需要定期同步上游的更新。 -```bash -# 1. 安装 loongsuite-distro (从 PyPI) -pip install loongsuite-distro==0.1.0 +**同步策略:** -# 2. 安装所有 instrumentations -loongsuite-bootstrap -a install --version 0.1.0 +``` +upstream/main ──────────────────────────────────────► 上游主分支 + │ + │ git fetch upstream + │ git merge upstream/main + ▼ +origin/main ───────────────────────────────────────► 我们的主分支 + │ + │ feature branches + ▼ +origin/feature/* ──────────────────────────────────► 功能分支 ``` -### 按需安装 (Auto-detect) +**需要注意的目录:** -```bash -# 只安装当前环境中检测到的库对应的 instrumentations -loongsuite-bootstrap -a install --version 0.1.0 --auto-detect -``` +| 目录 | 同步策略 | +|------|----------| +| `instrumentation/` | 完全同步上游 | +| `instrumentation-genai/` | 完全同步上游 | +| `util/opentelemetry-util-genai/` | 同步上游,**保留我们的扩展** | +| `instrumentation-loongsuite/` | **我们独有**,不受上游影响 | +| `loongsuite-distro/` | **我们独有**,不受上游影响 | +| `scripts/generate_loongsuite_*.py` | **我们独有** | +| `scripts/build_loongsuite_*.py` | **我们独有** | -### 使用白名单 +### 6.2 同步步骤 ```bash -# 创建白名单文件 -cat > whitelist.txt << EOF -loongsuite-instrumentation-dashscope -opentelemetry-instrumentation-flask -opentelemetry-instrumentation-redis -EOF +# 1. 添加上游远程(如果未添加) +git remote add upstream https://github.com/open-telemetry/opentelemetry-python-contrib.git -# 只安装白名单中的 instrumentations -loongsuite-bootstrap -a install --version 0.1.0 --whitelist whitelist.txt -``` +# 2. 获取上游更新 +git fetch upstream -### 安装流程说明 +# 3. 切换到主分支 +git checkout main -`loongsuite-bootstrap` 执行两阶段安装: +# 4. 合并上游更新 +git merge upstream/main -``` -Phase 1: 从 GitHub Release tar.gz 安装 - - loongsuite-instrumentation-dashscope - - loongsuite-instrumentation-langchain - - loongsuite-instrumentation-google-genai - - ... +# 5. 解决冲突(如有) +# ... -Phase 2: 从 PyPI 安装 - - opentelemetry-instrumentation-flask==0.60b1 - - opentelemetry-instrumentation-redis==0.60b1 - - opentelemetry-instrumentation-django==0.60b1 - - ... +# 6. 推送到我们的仓库 +git push origin main ``` ---- +### 6.3 冲突处理 -## 故障排查 +**常见冲突场景:** -### Dry Run 常见问题 +1. **`util/opentelemetry-util-genai/` 冲突** + - 我们对这个模块有扩展 + - 需要手动合并,保留我们的扩展代码 -**问题**: `hatch version` 失败 +2. **`scripts/` 目录冲突** + - 上游的 `scripts/generate_instrumentation_bootstrap.py` 等可能更新 + - 我们的 `scripts/generate_loongsuite_*.py` 依赖它们 + - 需要检查 API 兼容性 -``` -解决: 确保安装了 hatch -pip install hatch -``` +3. **`pyproject.toml` 冲突** + - 上游可能更新依赖版本 + - 需要验证兼容性 -**问题**: 构建失败,找不到依赖 +**冲突解决后验证:** -``` -解决: 安装构建依赖 -pip install build tomli +```bash +# 运行测试确保兼容性 +tox -c tox-loongsuite.ini -e py312-test-loongsuite-instrumentation-dashscope-latest + +# 运行 dry run 确保发布流程正常 +./scripts/dry_run_loongsuite_release.sh -l 0.1.0 -u 0.60b1 --skip-install ``` -**问题**: 安装验证失败 +--- -``` -解决: 检查 loongsuite-distro 是否正确安装 -pip install -e ./loongsuite-distro -``` +## 7. 故障排查 -### 发布常见问题 +### 构建问题 -**问题**: PyPI 发布失败 (403 Forbidden) +**问题**: `hatch version` 失败 +```bash +# 解决: 安装 hatch +pip install hatch +``` +**问题**: 构建时找不到依赖 +```bash +# 解决: 安装构建依赖 +pip install -r pkg-requirements.txt ``` -解决: -1. 检查 OIDC trusted publishing 配置 -2. 或配置 PYPI_API_TOKEN secret + +**问题**: tomlkit 相关错误 +```bash +# 解决: 安装 tomlkit +pip install tomlkit ``` -**问题**: GitHub Release 创建失败 +### 安装问题 +**问题**: `loongsuite-util-genai` 找不到 +```bash +# 原因: PyPI 包未正确构建 +# 解决: 检查 dry run step 3 的输出,确认包名为 loongsuite_util_genai ``` -解决: 确保 workflow 有 contents: write 权限 + +**问题**: `opentelemetry-util-genai` 和 `loongsuite-util-genai` 冲突 +```bash +# 解决: 卸载旧包 +pip uninstall opentelemetry-util-genai +pip install loongsuite-util-genai ``` -**问题**: 版本号冲突 +### 发布问题 +**问题**: PyPI 发布 403 Forbidden +```bash +# 解决: 检查 OIDC trusted publishing 配置或 API token ``` -解决: PyPI 不允许覆盖已发布版本,需要使用新版本号 + +**问题**: 版本号已存在 +```bash +# 解决: PyPI 不允许覆盖版本,使用新版本号 ``` --- -## 相关文件 +## 8. 相关文件索引 | 文件 | 说明 | |------|------| -| `scripts/build_loongsuite_package.py` | 构建脚本 | +| `scripts/build_loongsuite_package.py` | 构建脚本,处理包名重命名和依赖替换 | | `scripts/generate_loongsuite_bootstrap.py` | 生成 bootstrap_gen.py | | `scripts/dry_run_loongsuite_release.sh` | 本地验证脚本 | -| `.github/workflows/loongsuite-release.yml` | GitHub Actions workflow | +| `.github/workflows/loongsuite-release.yml` | GitHub Actions 发布工作流 | | `loongsuite-distro/src/loongsuite/distro/bootstrap.py` | Bootstrap 安装逻辑 | -| `loongsuite-distro/src/loongsuite/distro/bootstrap_gen.py` | 生成的包映射配置 | +| `loongsuite-distro/src/loongsuite/distro/bootstrap_gen.py` | 生成的包名映射配置 | +| `tox-loongsuite.ini` | 测试配置 | +| `pkg-requirements.txt` | 构建依赖 | | `CHANGELOG-loongsuite.md` | 变更日志 | diff --git a/loongsuite-distro/src/loongsuite/distro/bootstrap_gen.py b/loongsuite-distro/src/loongsuite/distro/bootstrap_gen.py index 9facb7ea3..f0d0fbcc9 100644 --- a/loongsuite-distro/src/loongsuite/distro/bootstrap_gen.py +++ b/loongsuite-distro/src/loongsuite/distro/bootstrap_gen.py @@ -1,3 +1,4 @@ + # Copyright The OpenTelemetry Authors # # Licensed under the Apache License, Version 2.0 (the "License"); @@ -17,229 +18,66 @@ # # Generated with options: # --upstream-version: 0.60b1 -# --loongsuite-version: 0.1b0 +# --loongsuite-version: 0.1.0.dev0 libraries = [ - { - "library": "openai >= 1.26.0", - "instrumentation": "loongsuite-instrumentation-openai-v2==0.1b0", - }, - { - "library": "google-cloud-aiplatform >= 1.64", - "instrumentation": "loongsuite-instrumentation-vertexai==0.1b0", - }, - { - "library": "aio_pika >= 7.2.0, < 10.0.0", - "instrumentation": "opentelemetry-instrumentation-aio-pika==0.60b1", - }, - { - "library": "aiohttp ~= 3.0", - "instrumentation": "opentelemetry-instrumentation-aiohttp-client==0.60b1", - }, - { - "library": "aiohttp ~= 3.0", - "instrumentation": "opentelemetry-instrumentation-aiohttp-server==0.60b1", - }, - { - "library": "aiokafka >= 0.8, < 1.0", - "instrumentation": "opentelemetry-instrumentation-aiokafka==0.60b1", - }, - { - "library": "aiopg >= 0.13.0, < 2.0.0", - "instrumentation": "opentelemetry-instrumentation-aiopg==0.60b1", - }, - { - "library": "asgiref ~= 3.0", - "instrumentation": "opentelemetry-instrumentation-asgi==0.60b1", - }, - { - "library": "asyncclick ~= 8.0", - "instrumentation": "opentelemetry-instrumentation-asyncclick==0.60b1", - }, - { - "library": "asyncpg >= 0.12.0", - "instrumentation": "opentelemetry-instrumentation-asyncpg==0.60b1", - }, - { - "library": "boto~=2.0", - "instrumentation": "opentelemetry-instrumentation-boto==0.60b1", - }, - { - "library": "boto3 ~= 1.0", - "instrumentation": "opentelemetry-instrumentation-boto3sqs==0.60b1", - }, - { - "library": "botocore ~= 1.0", - "instrumentation": "opentelemetry-instrumentation-botocore==0.60b1", - }, - { - "library": "cassandra-driver ~= 3.25", - "instrumentation": "opentelemetry-instrumentation-cassandra==0.60b1", - }, - { - "library": "scylla-driver ~= 3.25", - "instrumentation": "opentelemetry-instrumentation-cassandra==0.60b1", - }, - { - "library": "celery >= 4.0, < 6.0", - "instrumentation": "opentelemetry-instrumentation-celery==0.60b1", - }, - { - "library": "click >= 8.1.3, < 9.0.0", - "instrumentation": "opentelemetry-instrumentation-click==0.60b1", - }, - { - "library": "confluent-kafka >= 1.8.2, <= 2.11.0", - "instrumentation": "opentelemetry-instrumentation-confluent-kafka==0.60b1", - }, - { - "library": "django >= 1.10", - "instrumentation": "opentelemetry-instrumentation-django==0.60b1", - }, - { - "library": "elasticsearch >= 6.0", - "instrumentation": "opentelemetry-instrumentation-elasticsearch==0.60b1", - }, - { - "library": "falcon >= 1.4.1, < 5.0.0", - "instrumentation": "opentelemetry-instrumentation-falcon==0.60b1", - }, - { - "library": "fastapi ~= 0.92", - "instrumentation": "opentelemetry-instrumentation-fastapi==0.60b1", - }, - { - "library": "flask >= 1.0", - "instrumentation": "opentelemetry-instrumentation-flask==0.60b1", - }, - { - "library": "grpcio >= 1.42.0", - "instrumentation": "opentelemetry-instrumentation-grpc==0.60b1", - }, - { - "library": "httpx >= 0.18.0", - "instrumentation": "opentelemetry-instrumentation-httpx==0.60b1", - }, - { - "library": "jinja2 >= 2.7, < 4.0", - "instrumentation": "opentelemetry-instrumentation-jinja2==0.60b1", - }, - { - "library": "kafka-python >= 2.0, < 3.0", - "instrumentation": "opentelemetry-instrumentation-kafka-python==0.60b1", - }, - { - "library": "kafka-python-ng >= 2.0, < 3.0", - "instrumentation": "opentelemetry-instrumentation-kafka-python==0.60b1", - }, - { - "library": "mysql-connector-python >= 8.0, < 10.0", - "instrumentation": "opentelemetry-instrumentation-mysql==0.60b1", - }, - { - "library": "mysqlclient < 3", - "instrumentation": "opentelemetry-instrumentation-mysqlclient==0.60b1", - }, - { - "library": "pika >= 0.12.0", - "instrumentation": "opentelemetry-instrumentation-pika==0.60b1", - }, - { - "library": "psycopg >= 3.1.0", - "instrumentation": "opentelemetry-instrumentation-psycopg==0.60b1", - }, - { - "library": "psycopg2 >= 2.7.3.1", - "instrumentation": "opentelemetry-instrumentation-psycopg2==0.60b1", - }, - { - "library": "psycopg2-binary >= 2.7.3.1", - "instrumentation": "opentelemetry-instrumentation-psycopg2==0.60b1", - }, - { - "library": "pymemcache >= 1.3.5, < 5", - "instrumentation": "opentelemetry-instrumentation-pymemcache==0.60b1", - }, - { - "library": "pymongo >= 3.1, < 5.0", - "instrumentation": "opentelemetry-instrumentation-pymongo==0.60b1", - }, - { - "library": "pymssql >= 2.1.5, < 3", - "instrumentation": "opentelemetry-instrumentation-pymssql==0.60b1", - }, - { - "library": "PyMySQL < 2", - "instrumentation": "opentelemetry-instrumentation-pymysql==0.60b1", - }, - { - "library": "pyramid >= 1.7", - "instrumentation": "opentelemetry-instrumentation-pyramid==0.60b1", - }, - { - "library": "redis >= 2.6", - "instrumentation": "opentelemetry-instrumentation-redis==0.60b1", - }, - { - "library": "remoulade >= 0.50", - "instrumentation": "opentelemetry-instrumentation-remoulade==0.60b1", - }, - { - "library": "requests ~= 2.0", - "instrumentation": "opentelemetry-instrumentation-requests==0.60b1", - }, - { - "library": "sqlalchemy >= 1.0.0, < 2.1.0", - "instrumentation": "opentelemetry-instrumentation-sqlalchemy==0.60b1", - }, - { - "library": "starlette >= 0.13", - "instrumentation": "opentelemetry-instrumentation-starlette==0.60b1", - }, - { - "library": "psutil >= 5", - "instrumentation": "opentelemetry-instrumentation-system-metrics==0.60b1", - }, - { - "library": "tornado >= 5.1.1", - "instrumentation": "opentelemetry-instrumentation-tornado==0.60b1", - }, - { - "library": "tortoise-orm >= 0.17.0", - "instrumentation": "opentelemetry-instrumentation-tortoiseorm==0.60b1", - }, - { - "library": "pydantic >= 1.10.2", - "instrumentation": "opentelemetry-instrumentation-tortoiseorm==0.60b1", - }, - { - "library": "urllib3 >= 1.0.0, < 3.0.0", - "instrumentation": "opentelemetry-instrumentation-urllib3==0.60b1", - }, - { - "library": "agentscope >= 1.0.0", - "instrumentation": "loongsuite-instrumentation-agentscope==0.1b0", - }, - { - "library": "agno", - "instrumentation": "loongsuite-instrumentation-agno==0.1b0", - }, - { - "library": "dashscope >= 1.0.0", - "instrumentation": "loongsuite-instrumentation-dashscope==0.1b0", - }, - { - "library": "langchain_core >= 0.1.0", - "instrumentation": "loongsuite-instrumentation-langchain==0.1b0", - }, - { - "library": "mcp >= 1.3.0, <= 1.25.0", - "instrumentation": "loongsuite-instrumentation-mcp==0.1b0", - }, - { - "library": "mem0ai >= 1.0.0", - "instrumentation": "loongsuite-instrumentation-mem0==0.1b0", - }, + {"library": "openai >= 1.26.0", "instrumentation": "loongsuite-instrumentation-openai-v2==0.1.0.dev0"}, + {"library": "google-cloud-aiplatform >= 1.64", "instrumentation": "loongsuite-instrumentation-vertexai==0.1.0.dev0"}, + {"library": "aio_pika >= 7.2.0, < 10.0.0", "instrumentation": "opentelemetry-instrumentation-aio-pika==0.60b1"}, + {"library": "aiohttp ~= 3.0", "instrumentation": "opentelemetry-instrumentation-aiohttp-client==0.60b1"}, + {"library": "aiohttp ~= 3.0", "instrumentation": "opentelemetry-instrumentation-aiohttp-server==0.60b1"}, + {"library": "aiokafka >= 0.8, < 1.0", "instrumentation": "opentelemetry-instrumentation-aiokafka==0.60b1"}, + {"library": "aiopg >= 0.13.0, < 2.0.0", "instrumentation": "opentelemetry-instrumentation-aiopg==0.60b1"}, + {"library": "asgiref ~= 3.0", "instrumentation": "opentelemetry-instrumentation-asgi==0.60b1"}, + {"library": "asyncclick ~= 8.0", "instrumentation": "opentelemetry-instrumentation-asyncclick==0.60b1"}, + {"library": "asyncpg >= 0.12.0", "instrumentation": "opentelemetry-instrumentation-asyncpg==0.60b1"}, + {"library": "boto~=2.0", "instrumentation": "opentelemetry-instrumentation-boto==0.60b1"}, + {"library": "boto3 ~= 1.0", "instrumentation": "opentelemetry-instrumentation-boto3sqs==0.60b1"}, + {"library": "botocore ~= 1.0", "instrumentation": "opentelemetry-instrumentation-botocore==0.60b1"}, + {"library": "cassandra-driver ~= 3.25", "instrumentation": "opentelemetry-instrumentation-cassandra==0.60b1"}, + {"library": "scylla-driver ~= 3.25", "instrumentation": "opentelemetry-instrumentation-cassandra==0.60b1"}, + {"library": "celery >= 4.0, < 6.0", "instrumentation": "opentelemetry-instrumentation-celery==0.60b1"}, + {"library": "click >= 8.1.3, < 9.0.0", "instrumentation": "opentelemetry-instrumentation-click==0.60b1"}, + {"library": "confluent-kafka >= 1.8.2, <= 2.11.0", "instrumentation": "opentelemetry-instrumentation-confluent-kafka==0.60b1"}, + {"library": "django >= 1.10", "instrumentation": "opentelemetry-instrumentation-django==0.60b1"}, + {"library": "elasticsearch >= 6.0", "instrumentation": "opentelemetry-instrumentation-elasticsearch==0.60b1"}, + {"library": "falcon >= 1.4.1, < 5.0.0", "instrumentation": "opentelemetry-instrumentation-falcon==0.60b1"}, + {"library": "fastapi ~= 0.92", "instrumentation": "opentelemetry-instrumentation-fastapi==0.60b1"}, + {"library": "flask >= 1.0", "instrumentation": "opentelemetry-instrumentation-flask==0.60b1"}, + {"library": "grpcio >= 1.42.0", "instrumentation": "opentelemetry-instrumentation-grpc==0.60b1"}, + {"library": "httpx >= 0.18.0", "instrumentation": "opentelemetry-instrumentation-httpx==0.60b1"}, + {"library": "jinja2 >= 2.7, < 4.0", "instrumentation": "opentelemetry-instrumentation-jinja2==0.60b1"}, + {"library": "kafka-python >= 2.0, < 3.0", "instrumentation": "opentelemetry-instrumentation-kafka-python==0.60b1"}, + {"library": "kafka-python-ng >= 2.0, < 3.0", "instrumentation": "opentelemetry-instrumentation-kafka-python==0.60b1"}, + {"library": "mysql-connector-python >= 8.0, < 10.0", "instrumentation": "opentelemetry-instrumentation-mysql==0.60b1"}, + {"library": "mysqlclient < 3", "instrumentation": "opentelemetry-instrumentation-mysqlclient==0.60b1"}, + {"library": "pika >= 0.12.0", "instrumentation": "opentelemetry-instrumentation-pika==0.60b1"}, + {"library": "psycopg >= 3.1.0", "instrumentation": "opentelemetry-instrumentation-psycopg==0.60b1"}, + {"library": "psycopg2 >= 2.7.3.1", "instrumentation": "opentelemetry-instrumentation-psycopg2==0.60b1"}, + {"library": "psycopg2-binary >= 2.7.3.1", "instrumentation": "opentelemetry-instrumentation-psycopg2==0.60b1"}, + {"library": "pymemcache >= 1.3.5, < 5", "instrumentation": "opentelemetry-instrumentation-pymemcache==0.60b1"}, + {"library": "pymongo >= 3.1, < 5.0", "instrumentation": "opentelemetry-instrumentation-pymongo==0.60b1"}, + {"library": "pymssql >= 2.1.5, < 3", "instrumentation": "opentelemetry-instrumentation-pymssql==0.60b1"}, + {"library": "PyMySQL < 2", "instrumentation": "opentelemetry-instrumentation-pymysql==0.60b1"}, + {"library": "pyramid >= 1.7", "instrumentation": "opentelemetry-instrumentation-pyramid==0.60b1"}, + {"library": "redis >= 2.6", "instrumentation": "opentelemetry-instrumentation-redis==0.60b1"}, + {"library": "remoulade >= 0.50", "instrumentation": "opentelemetry-instrumentation-remoulade==0.60b1"}, + {"library": "requests ~= 2.0", "instrumentation": "opentelemetry-instrumentation-requests==0.60b1"}, + {"library": "sqlalchemy >= 1.0.0, < 2.1.0", "instrumentation": "opentelemetry-instrumentation-sqlalchemy==0.60b1"}, + {"library": "starlette >= 0.13", "instrumentation": "opentelemetry-instrumentation-starlette==0.60b1"}, + {"library": "psutil >= 5", "instrumentation": "opentelemetry-instrumentation-system-metrics==0.60b1"}, + {"library": "tornado >= 5.1.1", "instrumentation": "opentelemetry-instrumentation-tornado==0.60b1"}, + {"library": "tortoise-orm >= 0.17.0", "instrumentation": "opentelemetry-instrumentation-tortoiseorm==0.60b1"}, + {"library": "pydantic >= 1.10.2", "instrumentation": "opentelemetry-instrumentation-tortoiseorm==0.60b1"}, + {"library": "urllib3 >= 1.0.0, < 3.0.0", "instrumentation": "opentelemetry-instrumentation-urllib3==0.60b1"}, + {"library": "agentscope >= 1.0.0", "instrumentation": "loongsuite-instrumentation-agentscope==0.1.0.dev0"}, + {"library": "agno", "instrumentation": "loongsuite-instrumentation-agno==0.1.0.dev0"}, + {"library": "claude-agent-sdk >= 0.1.0", "instrumentation": "loongsuite-instrumentation-claude-agent-sdk==0.1.0.dev0"}, + {"library": "dashscope >= 1.0.0", "instrumentation": "loongsuite-instrumentation-dashscope==0.1.0.dev0"}, + {"library": "google-adk >= 0.1.0", "instrumentation": "loongsuite-instrumentation-google-adk==0.1.0.dev0"}, + {"library": "langchain_core >= 0.1.0", "instrumentation": "loongsuite-instrumentation-langchain==0.1.0.dev0"}, + {"library": "mcp >= 1.3.0, <= 1.25.0", "instrumentation": "loongsuite-instrumentation-mcp==0.1.0.dev0"}, + {"library": "mem0ai >= 1.0.0", "instrumentation": "loongsuite-instrumentation-mem0==0.1.0.dev0"}, ] default_instrumentations = [ @@ -250,5 +88,5 @@ "opentelemetry-instrumentation-threading==0.60b1", "opentelemetry-instrumentation-urllib==0.60b1", "opentelemetry-instrumentation-wsgi==0.60b1", - "loongsuite-instrumentation-dify==0.1b0", + "loongsuite-instrumentation-dify==0.1.0.dev0", ] diff --git a/util/opentelemetry-util-genai/scripts/generate_version_mapping_table.py b/util/opentelemetry-util-genai/scripts/generate_version_mapping_table.py index 27c421abd..1912a5626 100644 --- a/util/opentelemetry-util-genai/scripts/generate_version_mapping_table.py +++ b/util/opentelemetry-util-genai/scripts/generate_version_mapping_table.py @@ -14,8 +14,8 @@ def _load_mapping(mapping_path: Path) -> dict[str, Any]: - with mapping_path.open("r", encoding="utf-8") as f: - return json.load(f) + with mapping_path.open("r", encoding="utf-8") as mapping_str: + return json.load(mapping_str) def _format_cell(value: Any) -> str: diff --git a/util/opentelemetry-util-genai/scripts/update_upstream_version_map.py b/util/opentelemetry-util-genai/scripts/update_upstream_version_map.py index 1eeb90917..072519ce1 100644 --- a/util/opentelemetry-util-genai/scripts/update_upstream_version_map.py +++ b/util/opentelemetry-util-genai/scripts/update_upstream_version_map.py @@ -24,8 +24,8 @@ def _read_local_version(version_file: Path) -> str: def _load_mapping(mapping_file: Path) -> dict[str, Any]: if not mapping_file.exists(): return {"schema_version": 1, "mappings": []} - with mapping_file.open("r", encoding="utf-8") as f: - return json.load(f) + with mapping_file.open("r", encoding="utf-8") as mapping_str: + return json.load(mapping_str) def _upsert_mapping( From 573a4b31d58a6c26079b2de69df77591b571b0ee Mon Sep 17 00:00:00 2001 From: cirilla-zmh Date: Tue, 24 Feb 2026 17:25:39 +0800 Subject: [PATCH 15/16] Generate readme and bootstrap Change-Id: Id9fc9f7b9a55ec0ec21c0b5374d131b62812a2db Co-developed-by: Cursor --- instrumentation-loongsuite/README.md | 7 +- .../src/loongsuite/distro/bootstrap_gen.py | 306 ++++++++++++++---- scripts/generate_loongsuite_bootstrap.py | 0 scripts/generate_loongsuite_readme.py | 0 4 files changed, 242 insertions(+), 71 deletions(-) mode change 100644 => 100755 scripts/generate_loongsuite_bootstrap.py mode change 100644 => 100755 scripts/generate_loongsuite_readme.py diff --git a/instrumentation-loongsuite/README.md b/instrumentation-loongsuite/README.md index 7e47239c3..f05e47fe8 100644 --- a/instrumentation-loongsuite/README.md +++ b/instrumentation-loongsuite/README.md @@ -3,9 +3,10 @@ | --------------- | ------------------ | --------------- | -------------- | | [loongsuite-instrumentation-agentscope](./loongsuite-instrumentation-agentscope) | agentscope >= 1.0.0 | No | development | [loongsuite-instrumentation-agno](./loongsuite-instrumentation-agno) | agno | No | development +| [loongsuite-instrumentation-claude-agent-sdk](./loongsuite-instrumentation-claude-agent-sdk) | claude-agent-sdk >= 0.1.0 | No | development | [loongsuite-instrumentation-dashscope](./loongsuite-instrumentation-dashscope) | dashscope >= 1.0.0 | No | development | [loongsuite-instrumentation-dify](./loongsuite-instrumentation-dify) | dify | No | development -| [loongsuite-instrumentation-google-adk](./loongsuite-instrumentation-google-adk) | google-adk >= 0.1.0 | Yes | experimental -| [loongsuite-instrumentation-langchain](./loongsuite-instrumentation-langchain) | langchain_core >= 0.1.0 | Yes | development -| [loongsuite-instrumentation-mcp](./loongsuite-instrumentation-mcp) | mcp>=1.3.0 | Yes | development +| [loongsuite-instrumentation-google-adk](./loongsuite-instrumentation-google-adk) | google-adk >= 0.1.0 | No | development +| [loongsuite-instrumentation-langchain](./loongsuite-instrumentation-langchain) | langchain_core >= 0.1.0 | No | development +| [loongsuite-instrumentation-mcp](./loongsuite-instrumentation-mcp) | mcp >= 1.3.0, <= 1.25.0 | No | development | [loongsuite-instrumentation-mem0](./loongsuite-instrumentation-mem0) | mem0ai >= 1.0.0 | No | development \ No newline at end of file diff --git a/loongsuite-distro/src/loongsuite/distro/bootstrap_gen.py b/loongsuite-distro/src/loongsuite/distro/bootstrap_gen.py index f0d0fbcc9..3a50509ab 100644 --- a/loongsuite-distro/src/loongsuite/distro/bootstrap_gen.py +++ b/loongsuite-distro/src/loongsuite/distro/bootstrap_gen.py @@ -1,4 +1,3 @@ - # Copyright The OpenTelemetry Authors # # Licensed under the Apache License, Version 2.0 (the "License"); @@ -17,76 +16,247 @@ # RUN `python scripts/generate_loongsuite_bootstrap.py` TO REGENERATE. # # Generated with options: -# --upstream-version: 0.60b1 -# --loongsuite-version: 0.1.0.dev0 +# --upstream-version: (from source) +# --loongsuite-version: (from source) libraries = [ - {"library": "openai >= 1.26.0", "instrumentation": "loongsuite-instrumentation-openai-v2==0.1.0.dev0"}, - {"library": "google-cloud-aiplatform >= 1.64", "instrumentation": "loongsuite-instrumentation-vertexai==0.1.0.dev0"}, - {"library": "aio_pika >= 7.2.0, < 10.0.0", "instrumentation": "opentelemetry-instrumentation-aio-pika==0.60b1"}, - {"library": "aiohttp ~= 3.0", "instrumentation": "opentelemetry-instrumentation-aiohttp-client==0.60b1"}, - {"library": "aiohttp ~= 3.0", "instrumentation": "opentelemetry-instrumentation-aiohttp-server==0.60b1"}, - {"library": "aiokafka >= 0.8, < 1.0", "instrumentation": "opentelemetry-instrumentation-aiokafka==0.60b1"}, - {"library": "aiopg >= 0.13.0, < 2.0.0", "instrumentation": "opentelemetry-instrumentation-aiopg==0.60b1"}, - {"library": "asgiref ~= 3.0", "instrumentation": "opentelemetry-instrumentation-asgi==0.60b1"}, - {"library": "asyncclick ~= 8.0", "instrumentation": "opentelemetry-instrumentation-asyncclick==0.60b1"}, - {"library": "asyncpg >= 0.12.0", "instrumentation": "opentelemetry-instrumentation-asyncpg==0.60b1"}, - {"library": "boto~=2.0", "instrumentation": "opentelemetry-instrumentation-boto==0.60b1"}, - {"library": "boto3 ~= 1.0", "instrumentation": "opentelemetry-instrumentation-boto3sqs==0.60b1"}, - {"library": "botocore ~= 1.0", "instrumentation": "opentelemetry-instrumentation-botocore==0.60b1"}, - {"library": "cassandra-driver ~= 3.25", "instrumentation": "opentelemetry-instrumentation-cassandra==0.60b1"}, - {"library": "scylla-driver ~= 3.25", "instrumentation": "opentelemetry-instrumentation-cassandra==0.60b1"}, - {"library": "celery >= 4.0, < 6.0", "instrumentation": "opentelemetry-instrumentation-celery==0.60b1"}, - {"library": "click >= 8.1.3, < 9.0.0", "instrumentation": "opentelemetry-instrumentation-click==0.60b1"}, - {"library": "confluent-kafka >= 1.8.2, <= 2.11.0", "instrumentation": "opentelemetry-instrumentation-confluent-kafka==0.60b1"}, - {"library": "django >= 1.10", "instrumentation": "opentelemetry-instrumentation-django==0.60b1"}, - {"library": "elasticsearch >= 6.0", "instrumentation": "opentelemetry-instrumentation-elasticsearch==0.60b1"}, - {"library": "falcon >= 1.4.1, < 5.0.0", "instrumentation": "opentelemetry-instrumentation-falcon==0.60b1"}, - {"library": "fastapi ~= 0.92", "instrumentation": "opentelemetry-instrumentation-fastapi==0.60b1"}, - {"library": "flask >= 1.0", "instrumentation": "opentelemetry-instrumentation-flask==0.60b1"}, - {"library": "grpcio >= 1.42.0", "instrumentation": "opentelemetry-instrumentation-grpc==0.60b1"}, - {"library": "httpx >= 0.18.0", "instrumentation": "opentelemetry-instrumentation-httpx==0.60b1"}, - {"library": "jinja2 >= 2.7, < 4.0", "instrumentation": "opentelemetry-instrumentation-jinja2==0.60b1"}, - {"library": "kafka-python >= 2.0, < 3.0", "instrumentation": "opentelemetry-instrumentation-kafka-python==0.60b1"}, - {"library": "kafka-python-ng >= 2.0, < 3.0", "instrumentation": "opentelemetry-instrumentation-kafka-python==0.60b1"}, - {"library": "mysql-connector-python >= 8.0, < 10.0", "instrumentation": "opentelemetry-instrumentation-mysql==0.60b1"}, - {"library": "mysqlclient < 3", "instrumentation": "opentelemetry-instrumentation-mysqlclient==0.60b1"}, - {"library": "pika >= 0.12.0", "instrumentation": "opentelemetry-instrumentation-pika==0.60b1"}, - {"library": "psycopg >= 3.1.0", "instrumentation": "opentelemetry-instrumentation-psycopg==0.60b1"}, - {"library": "psycopg2 >= 2.7.3.1", "instrumentation": "opentelemetry-instrumentation-psycopg2==0.60b1"}, - {"library": "psycopg2-binary >= 2.7.3.1", "instrumentation": "opentelemetry-instrumentation-psycopg2==0.60b1"}, - {"library": "pymemcache >= 1.3.5, < 5", "instrumentation": "opentelemetry-instrumentation-pymemcache==0.60b1"}, - {"library": "pymongo >= 3.1, < 5.0", "instrumentation": "opentelemetry-instrumentation-pymongo==0.60b1"}, - {"library": "pymssql >= 2.1.5, < 3", "instrumentation": "opentelemetry-instrumentation-pymssql==0.60b1"}, - {"library": "PyMySQL < 2", "instrumentation": "opentelemetry-instrumentation-pymysql==0.60b1"}, - {"library": "pyramid >= 1.7", "instrumentation": "opentelemetry-instrumentation-pyramid==0.60b1"}, - {"library": "redis >= 2.6", "instrumentation": "opentelemetry-instrumentation-redis==0.60b1"}, - {"library": "remoulade >= 0.50", "instrumentation": "opentelemetry-instrumentation-remoulade==0.60b1"}, - {"library": "requests ~= 2.0", "instrumentation": "opentelemetry-instrumentation-requests==0.60b1"}, - {"library": "sqlalchemy >= 1.0.0, < 2.1.0", "instrumentation": "opentelemetry-instrumentation-sqlalchemy==0.60b1"}, - {"library": "starlette >= 0.13", "instrumentation": "opentelemetry-instrumentation-starlette==0.60b1"}, - {"library": "psutil >= 5", "instrumentation": "opentelemetry-instrumentation-system-metrics==0.60b1"}, - {"library": "tornado >= 5.1.1", "instrumentation": "opentelemetry-instrumentation-tornado==0.60b1"}, - {"library": "tortoise-orm >= 0.17.0", "instrumentation": "opentelemetry-instrumentation-tortoiseorm==0.60b1"}, - {"library": "pydantic >= 1.10.2", "instrumentation": "opentelemetry-instrumentation-tortoiseorm==0.60b1"}, - {"library": "urllib3 >= 1.0.0, < 3.0.0", "instrumentation": "opentelemetry-instrumentation-urllib3==0.60b1"}, - {"library": "agentscope >= 1.0.0", "instrumentation": "loongsuite-instrumentation-agentscope==0.1.0.dev0"}, - {"library": "agno", "instrumentation": "loongsuite-instrumentation-agno==0.1.0.dev0"}, - {"library": "claude-agent-sdk >= 0.1.0", "instrumentation": "loongsuite-instrumentation-claude-agent-sdk==0.1.0.dev0"}, - {"library": "dashscope >= 1.0.0", "instrumentation": "loongsuite-instrumentation-dashscope==0.1.0.dev0"}, - {"library": "google-adk >= 0.1.0", "instrumentation": "loongsuite-instrumentation-google-adk==0.1.0.dev0"}, - {"library": "langchain_core >= 0.1.0", "instrumentation": "loongsuite-instrumentation-langchain==0.1.0.dev0"}, - {"library": "mcp >= 1.3.0, <= 1.25.0", "instrumentation": "loongsuite-instrumentation-mcp==0.1.0.dev0"}, - {"library": "mem0ai >= 1.0.0", "instrumentation": "loongsuite-instrumentation-mem0==0.1.0.dev0"}, + { + "library": "openai >= 1.26.0", + "instrumentation": "loongsuite-instrumentation-openai-v2", + }, + { + "library": "google-cloud-aiplatform >= 1.64", + "instrumentation": "loongsuite-instrumentation-vertexai>=2.0b0", + }, + { + "library": "aio_pika >= 7.2.0, < 10.0.0", + "instrumentation": "opentelemetry-instrumentation-aio-pika==0.61b0.dev", + }, + { + "library": "aiohttp ~= 3.0", + "instrumentation": "opentelemetry-instrumentation-aiohttp-client==0.61b0.dev", + }, + { + "library": "aiohttp ~= 3.0", + "instrumentation": "opentelemetry-instrumentation-aiohttp-server==0.61b0.dev", + }, + { + "library": "aiokafka >= 0.8, < 1.0", + "instrumentation": "opentelemetry-instrumentation-aiokafka==0.61b0.dev", + }, + { + "library": "aiopg >= 0.13.0, < 2.0.0", + "instrumentation": "opentelemetry-instrumentation-aiopg==0.61b0.dev", + }, + { + "library": "asgiref ~= 3.0", + "instrumentation": "opentelemetry-instrumentation-asgi==0.61b0.dev", + }, + { + "library": "asyncclick ~= 8.0", + "instrumentation": "opentelemetry-instrumentation-asyncclick==0.61b0.dev", + }, + { + "library": "asyncpg >= 0.12.0", + "instrumentation": "opentelemetry-instrumentation-asyncpg==0.61b0.dev", + }, + { + "library": "boto~=2.0", + "instrumentation": "opentelemetry-instrumentation-boto==0.61b0.dev", + }, + { + "library": "boto3 ~= 1.0", + "instrumentation": "opentelemetry-instrumentation-boto3sqs==0.61b0.dev", + }, + { + "library": "botocore ~= 1.0", + "instrumentation": "opentelemetry-instrumentation-botocore==0.61b0.dev", + }, + { + "library": "cassandra-driver ~= 3.25", + "instrumentation": "opentelemetry-instrumentation-cassandra==0.61b0.dev", + }, + { + "library": "scylla-driver ~= 3.25", + "instrumentation": "opentelemetry-instrumentation-cassandra==0.61b0.dev", + }, + { + "library": "celery >= 4.0, < 6.0", + "instrumentation": "opentelemetry-instrumentation-celery==0.61b0.dev", + }, + { + "library": "click >= 8.1.3, < 9.0.0", + "instrumentation": "opentelemetry-instrumentation-click==0.61b0.dev", + }, + { + "library": "confluent-kafka >= 1.8.2, <= 2.11.0", + "instrumentation": "opentelemetry-instrumentation-confluent-kafka==0.61b0.dev", + }, + { + "library": "django >= 1.10", + "instrumentation": "opentelemetry-instrumentation-django==0.61b0.dev", + }, + { + "library": "elasticsearch >= 6.0", + "instrumentation": "opentelemetry-instrumentation-elasticsearch==0.61b0.dev", + }, + { + "library": "falcon >= 1.4.1, < 5.0.0", + "instrumentation": "opentelemetry-instrumentation-falcon==0.61b0.dev", + }, + { + "library": "fastapi ~= 0.92", + "instrumentation": "opentelemetry-instrumentation-fastapi==0.61b0.dev", + }, + { + "library": "flask >= 1.0", + "instrumentation": "opentelemetry-instrumentation-flask==0.61b0.dev", + }, + { + "library": "grpcio >= 1.42.0", + "instrumentation": "opentelemetry-instrumentation-grpc==0.61b0.dev", + }, + { + "library": "httpx >= 0.18.0", + "instrumentation": "opentelemetry-instrumentation-httpx==0.61b0.dev", + }, + { + "library": "jinja2 >= 2.7, < 4.0", + "instrumentation": "opentelemetry-instrumentation-jinja2==0.61b0.dev", + }, + { + "library": "kafka-python >= 2.0, < 3.0", + "instrumentation": "opentelemetry-instrumentation-kafka-python==0.61b0.dev", + }, + { + "library": "kafka-python-ng >= 2.0, < 3.0", + "instrumentation": "opentelemetry-instrumentation-kafka-python==0.61b0.dev", + }, + { + "library": "mysql-connector-python >= 8.0, < 10.0", + "instrumentation": "opentelemetry-instrumentation-mysql==0.61b0.dev", + }, + { + "library": "mysqlclient < 3", + "instrumentation": "opentelemetry-instrumentation-mysqlclient==0.61b0.dev", + }, + { + "library": "pika >= 0.12.0", + "instrumentation": "opentelemetry-instrumentation-pika==0.61b0.dev", + }, + { + "library": "psycopg >= 3.1.0", + "instrumentation": "opentelemetry-instrumentation-psycopg==0.61b0.dev", + }, + { + "library": "psycopg2 >= 2.7.3.1", + "instrumentation": "opentelemetry-instrumentation-psycopg2==0.61b0.dev", + }, + { + "library": "psycopg2-binary >= 2.7.3.1", + "instrumentation": "opentelemetry-instrumentation-psycopg2==0.61b0.dev", + }, + { + "library": "pymemcache >= 1.3.5, < 5", + "instrumentation": "opentelemetry-instrumentation-pymemcache==0.61b0.dev", + }, + { + "library": "pymongo >= 3.1, < 5.0", + "instrumentation": "opentelemetry-instrumentation-pymongo==0.61b0.dev", + }, + { + "library": "pymssql >= 2.1.5, < 3", + "instrumentation": "opentelemetry-instrumentation-pymssql==0.61b0.dev", + }, + { + "library": "PyMySQL < 2", + "instrumentation": "opentelemetry-instrumentation-pymysql==0.61b0.dev", + }, + { + "library": "pyramid >= 1.7", + "instrumentation": "opentelemetry-instrumentation-pyramid==0.61b0.dev", + }, + { + "library": "redis >= 2.6", + "instrumentation": "opentelemetry-instrumentation-redis==0.61b0.dev", + }, + { + "library": "remoulade >= 0.50", + "instrumentation": "opentelemetry-instrumentation-remoulade==0.61b0.dev", + }, + { + "library": "requests ~= 2.0", + "instrumentation": "opentelemetry-instrumentation-requests==0.61b0.dev", + }, + { + "library": "sqlalchemy >= 1.0.0, < 2.1.0", + "instrumentation": "opentelemetry-instrumentation-sqlalchemy==0.61b0.dev", + }, + { + "library": "starlette >= 0.13", + "instrumentation": "opentelemetry-instrumentation-starlette==0.61b0.dev", + }, + { + "library": "psutil >= 5", + "instrumentation": "opentelemetry-instrumentation-system-metrics==0.61b0.dev", + }, + { + "library": "tornado >= 5.1.1", + "instrumentation": "opentelemetry-instrumentation-tornado==0.61b0.dev", + }, + { + "library": "tortoise-orm >= 0.17.0", + "instrumentation": "opentelemetry-instrumentation-tortoiseorm==0.61b0.dev", + }, + { + "library": "pydantic >= 1.10.2", + "instrumentation": "opentelemetry-instrumentation-tortoiseorm==0.61b0.dev", + }, + { + "library": "urllib3 >= 1.0.0, < 3.0.0", + "instrumentation": "opentelemetry-instrumentation-urllib3==0.61b0.dev", + }, + { + "library": "agentscope >= 1.0.0", + "instrumentation": "loongsuite-instrumentation-agentscope==1.0.0", + }, + { + "library": "agno", + "instrumentation": "loongsuite-instrumentation-agno==0.1b0.dev", + }, + { + "library": "claude-agent-sdk >= 0.1.0", + "instrumentation": "loongsuite-instrumentation-claude-agent-sdk==0.1.0.dev0", + }, + { + "library": "dashscope >= 1.0.0", + "instrumentation": "loongsuite-instrumentation-dashscope==0.1.0.dev0", + }, + { + "library": "google-adk >= 0.1.0", + "instrumentation": "loongsuite-instrumentation-google-adk==0.1.0", + }, + { + "library": "langchain_core >= 0.1.0", + "instrumentation": "loongsuite-instrumentation-langchain==1.0.0", + }, + { + "library": "mcp >= 1.3.0, <= 1.25.0", + "instrumentation": "loongsuite-instrumentation-mcp==0.1.0", + }, + { + "library": "mem0ai >= 1.0.0", + "instrumentation": "loongsuite-instrumentation-mem0==0.1.0", + }, ] default_instrumentations = [ - "opentelemetry-instrumentation-asyncio==0.60b1", - "opentelemetry-instrumentation-dbapi==0.60b1", - "opentelemetry-instrumentation-logging==0.60b1", - "opentelemetry-instrumentation-sqlite3==0.60b1", - "opentelemetry-instrumentation-threading==0.60b1", - "opentelemetry-instrumentation-urllib==0.60b1", - "opentelemetry-instrumentation-wsgi==0.60b1", - "loongsuite-instrumentation-dify==0.1.0.dev0", + "opentelemetry-instrumentation-asyncio==0.61b0.dev", + "opentelemetry-instrumentation-dbapi==0.61b0.dev", + "opentelemetry-instrumentation-logging==0.61b0.dev", + "opentelemetry-instrumentation-sqlite3==0.61b0.dev", + "opentelemetry-instrumentation-threading==0.61b0.dev", + "opentelemetry-instrumentation-urllib==0.61b0.dev", + "opentelemetry-instrumentation-wsgi==0.61b0.dev", + "loongsuite-instrumentation-dify==1.1.0", ] diff --git a/scripts/generate_loongsuite_bootstrap.py b/scripts/generate_loongsuite_bootstrap.py old mode 100644 new mode 100755 diff --git a/scripts/generate_loongsuite_readme.py b/scripts/generate_loongsuite_readme.py old mode 100644 new mode 100755 From 66b766e4ae74a6960325e8a33674c1d0d548cd42 Mon Sep 17 00:00:00 2001 From: cirilla-zmh Date: Tue, 24 Feb 2026 17:35:19 +0800 Subject: [PATCH 16/16] Separate release types Change-Id: Id2dc361d6ed22f6761dd2581e176075c7db86a90 Co-developed-by: Cursor --- .github/workflows/loongsuite-release.yml | 48 ++++++++++++++++++++---- docs/loongsuite-release.md | 31 ++++++++++----- 2 files changed, 62 insertions(+), 17 deletions(-) diff --git a/.github/workflows/loongsuite-release.yml b/.github/workflows/loongsuite-release.yml index 6aca82130..a7c7b2b27 100644 --- a/.github/workflows/loongsuite-release.yml +++ b/.github/workflows/loongsuite-release.yml @@ -2,15 +2,20 @@ # # This workflow handles the complete LoongSuite release process: # -# 1. PyPI packages (loongsuite-util-genai, loongsuite-distro) +# 1. PyPI packages (loongsuite-util-genai, loongsuite-distro) - .whl only, NOT loongsuite-python-agent.tar.gz # 2. GitHub Release (instrumentation-genai/*, instrumentation-loongsuite/* as tar.gz) # # Trigger: # - workflow_dispatch: Manual trigger with version inputs # - push tags: Automatic trigger on v* tags # -# Required secrets: -# - PYPI_API_TOKEN: PyPI API token for publishing (optional, can skip PyPI publish) +# PyPI / Test PyPI configuration: +# - Production PyPI: Set PYPI_API_TOKEN secret, or configure OIDC trusted publishing on pypi.org +# - Test PyPI: Set TEST_PYPI_TOKEN secret (pypi-xxx from https://test.pypi.org/manage/account/token/) +# - Use publish_target: testpypi to publish to Test PyPI instead of production +# +# IMPORTANT: Only loongsuite_util_genai-*.whl and loongsuite_distro-*.whl are uploaded to PyPI. +# loongsuite-python-agent-*.tar.gz is for GitHub Release only and must NOT be uploaded to PyPI. # name: LoongSuite Release @@ -32,6 +37,13 @@ on: description: 'Skip PyPI publish (for testing)' type: boolean default: false + publish_target: + description: 'Publish target: pypi or testpypi' + type: choice + options: + - pypi + - testpypi + default: pypi push: tags: - 'v*' @@ -134,16 +146,18 @@ jobs: name: github-release path: dist/loongsuite-python-agent-*.tar.gz - # Publish to PyPI + # Publish to production PyPI publish-pypi: needs: build runs-on: ubuntu-latest - if: ${{ !inputs.skip_pypi }} + if: | + (github.event_name != 'workflow_dispatch' || !inputs.skip_pypi) && + (github.event_name != 'workflow_dispatch' || github.event.inputs.publish_target != 'testpypi') environment: name: pypi url: https://pypi.org/project/loongsuite-distro/ permissions: - id-token: write # OIDC publishing + id-token: write # For OIDC; or use PYPI_API_TOKEN secret steps: - name: Download PyPI artifacts uses: actions/download-artifact@v4 @@ -154,8 +168,26 @@ jobs: - name: Publish to PyPI uses: pypa/gh-action-pypi-publish@release/v1 with: - # Uses OIDC trusted publishing (no API token needed if configured) - # Or set PYPI_API_TOKEN secret + skip-existing: true + + # Publish to Test PyPI (for testing before production) + publish-testpypi: + needs: build + runs-on: ubuntu-latest + if: ${{ github.event_name == 'workflow_dispatch' && !inputs.skip_pypi && inputs.publish_target == 'testpypi' }} + steps: + - name: Download PyPI artifacts + uses: actions/download-artifact@v4 + with: + name: pypi-packages + path: dist/ + + - name: Publish to Test PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + repository-url: https://test.pypi.org/legacy/ + username: __token__ + password: ${{ secrets.TEST_PYPI_TOKEN }} skip-existing: true # Create GitHub Release diff --git a/docs/loongsuite-release.md b/docs/loongsuite-release.md index c1590c39e..57a0e8775 100644 --- a/docs/loongsuite-release.md +++ b/docs/loongsuite-release.md @@ -435,17 +435,30 @@ git tag v0.1.0 git push origin v0.1.0 ``` -#### PyPI 发布配置 +#### PyPI / Test PyPI 发布配置 -使用 OIDC Trusted Publishing(推荐)或 API Token: +**发布到生产 PyPI(二选一):** -**OIDC 配置:** -1. PyPI 项目设置 → Publishing -2. 添加 trusted publisher: - - Owner: `alibaba` - - Repository: `loongsuite-python-agent` - - Workflow: `loongsuite-release.yml` - - Environment: `pypi` +1. **API Token**:在 GitHub 仓库 Settings → Secrets → Actions 中添加: + - `PYPI_API_TOKEN`:从 [pypi.org/manage/account/token/](https://pypi.org/manage/account/token/) 创建 + +2. **OIDC Trusted Publishing**(推荐): + - PyPI 项目设置 → Publishing → Add a new pending publisher + - Owner: `alibaba`,Repository: `loongsuite-python-agent` + - Workflow: `loongsuite-release.yml`,Environment: `pypi` + - 在 GitHub 仓库中创建 Environment `pypi`(Settings → Environments) + +**发布到 Test PyPI(测试用):** + +1. 在 [test.pypi.org/manage/account/token/](https://test.pypi.org/manage/account/token/) 创建 API Token +2. 在 GitHub Secrets 中添加:`TEST_PYPI_TOKEN`(值为 `pypi-xxx`) +3. 手动触发 workflow 时,将 `publish_target` 选为 **testpypi** + +**重要说明:** + +- 只有 `loongsuite_util_genai-*.whl` 和 `loongsuite_distro-*.whl` 会上传到 PyPI +- `loongsuite-python-agent-*.tar.gz` 仅用于 GitHub Release,**禁止**上传到 PyPI +- 若手动使用 `twine upload dist/*`,请先 `rm dist/loongsuite-python-agent-*.tar.gz`,否则会报错 `InvalidDistribution: Too many top-level members in sdist archive` #### 发布检查清单