|
| 1 | +#!/usr/bin/env python3 |
| 2 | +""" |
| 3 | +Generates instrumentation-manifest.json by parsing instrumentation source files |
| 4 | +using the Python AST (no code execution). |
| 5 | +
|
| 6 | +This approach mirrors the Node SDK's generateManifest.ts script. |
| 7 | +
|
| 8 | +## How it works (high level) |
| 9 | +
|
| 10 | +- Scans `drift/instrumentation/<library>/instrumentation.py` (excluding internal dirs). |
| 11 | +- Parses each file with Python's AST and finds `super().__init__(...)` calls. |
| 12 | +- Extracts `module_name` and `supported_versions` from the call. |
| 13 | +- Outputs a JSON manifest matching the Node SDK format. |
| 14 | +
|
| 15 | +## Maintainer notes (adding new instrumentations) |
| 16 | +
|
| 17 | +- Keep `module_name` and `supported_versions` as string literals in the __init__ call. |
| 18 | +- Ensure the library folder has an `instrumentation.py`; that's what this script scans. |
| 19 | +- If an instrumentation is internal (e.g., `socket`, `datetime`), add it to INTERNAL_INSTRUMENTATIONS. |
| 20 | +
|
| 21 | +Run: python scripts/generate_manifest.py [--dry-run] [--output PATH] |
| 22 | +""" |
| 23 | + |
| 24 | +from __future__ import annotations |
| 25 | + |
| 26 | +import argparse |
| 27 | +import ast |
| 28 | +import json |
| 29 | +import sys |
| 30 | +from datetime import UTC, datetime |
| 31 | +from pathlib import Path |
| 32 | + |
| 33 | +# Script location |
| 34 | +SCRIPT_DIR = Path(__file__).parent |
| 35 | +PROJECT_ROOT = SCRIPT_DIR.parent |
| 36 | +INSTRUMENTATION_DIR = PROJECT_ROOT / "drift" / "instrumentation" |
| 37 | + |
| 38 | +# Internal instrumentations that shouldn't be in the public manifest. |
| 39 | +# These patch Python built-ins or provide internal SDK functionality. |
| 40 | +INTERNAL_INSTRUMENTATIONS = { |
| 41 | + "socket", # Internal TCP-level patching |
| 42 | + "datetime", # Internal time mocking |
| 43 | + "wsgi", # Base class for framework instrumentations |
| 44 | + "http", # Transform engine, not a standalone instrumentation |
| 45 | + "e2e_common", # Test infrastructure |
| 46 | +} |
| 47 | + |
| 48 | + |
| 49 | +def get_sdk_version() -> str: |
| 50 | + """Read version from pyproject.toml.""" |
| 51 | + pyproject_path = PROJECT_ROOT / "pyproject.toml" |
| 52 | + |
| 53 | + with open(pyproject_path) as f: |
| 54 | + for line in f: |
| 55 | + if line.startswith("version = "): |
| 56 | + # Extract version from: version = "0.1.6" |
| 57 | + return line.split('"')[1] |
| 58 | + |
| 59 | + raise RuntimeError("Could not find version in pyproject.toml") |
| 60 | + |
| 61 | + |
| 62 | +def discover_instrumentation_files() -> list[Path]: |
| 63 | + """Discover all instrumentation.py files in the instrumentation directory.""" |
| 64 | + files: list[Path] = [] |
| 65 | + |
| 66 | + for entry in INSTRUMENTATION_DIR.iterdir(): |
| 67 | + if entry.is_dir() and entry.name not in INTERNAL_INSTRUMENTATIONS: |
| 68 | + instrumentation_file = entry / "instrumentation.py" |
| 69 | + if instrumentation_file.exists(): |
| 70 | + files.append(instrumentation_file) |
| 71 | + |
| 72 | + return sorted(files) |
| 73 | + |
| 74 | + |
| 75 | +def extract_string_from_node(node: ast.expr) -> str | None: |
| 76 | + """Extract a string value from an AST node (handles Constant nodes).""" |
| 77 | + if isinstance(node, ast.Constant) and isinstance(node.value, str): |
| 78 | + return node.value |
| 79 | + return None |
| 80 | + |
| 81 | + |
| 82 | +def parse_instrumentation_file(file_path: Path) -> dict[str, str] | None: |
| 83 | + """ |
| 84 | + Parse a Python file and extract module_name and supported_versions |
| 85 | + from the InstrumentationBase.__init__() call. |
| 86 | +
|
| 87 | + Returns dict with 'module_name' and 'supported_versions', or None if not found. |
| 88 | + """ |
| 89 | + with open(file_path) as f: |
| 90 | + source = f.read() |
| 91 | + |
| 92 | + try: |
| 93 | + tree = ast.parse(source, filename=str(file_path)) |
| 94 | + except SyntaxError as e: |
| 95 | + print(f" Warning: Syntax error in {file_path}: {e}", file=sys.stderr) |
| 96 | + return None |
| 97 | + |
| 98 | + # Find super().__init__(...) calls |
| 99 | + for node in ast.walk(tree): |
| 100 | + if not isinstance(node, ast.Call): |
| 101 | + continue |
| 102 | + |
| 103 | + # Check if it's super().__init__(...) |
| 104 | + if not isinstance(node.func, ast.Attribute): |
| 105 | + continue |
| 106 | + if node.func.attr != "__init__": |
| 107 | + continue |
| 108 | + if not isinstance(node.func.value, ast.Call): |
| 109 | + continue |
| 110 | + func_value = node.func.value |
| 111 | + if not isinstance(func_value.func, ast.Name): |
| 112 | + continue |
| 113 | + if func_value.func.id != "super": |
| 114 | + continue |
| 115 | + |
| 116 | + # Found a super().__init__() call, extract keyword arguments |
| 117 | + module_name: str | None = None |
| 118 | + supported_versions: str | None = None |
| 119 | + |
| 120 | + for keyword in node.keywords: |
| 121 | + if keyword.arg == "module_name": |
| 122 | + module_name = extract_string_from_node(keyword.value) |
| 123 | + elif keyword.arg == "supported_versions": |
| 124 | + supported_versions = extract_string_from_node(keyword.value) |
| 125 | + |
| 126 | + if module_name: |
| 127 | + return { |
| 128 | + "module_name": module_name, |
| 129 | + "supported_versions": supported_versions or "*", |
| 130 | + } |
| 131 | + |
| 132 | + return None |
| 133 | + |
| 134 | + |
| 135 | +def main() -> int: |
| 136 | + parser = argparse.ArgumentParser(description="Generate instrumentation manifest for the Python SDK") |
| 137 | + parser.add_argument( |
| 138 | + "--dry-run", |
| 139 | + action="store_true", |
| 140 | + help="Print manifest to stdout without writing to file", |
| 141 | + ) |
| 142 | + parser.add_argument( |
| 143 | + "--output", |
| 144 | + type=Path, |
| 145 | + default=PROJECT_ROOT / "instrumentation-manifest.json", |
| 146 | + help="Output file path (default: instrumentation-manifest.json in project root)", |
| 147 | + ) |
| 148 | + args = parser.parse_args() |
| 149 | + |
| 150 | + print("Discovering instrumentation files...") |
| 151 | + files = discover_instrumentation_files() |
| 152 | + print(f"Found {len(files)} instrumentation files (excluding internal)") |
| 153 | + |
| 154 | + instrumentations: list[dict[str, str | list[str]]] = [] |
| 155 | + |
| 156 | + for file_path in files: |
| 157 | + relative_path = file_path.relative_to(INSTRUMENTATION_DIR) |
| 158 | + result = parse_instrumentation_file(file_path) |
| 159 | + |
| 160 | + if result is None: |
| 161 | + print(f" Warning: No instrumentation found in {relative_path}", file=sys.stderr) |
| 162 | + else: |
| 163 | + print(f" {relative_path}: {result['module_name']}@{result['supported_versions']}") |
| 164 | + instrumentations.append( |
| 165 | + { |
| 166 | + "packageName": result["module_name"], |
| 167 | + "supportedVersions": [result["supported_versions"]], |
| 168 | + } |
| 169 | + ) |
| 170 | + |
| 171 | + # Sort by package name for consistent output |
| 172 | + instrumentations.sort(key=lambda x: x["packageName"]) # type: ignore[arg-type, return-value] |
| 173 | + |
| 174 | + sdk_version = get_sdk_version() |
| 175 | + |
| 176 | + manifest = { |
| 177 | + "sdkVersion": sdk_version, |
| 178 | + "language": "python", |
| 179 | + "generatedAt": datetime.now(UTC).isoformat().replace("+00:00", "Z"), |
| 180 | + "instrumentations": instrumentations, |
| 181 | + } |
| 182 | + |
| 183 | + manifest_json = json.dumps(manifest, indent=2) + "\n" |
| 184 | + |
| 185 | + if args.dry_run: |
| 186 | + print("\n--- Manifest (dry run) ---") |
| 187 | + print(manifest_json) |
| 188 | + else: |
| 189 | + args.output.parent.mkdir(parents=True, exist_ok=True) |
| 190 | + with open(args.output, "w") as f: |
| 191 | + f.write(manifest_json) |
| 192 | + print(f"\nGenerated {args.output}") |
| 193 | + |
| 194 | + print(f"SDK version: {sdk_version}") |
| 195 | + print(f"Instrumentations: {len(instrumentations)}") |
| 196 | + for entry in instrumentations: |
| 197 | + versions = entry["supportedVersions"] |
| 198 | + print(f" - {entry['packageName']}: {', '.join(versions)}") # type: ignore[arg-type] |
| 199 | + |
| 200 | + return 0 |
| 201 | + |
| 202 | + |
| 203 | +if __name__ == "__main__": |
| 204 | + sys.exit(main()) |
0 commit comments