Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
c26bddb
feat: integrate .NET instrumentation watcher
Pittu-Sharma May 10, 2026
adf5fc4
style: run prettier format check fixes
Pittu-Sharma May 10, 2026
9daeef2
Refactor .NET instrumentation watcher to use NuGet V3 Search API
Pittu-Sharma May 12, 2026
bc9f0fb
trigger ci: re-run workflows
Pittu-Sharma May 12, 2026
1b14238
Merge branch 'main' into feat/dotnet-instrumentation-watcher
Pittu-Sharma May 12, 2026
8b02341
Merge branch 'main' into feat/dotnet-instrumentation-watcher
Pittu-Sharma May 12, 2026
1628d32
Merge branch 'main' into feat/dotnet-instrumentation-watcher
Pittu-Sharma May 13, 2026
1f360f7
Merge branch 'main' into feat/dotnet-instrumentation-watcher
Pittu-Sharma May 13, 2026
c27f3c7
Merge branch 'main' into feat/dotnet-instrumentation-watcher
Pittu-Sharma May 13, 2026
5091e8b
Address mentor feedback: Use NuGet service index, include prerelease,…
Pittu-Sharma May 13, 2026
8d3b2fd
Merge branch 'main' into feat/dotnet-instrumentation-watcher
Pittu-Sharma May 13, 2026
7e7065b
fix(dotnet-watcher): address mentor review feedback
Pittu-Sharma May 13, 2026
6ab4f7b
Merge branch 'main' into feat/dotnet-instrumentation-watcher
Pittu-Sharma May 13, 2026
8e46aa2
Merge branch 'main' into feat/dotnet-instrumentation-watcher
Pittu-Sharma May 13, 2026
917a264
fix(.net-watcher): correct versioning and filter deprecated packages
Pittu-Sharma May 14, 2026
1236676
Merge branch 'feat/dotnet-instrumentation-watcher' of https://github.…
Pittu-Sharma May 14, 2026
5945063
Merge branch 'main' into feat/dotnet-instrumentation-watcher
Pittu-Sharma May 14, 2026
13190d8
Merge branch 'main' into feat/dotnet-instrumentation-watcher
Pittu-Sharma May 14, 2026
880f805
refactor(api): address copilot feedback on resilience and caching
Pittu-Sharma May 14, 2026
462a137
chore: remove unused files, revert glow badge changes, and fix result…
Pittu-Sharma May 14, 2026
b7ca795
Merge branch 'main' into feat/dotnet-instrumentation-watcher
Pittu-Sharma May 14, 2026
46a7bd5
Merge branch 'main' from upstream to resolve conflicts
Pittu-Sharma May 15, 2026
fcf9570
Merge branch 'main' into feat/dotnet-instrumentation-watcher
Pittu-Sharma May 16, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/workflows/build-and-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,11 @@ jobs:
cd ecosystem-automation/explorer-db-builder
uv run pytest tests/ --cov=explorer_db_builder --cov-report=term-missing --cov-report=json
- name: Run dotnet-instrumentation-watcher tests
run: |
cd ecosystem-automation/dotnet-instrumentation-watcher
uv run pytest tests/ --cov=dotnet_instrumentation_watcher --cov-report=term-missing --cov-report=json
test-ecosystem-explorer:
runs-on: ubuntu-latest
defaults:
Expand Down
18 changes: 18 additions & 0 deletions .github/workflows/nightly-registry-update.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ jobs:
outputs:
collector_result: ${{ steps.collector_watcher.outcome }}
java_result: ${{ steps.java_instrumentation_watcher.outcome }}
dotnet_result: ${{ steps.dotnet_instrumentation_watcher.outcome }}
configuration_result: ${{ steps.configuration_watcher.outcome }}
steps:
- name: Checkout code
Expand Down Expand Up @@ -140,6 +141,12 @@ jobs:
run: uv run java-instrumentation-watcher
continue-on-error: true

- name: Run dotnet-instrumentation-watcher
id: dotnet_instrumentation_watcher
if: always()
run: uv run dotnet-instrumentation-watcher
continue-on-error: true

- name: Run configuration-watcher
id: configuration_watcher
if: always()
Expand Down Expand Up @@ -286,3 +293,14 @@ jobs:
with:
success: ${{ needs.synchronize-inventory.outputs.configuration_result == 'success' }}
watcher-name: "configuration-watcher"

notify-dotnet:
permissions:
contents: read
issues: write
needs: [synchronize-inventory]
if: ${{ !cancelled() && needs.synchronize-inventory.result != 'skipped' }}
uses: ./.github/workflows/reusable-workflow-notification.yml
with:
success: ${{ needs.synchronize-inventory.outputs.dotnet_result == 'success' }}
watcher-name: "dotnet-instrumentation-watcher"
14 changes: 0 additions & 14 deletions ecosystem-automation/configuration-watcher/tests/__init__.py

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
[project]
name = "dotnet-instrumentation-watcher"
version = "0.1.0"
description = "Automation tool for watching and collecting OpenTelemetry .NET instrumentation metadata"
requires-python = ">=3.11"
dependencies = [
"PyYAML>=6.0.1",
"requests>=2.31.0",
"semantic-version>=2.10.0",
"watcher-common",
]

