Skip to content

Commit 40a253c

Browse files
feat(automation): integrate .NET instrumentation watcher (#421)
Co-authored-by: Jay DeLuca <jaydeluca4@gmail.com>
1 parent be15d75 commit 40a253c

21 files changed

Lines changed: 1365 additions & 32 deletions

File tree

.github/workflows/build-and-test.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,11 @@ jobs:
7676
cd ecosystem-automation/explorer-db-builder
7777
uv run pytest tests/ --cov=explorer_db_builder --cov-report=term-missing --cov-report=json
7878
79+
- name: Run dotnet-instrumentation-watcher tests
80+
run: |
81+
cd ecosystem-automation/dotnet-instrumentation-watcher
82+
uv run pytest tests/ --cov=dotnet_instrumentation_watcher --cov-report=term-missing --cov-report=json
83+
7984
test-ecosystem-explorer:
8085
runs-on: ubuntu-latest
8186
defaults:

.github/workflows/nightly-registry-update.yml

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ jobs:
2222
outputs:
2323
collector_result: ${{ steps.collector_watcher.outcome }}
2424
java_result: ${{ steps.java_instrumentation_watcher.outcome }}
25+
dotnet_result: ${{ steps.dotnet_instrumentation_watcher.outcome }}
2526
configuration_result: ${{ steps.configuration_watcher.outcome }}
2627
steps:
2728
- name: Checkout code
@@ -140,6 +141,12 @@ jobs:
140141
run: uv run java-instrumentation-watcher
141142
continue-on-error: true
142143

144+
- name: Run dotnet-instrumentation-watcher
145+
id: dotnet_instrumentation_watcher
146+
if: always()
147+
run: uv run dotnet-instrumentation-watcher
148+
continue-on-error: true
149+
143150
- name: Run configuration-watcher
144151
id: configuration_watcher
145152
if: always()
@@ -286,3 +293,14 @@ jobs:
286293
with:
287294
success: ${{ needs.synchronize-inventory.outputs.configuration_result == 'success' }}
288295
watcher-name: "configuration-watcher"
296+
297+
notify-dotnet:
298+
permissions:
299+
contents: read
300+
issues: write
301+
needs: [synchronize-inventory]
302+
if: ${{ !cancelled() && needs.synchronize-inventory.result != 'skipped' }}
303+
uses: ./.github/workflows/reusable-workflow-notification.yml
304+
with:
305+
success: ${{ needs.synchronize-inventory.outputs.dotnet_result == 'success' }}
306+
watcher-name: "dotnet-instrumentation-watcher"

ecosystem-automation/configuration-watcher/tests/__init__.py

Lines changed: 0 additions & 14 deletions
This file was deleted.
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
[project]
2+
name = "dotnet-instrumentation-watcher"
3+
version = "0.1.0"
4+
description = "Automation tool for watching and collecting OpenTelemetry .NET instrumentation metadata"
5+
requires-python = ">=3.11"
6+
dependencies = [
7+
"PyYAML>=6.0.1",
8+
"requests>=2.31.0",
9+
"semantic-version>=2.10.0",
10+
"watcher-common",
11+
]
12+
13+
[project.scripts]
14+
dotnet-instrumentation-watcher = "dotnet_instrumentation_watcher.main:main"
15+
16+
[project.optional-dependencies]
17+
dev = [
18+
"pytest>=8.0.0",
19+
"pytest-cov>=4.1.0",
20+
]
21+
22+
[build-system]
23+
requires = ["hatchling"]
24+
build-backend = "hatchling.build"
25+
26+
[tool.uv.sources]
27+
watcher-common = { workspace = true }
28+
29+
[tool.hatch.build.targets.wheel]
30+
packages = ["src/dotnet_instrumentation_watcher"]

ecosystem-automation/collector-watcher/tests/__init__.py renamed to ecosystem-automation/dotnet-instrumentation-watcher/src/dotnet_instrumentation_watcher/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,4 +12,4 @@
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
1414
#
15-
"""Tests for collector-watcher."""
15+
"""Dotnet Instrumentation Watcher package."""
Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
# Copyright The OpenTelemetry Authors
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# https://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
#
15+
"""NuGet API client for fetching .NET instrumentation data."""
16+
17+
import logging
18+
from typing import Any, Dict, List
19+
20+
import requests
21+
from requests.adapters import HTTPAdapter
22+
from urllib3 import Retry
23+
24+
logger = logging.getLogger(__name__)
25+
26+
27+
class NuGetAPIError(Exception):
28+
"""Custom exception for NuGet API errors."""
29+
30+
pass
31+
32+
33+
class DotNetInstrumentationClient:
34+
"""Client for fetching .NET instrumentation metadata from NuGet."""
35+
36+
SERVICE_INDEX_URL = "https://api.nuget.org/v3/index.json"
37+
OWNER = "OpenTelemetry"
38+
TIMEOUT = 30
39+
40+
def __init__(self):
41+
"""Initialize the client."""
42+
self._session = requests.Session()
43+
self._search_url = None
44+
45+
retry_strategy = Retry(
46+
total=3,
47+
backoff_factor=1,
48+
status_forcelist=[429, 500, 502, 503, 504],
49+
)
50+
51+
adapter = HTTPAdapter(max_retries=retry_strategy)
52+
self._session.mount("https://", adapter)
53+
54+
def _get_search_url(self) -> str:
55+
"""Resolve the search URL from the NuGet service index.
56+
57+
Raises:
58+
NuGetAPIError: If the service index cannot be fetched or does not
59+
contain a SearchQueryService resource.
60+
"""
61+
if self._search_url:
62+
return self._search_url
63+
64+
try:
65+
response = self._session.get(self.SERVICE_INDEX_URL, timeout=self.TIMEOUT)
66+
response.raise_for_status()
67+
index_data = response.json()
68+
except requests.RequestException as e:
69+
raise NuGetAPIError(f"Error fetching NuGet service index: {e}") from e
70+
71+
for resource in index_data.get("resources", []):
72+
if resource.get("@type") == "SearchQueryService":
73+
self._search_url = resource["@id"]
74+
return self._search_url
75+
76+
raise NuGetAPIError("NuGet service index did not contain a SearchQueryService resource")
77+
78+
def fetch_instrumentation_list(self) -> Dict[str, Any]:
79+
"""
80+
Fetch instrumentation list by querying NuGet for packages owned by OpenTelemetry.
81+
82+
The top-level ``version`` field in each search result entry is the latest
83+
version of the package as reported by NuGet — no local sorting is needed.
84+
"""
85+
all_packages = self._fetch_all_packages_by_owner(self.OWNER)
86+
modules = []
87+
88+
for pkg in all_packages:
89+
package_id = pkg.get("id", "")
90+
91+
# Skip packages flagged as deprecated by NuGet (includes Contrib packages).
92+
if pkg.get("deprecation"):
93+
logger.info(f" Skipping deprecated package: {package_id}")
94+
continue
95+
96+
# The top-level "version" field is the latest version returned by the
97+
# NuGet search API — rely on the server ordering rather than sorting locally.
98+
version = pkg.get("version", "")
99+
description = pkg.get("description", "")
100+
101+
# Filter and classify packages
102+
if "Instrumentation" in package_id:
103+
component_type = "instrumentation"
104+
elif "Exporter" in package_id:
105+
component_type = "exporter"
106+
elif "Extensions" in package_id or "Resources" in package_id or "Sampler" in package_id:
107+
component_type = "extension"
108+
else:
109+
# Skip core and unclassified packages.
110+
continue
111+
112+
modules.append(
113+
{
114+
"name": package_id,
115+
"description": description or f"{package_id} for OpenTelemetry",
116+
"type": component_type,
117+
"version": version,
118+
}
119+
)
120+
121+
# Sort by name for deterministic registry output.
122+
modules.sort(key=lambda x: x["name"])
123+
124+
return {"modules": modules}
125+
126+
def get_core_version(self) -> str:
127+
"""Get the latest stable version of the core OpenTelemetry package.
128+
129+
This is used as the 'ecosystem version' for the registry.
130+
131+
Raises:
132+
NuGetAPIError: If the version cannot be determined.
133+
"""
134+
params = {
135+
"q": "PackageId:OpenTelemetry",
136+
"prerelease": "false",
137+
"semVerLevel": "2.0.0",
138+
"take": 1,
139+
}
140+
try:
141+
search_url = self._get_search_url()
142+
response = self._session.get(search_url, params=params, timeout=self.TIMEOUT)
143+
response.raise_for_status()
144+
data = response.json()
145+
results = data.get("data", [])
146+
if not results:
147+
raise NuGetAPIError("No results returned for core OpenTelemetry package")
148+
return results[0]["version"]
149+
except (KeyError, IndexError) as e:
150+
raise NuGetAPIError(f"Unexpected response shape fetching core version: {e}") from e
151+
except requests.RequestException as e:
152+
raise NuGetAPIError(f"Error fetching core version: {e}") from e
153+
154+
def _fetch_all_packages_by_owner(self, owner: str) -> List[Dict[str, Any]]:
155+
"""Fetch all packages for a specific owner using pagination."""
156+
packages = []
157+
skip = 0
158+
take = 20
159+
160+
while True:
161+
params = {
162+
"q": f"owner:{owner}",
163+
"prerelease": "true",
164+
"semVerLevel": "2.0.0",
165+
"skip": skip,
166+
"take": take,
167+
}
168+
try:
169+
search_url = self._get_search_url()
170+
response = self._session.get(search_url, params=params, timeout=self.TIMEOUT)
171+
response.raise_for_status()
172+
data = response.json()
173+
174+
batch = data.get("data", [])
175+
packages.extend(batch)
176+
177+
if len(batch) < take:
178+
break
179+
180+
skip += take
181+
except requests.RequestException as e:
182+
raise NuGetAPIError(f"Error fetching packages from NuGet: {e}") from e
183+
184+
return packages

0 commit comments

Comments
 (0)