Skip to content
44 changes: 44 additions & 0 deletions ecosystem-automation/js-instrumentation-watcher/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# 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.

[project]
name = "js-instrumentation-watcher"
version = "0.1.0"
description = "Automation tool for watching and collecting OpenTelemetry JavaScript instrumentation metadata"
requires-python = ">=3.11"
dependencies = [
"PyYAML>=6.0.1",
"GitPython>=3.1.40",

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I just removed the need for these dependencies from the other modules in #600 could you take a look and do the same thing for this new watcher?

"semantic-version>=2.10.0",
"watcher-common",
]

[project.scripts]
js-instrumentation-watcher = "js_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/js_instrumentation_watcher"]
Original file line number Diff line number Diff line change
@@ -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
#
# 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.
#
Comment thread
jaydeluca marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -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
#
# 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.
#

from .main import main

if __name__ == "__main__":
main()
Comment thread
jaydeluca marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
# 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.
#

"""Synchronization orchestration for JS instrumentation metadata."""

import logging
from pathlib import Path
from typing import Any

from .inventory_manager import InventoryManager
from .package_parser import PackageParser
from .package_scanner import PackageScanner

logger = logging.getLogger(__name__)


class InstrumentationSync:
"""
Orchestrates synchronization of JS instrumentation metadata.

Walks the js-contrib repository, parses each instrumentation package,
and writes per-package versioned YAML to the registry.

Unlike the Java watcher which has a single release version, JS packages
version independently. Each package is stored at its own version:
ecosystem-registry/javascript/{package}/v{version}.yaml
"""

def __init__(
self,
repo_path: Path,
inventory_manager: InventoryManager,
):
"""
Args:
repo_path: Path to the cloned opentelemetry-js-contrib repository
inventory_manager: Inventory manager for writing registry files
"""
self.repo_path = repo_path
self.inventory_manager = inventory_manager
self.scanner = PackageScanner(repo_path)

def sync(self) -> dict[str, Any]:
"""
Synchronize all JS instrumentation packages to the registry.

For each package:
- If the current version already exists in the registry, skip it
- Otherwise parse and write the metadata

Returns:
Summary dict with counts of new, skipped, and failed packages
"""
summary: dict[str, Any] = {
"new": [],
"skipped": [],
"failed": [],
}

bundle_membership = self.scanner.load_bundle_membership()
logger.info("Loaded %d packages from auto-instrumentations-node", len(bundle_membership))

component_owners = self.scanner.load_component_owners()
logger.info("Loaded owners for %d components", len(component_owners))

packages = self.scanner.discover_packages()

for package_path in packages:
name = package_path.name
parser = PackageParser(
package_path=package_path,
bundle_membership=bundle_membership,
component_owners=component_owners,
)

try:
data = parser.parse()
except Exception as e:
logger.warning("Failed to parse %s: %s", name, e)
summary["failed"].append(name)
continue
Comment thread
jaydeluca marked this conversation as resolved.

if data is None:
logger.warning("No data parsed for %s — skipping", name)
summary["failed"].append(name)
continue

version = data.get("version", "")
if not version:
logger.warning("No version found for %s — skipping", name)
summary["failed"].append(name)
continue

if self.inventory_manager.version_exists(name, version):
logger.debug("Already tracked: %s v%s", name, version)
summary["skipped"].append(f"{name}@{version}")
continue

self.inventory_manager.save(name, version, data)
logger.info("Saved: %s v%s", name, version)
summary["new"].append(f"{name}@{version}")

logger.info(
"Sync complete — new: %d, skipped: %d, failed: %d",
len(summary["new"]),
len(summary["skipped"]),
len(summary["failed"]),
)

return summary
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
# 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.
#

"""Inventory manager for JS instrumentation registry storage."""

import logging
from pathlib import Path

import yaml

logger = logging.getLogger(__name__)


class InventoryManager:
"""
Manages storage of JS instrumentation metadata in the registry.

Registry layout (per maintainer direction):

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
Registry layout (per maintainer direction):
Registry layout:

ecosystem-registry/javascript/{package-name}/v{version}.yaml

Each package is versioned independently — there is no single
aggregated version file like the Java watcher uses.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
Each package is versioned independentlythere is no single
aggregated version file like the Java watcher uses.

"""

def __init__(self, registry_dir: str):
"""
Args:
registry_dir: Base registry directory, e.g. 'ecosystem-registry/javascript'
"""
self.registry_dir = Path(registry_dir)

def version_exists(self, package_name: str, version: str) -> bool:
"""
Check if a specific package version already exists in the registry.

Args:
package_name: Package directory name, e.g. 'instrumentation-express'
version: Version string, e.g. '0.66.0'

Returns:
True if the version file exists
"""
return self._version_path(package_name, version).exists()

def save(self, package_name: str, version: str, data: dict) -> None:
"""
Save a package version to the registry.

Args:
package_name: Package directory name
version: Version string
data: Metadata dict to serialize as YAML
"""
path = self._version_path(package_name, version)
path.parent.mkdir(parents=True, exist_ok=True)

with path.open("w") as f:
yaml.dump(
data,
f,
default_flow_style=False,
sort_keys=True,
allow_unicode=True,
)

logger.debug("Saved %s v%s to %s", package_name, version, path)

def _version_path(self, package_name: str, version: str) -> Path:
"""
Build the path for a package version file.

Args:
package_name: Package directory name
version: Version string

Returns:
Path to the version YAML file
"""
return self.registry_dir / package_name / f"v{version}.yaml"
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# 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.
#

"""Entry point for the JS instrumentation watcher."""

import logging
import os

from .instrumentation_sync import InstrumentationSync
from .inventory_manager import InventoryManager
from .repository_manager import JsContribRepositoryManager

logging.basicConfig(
level=logging.INFO,
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
)
logger = logging.getLogger(__name__)

REGISTRY_DIR = "ecosystem-registry/javascript"


def main() -> None:
"""Main entry point for the JS instrumentation watcher."""
base_dir = os.environ.get("JS_CONTRIB_REPOS_DIR", "tmp_repos")

logger.info("Starting JS instrumentation watcher...")

repo_manager = JsContribRepositoryManager(base_dir=base_dir)
repo_path = repo_manager.setup()

inventory_manager = InventoryManager(registry_dir=REGISTRY_DIR)

sync = InstrumentationSync(
repo_path=repo_path,
inventory_manager=inventory_manager,
)

summary = sync.sync()

logger.info("Sync complete: %s", summary)
Loading