[project.scripts]
dotnet-instrumentation-watcher = "dotnet_instrumentation_watcher.main:main"

[project.optional-dependencies]
dev = [
"pytest>=8.0.0",
"pytest-cov>=4.1.0",
]

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[tool.uv.sources]
watcher-common = { workspace = true }

[tool.hatch.build.targets.wheel]
packages = ["src/dotnet_instrumentation_watcher"]
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,4 @@
# See the License for the specific language governing permissions and
# limitations under the License.
#
"""Tests for collector-watcher."""
"""Dotnet Instrumentation Watcher package."""
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
# 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
#
# https://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.
#
"""NuGet API client for fetching .NET instrumentation data."""

import logging
from typing import Any, Dict, List

import requests
from requests.adapters import HTTPAdapter
from urllib3 import Retry

logger = logging.getLogger(__name__)


class NuGetAPIError(Exception):
"""Custom exception for NuGet API errors."""

pass


class DotNetInstrumentationClient:
"""Client for fetching .NET instrumentation metadata from NuGet."""

SERVICE_INDEX_URL = "https://api.nuget.org/v3/index.json"
OWNER = "OpenTelemetry"
TIMEOUT = 30

def __init__(self):
"""Initialize the client."""
self._session = requests.Session()
self._search_url = None

retry_strategy = Retry(
total=3,
backoff_factor=1,
status_forcelist=[429, 500, 502, 503, 504],
)

adapter = HTTPAdapter(max_retries=retry_strategy)
self._session.mount("https://", adapter)

def _get_search_url(self) -> str:
"""Resolve the search URL from the NuGet service index.

Raises:
NuGetAPIError: If the service index cannot be fetched or does not
contain a SearchQueryService resource.
"""
if self._search_url:
return self._search_url

try:
response = self._session.get(self.SERVICE_INDEX_URL, timeout=self.TIMEOUT)
response.raise_for_status()
index_data = response.json()
except requests.RequestException as e:
raise NuGetAPIError(f"Error fetching NuGet service index: {e}") from e

for resource in index_data.get("resources", []):
if resource.get("@type") == "SearchQueryService":
self._search_url = resource["@id"]
return self._search_url

raise NuGetAPIError("NuGet service index did not contain a SearchQueryService resource")

def fetch_instrumentation_list(self) -> Dict[str, Any]:
"""
Fetch instrumentation list by querying NuGet for packages owned by OpenTelemetry.

The top-level ``version`` field in each search result entry is the latest
version of the package as reported by NuGet — no local sorting is needed.
"""
all_packages = self._fetch_all_packages_by_owner(self.OWNER)
modules = []

for pkg in all_packages:
package_id = pkg.get("id", "")

# Skip packages flagged as deprecated by NuGet (includes Contrib packages).
if pkg.get("deprecation"):
logger.info(f" Skipping deprecated package: {package_id}")
continue

# The top-level "version" field is the latest version returned by the
# NuGet search API — rely on the server ordering rather than sorting locally.
version = pkg.get("version", "")
description = pkg.get("description", "")

# Filter and classify packages
if "Instrumentation" in package_id:
component_type = "instrumentation"
elif "Exporter" in package_id:
component_type = "exporter"
elif "Extensions" in package_id or "Resources" in package_id or "Sampler" in package_id:
component_type = "extension"
else:
# Skip core and unclassified packages.
continue

modules.append(
{
"name": package_id,
"description": description or f"{package_id} for OpenTelemetry",
"type": component_type,
"version": version,
}
)

# Sort by name for deterministic registry output.
modules.sort(key=lambda x: x["name"])

return {"modules": modules}

def get_core_version(self) -> str:
"""Get the latest stable version of the core OpenTelemetry package.

This is used as the 'ecosystem version' for the registry.

Raises:
NuGetAPIError: If the version cannot be determined.
"""
params = {
"q": "PackageId:OpenTelemetry",
"prerelease": "false",
"semVerLevel": "2.0.0",
"take": 1,
}
try:
search_url = self._get_search_url()
response = self._session.get(search_url, params=params, timeout=self.TIMEOUT)
response.raise_for_status()
data = response.json()
results = data.get("data", [])
if not results:
raise NuGetAPIError("No results returned for core OpenTelemetry package")
return results[0]["version"]
except (KeyError, IndexError) as e:
raise NuGetAPIError(f"Unexpected response shape fetching core version: {e}") from e
except requests.RequestException as e:
raise NuGetAPIError(f"Error fetching core version: {e}") from e

def _fetch_all_packages_by_owner(self, owner: str) -> List[Dict[str, Any]]:
"""Fetch all packages for a specific owner using pagination."""
packages = []
skip = 0
take = 20

while True:
params = {
"q": f"owner:{owner}",
"prerelease": "true",
"semVerLevel": "2.0.0",
"skip": skip,
"take": take,
}
try:
search_url = self._get_search_url()
response = self._session.get(search_url, params=params, timeout=self.TIMEOUT)
response.raise_for_status()
data = response.json()

batch = data.get("data", [])
packages.extend(batch)

if len(batch) < take:
break

skip += take
except requests.RequestException as e:
raise NuGetAPIError(f"Error fetching packages from NuGet: {e}") from e

return packages
Loading