netra-zen 1.0.2__tar.gz → 1.0.4__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {netra_zen-1.0.2/netra_zen.egg-info → netra_zen-1.0.4}/PKG-INFO +1 -1
- {netra_zen-1.0.2 → netra_zen-1.0.4/netra_zen.egg-info}/PKG-INFO +1 -1
- {netra_zen-1.0.2 → netra_zen-1.0.4}/netra_zen.egg-info/SOURCES.txt +5 -1
- {netra_zen-1.0.2 → netra_zen-1.0.4}/netra_zen.egg-info/top_level.txt +1 -0
- {netra_zen-1.0.2 → netra_zen-1.0.4}/pyproject.toml +2 -2
- {netra_zen-1.0.2 → netra_zen-1.0.4}/setup.py +1 -1
- netra_zen-1.0.4/zen/__init__.py +7 -0
- netra_zen-1.0.4/zen/telemetry/__init__.py +11 -0
- netra_zen-1.0.4/zen/telemetry/embedded_credentials.py +26 -0
- netra_zen-1.0.4/zen/telemetry/manager.py +249 -0
- {netra_zen-1.0.2 → netra_zen-1.0.4}/zen_orchestrator.py +120 -93
- {netra_zen-1.0.2 → netra_zen-1.0.4}/LICENSE.md +0 -0
- {netra_zen-1.0.2 → netra_zen-1.0.4}/MANIFEST.in +0 -0
- {netra_zen-1.0.2 → netra_zen-1.0.4}/README.md +0 -0
- {netra_zen-1.0.2 → netra_zen-1.0.4}/agent_interface/__init__.py +0 -0
- {netra_zen-1.0.2 → netra_zen-1.0.4}/agent_interface/base_agent.py +0 -0
- {netra_zen-1.0.2 → netra_zen-1.0.4}/config_example.json +0 -0
- {netra_zen-1.0.2 → netra_zen-1.0.4}/docs/CACHE_TOKENS_GUIDE.md +0 -0
- {netra_zen-1.0.2 → netra_zen-1.0.4}/docs/Cost_allocation.md +0 -0
- {netra_zen-1.0.2 → netra_zen-1.0.4}/docs/DOLLAR_BUDGET_USAGE_EXAMPLES.md +0 -0
- {netra_zen-1.0.2 → netra_zen-1.0.4}/docs/EXAMPLES.md +0 -0
- {netra_zen-1.0.2 → netra_zen-1.0.4}/docs/MODEL_COLUMN_GUIDE.md +0 -0
- {netra_zen-1.0.2 → netra_zen-1.0.4}/netra_zen.egg-info/dependency_links.txt +0 -0
- {netra_zen-1.0.2 → netra_zen-1.0.4}/netra_zen.egg-info/entry_points.txt +0 -0
- {netra_zen-1.0.2 → netra_zen-1.0.4}/netra_zen.egg-info/requires.txt +0 -0
- {netra_zen-1.0.2 → netra_zen-1.0.4}/prebuilt_commands_example.json +0 -0
- {netra_zen-1.0.2 → netra_zen-1.0.4}/requirements-dev.txt +0 -0
- {netra_zen-1.0.2 → netra_zen-1.0.4}/requirements.txt +0 -0
- {netra_zen-1.0.2 → netra_zen-1.0.4}/setup.cfg +0 -0
- {netra_zen-1.0.2 → netra_zen-1.0.4}/tests/__init__.py +0 -0
- {netra_zen-1.0.2 → netra_zen-1.0.4}/tests/test_agent_interface.py +0 -0
- {netra_zen-1.0.2 → netra_zen-1.0.4}/tests/test_cli_extensions.py +0 -0
- {netra_zen-1.0.2 → netra_zen-1.0.4}/tests/test_cli_integration.py +0 -0
- {netra_zen-1.0.2 → netra_zen-1.0.4}/tests/test_direct_command_execution.py +0 -0
- {netra_zen-1.0.2 → netra_zen-1.0.4}/tests/test_dollar_budget_enhancement.py +0 -0
- {netra_zen-1.0.2 → netra_zen-1.0.4}/tests/test_permission_fix_windows.py +0 -0
- {netra_zen-1.0.2 → netra_zen-1.0.4}/tests/test_pricing_engine.py +0 -0
- {netra_zen-1.0.2 → netra_zen-1.0.4}/tests/test_runner.py +0 -0
- {netra_zen-1.0.2 → netra_zen-1.0.4}/tests/test_workspace_detection.py +0 -0
- {netra_zen-1.0.2 → netra_zen-1.0.4}/tests/test_zen_commands.py +0 -0
- {netra_zen-1.0.2 → netra_zen-1.0.4}/tests/test_zen_integration.py +0 -0
- {netra_zen-1.0.2 → netra_zen-1.0.4}/tests/test_zen_metrics.py +0 -0
- {netra_zen-1.0.2 → netra_zen-1.0.4}/tests/test_zen_unit.py +0 -0
- {netra_zen-1.0.2 → netra_zen-1.0.4}/token_budget/__init__.py +0 -0
- {netra_zen-1.0.2 → netra_zen-1.0.4}/token_budget/budget_manager.py +0 -0
- {netra_zen-1.0.2 → netra_zen-1.0.4}/token_budget/models.py +0 -0
- {netra_zen-1.0.2 → netra_zen-1.0.4}/token_budget/visualization.py +0 -0
- {netra_zen-1.0.2 → netra_zen-1.0.4}/token_transparency/__init__.py +0 -0
- {netra_zen-1.0.2 → netra_zen-1.0.4}/token_transparency/claude_pricing_engine.py +0 -0
@@ -40,4 +40,8 @@ token_budget/budget_manager.py
|
|
40
40
|
token_budget/models.py
|
41
41
|
token_budget/visualization.py
|
42
42
|
token_transparency/__init__.py
|
43
|
-
token_transparency/claude_pricing_engine.py
|
43
|
+
token_transparency/claude_pricing_engine.py
|
44
|
+
zen/__init__.py
|
45
|
+
zen/telemetry/__init__.py
|
46
|
+
zen/telemetry/embedded_credentials.py
|
47
|
+
zen/telemetry/manager.py
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
4
4
|
|
5
5
|
[project]
|
6
6
|
name = "netra-zen"
|
7
|
-
version = "1.0.
|
7
|
+
version = "1.0.4"
|
8
8
|
description = "Multi-instance Claude orchestrator for parallel task execution"
|
9
9
|
readme = "README.md"
|
10
10
|
authors = [
|
@@ -58,7 +58,7 @@ zen = "zen_orchestrator:run"
|
|
58
58
|
|
59
59
|
[tool.setuptools]
|
60
60
|
py-modules = ["zen_orchestrator"]
|
61
|
-
packages = ["agent_interface", "token_budget", "token_transparency"]
|
61
|
+
packages = ["agent_interface", "token_budget", "token_transparency", "zen", "zen.telemetry"]
|
62
62
|
|
63
63
|
[tool.setuptools.package-data]
|
64
64
|
"*" = ["*.json", "*.yaml", "*.yml", "*.md"]
|
@@ -8,7 +8,7 @@ with open("README.md", "r", encoding="utf-8") as fh:
|
|
8
8
|
|
9
9
|
setup(
|
10
10
|
name="netra-zen",
|
11
|
-
version="1.0.
|
11
|
+
version="1.0.4",
|
12
12
|
author=" Systems",
|
13
13
|
author_email="pypi@netrasystems.ai",
|
14
14
|
description="Multi-instance Claude orchestrator for parallel task execution",
|
@@ -0,0 +1,11 @@
|
|
1
|
+
"""Telemetry utilities exposed by the Zen package."""
|
2
|
+
|
3
|
+
from .embedded_credentials import get_embedded_credentials, get_project_id
|
4
|
+
from .manager import TelemetryManager, telemetry_manager
|
5
|
+
|
6
|
+
__all__ = [
|
7
|
+
"TelemetryManager",
|
8
|
+
"telemetry_manager",
|
9
|
+
"get_embedded_credentials",
|
10
|
+
"get_project_id",
|
11
|
+
]
|
@@ -0,0 +1,26 @@
|
|
1
|
+
"""Embedded telemetry credentials. AUTO-GENERATED - DO NOT COMMIT."""
|
2
|
+
|
3
|
+
import base64
|
4
|
+
import json
|
5
|
+
from google.oauth2 import service_account
|
6
|
+
|
7
|
+
_EMBEDDED_CREDENTIALS_B64 = 'ewogICJ0eXBlIjogInNlcnZpY2VfYWNjb3VudCIsCiAgInByb2plY3RfaWQiOiAibmV0cmEtdGVsZW1ldHJ5LXB1YmxpYyIsCiAgInByaXZhdGVfa2V5X2lkIjogImVjOWM4ZGNlZGZmMTUzNjM5YTUxOTcyMzc0MjYyNjkwNjZkNzAxYTQiLAogICJwcml2YXRlX2tleSI6ICItLS0tLUJFR0lOIFBSSVZBVEUgS0VZLS0tLS1cbk1JSUV2Z0lCQURBTkJna3Foa2lHOXcwQkFRRUZBQVNDQktnd2dnU2tBZ0VBQW9JQkFRREhUcmZFOHlQdUFCTDNcbk5aS3diZ1AwamRyaWRnY0UwMUlLMks1YkZ1bWFrUHVrRGxzV0dVaUswOXEyaVNYTWVUQmJPZDF0VjFoc3VJcUhcbnpQK0pZd0NTcVp4S0pIQS8yWUdVcERqeWhHRVd3QTNtS3laVXVUUS9yUTBUK20yV0cwdEMxUzBpQzB6U211cEpcbjBNeGhUUDRjKzYreEczSDVSWEF1YjdONmx6eFFWVnJSY3FHMTYydlF5SFA2OWIrd2RidTJHM0o5UkJVN1VUK1FcbkU2RHB2K01YaEp3MnRPdHZLbFBQT3BnWm9Sb0pmeXU1WlpzbHlJZCtzY3FhUTY5ZjBaSmpIRjlYQVdlT25mUTRcbmExbmV0LzJqRjZibWpuZmQ2MjhBODA5cEluTXJEL0FwZjJzUWJJdXJIYUI4am5uTEQ0eDMrbVhXRTgyMFJLNktcbjViZ0FQanNoQWdNQkFBRUNnZ0VBQmZERVZMWVlDakFkb0pscnpyOHF0a056cEpnV3Y3ZXlQSEZHcWgraDFSbndcbjdDd0Mza0xnNlFWbFFaZFBKWHZ2dTJwYlBaYnl3MlBST2ppN25adFNNU3pseEFaM3c0bHV2YkRTNHpTYnBiZFJcbityd3F3Mi8xUFJnaCtZaFhjNWZLNjVvcHd4Zmg0VzJkWWRlYnZlTXkrRWR1cmtsV2dYTG13L1dQbkkzdExlbzlcbjV1elZjbU42Qk04YkU3azFYK1M0RURBS0VRWlprUEdzTFQ4RXN4UmdWOWtnT1Zicm5VQ1Z0dXA1Q0NGbUR3U1Bcbmg0U25wMEsvTUp3b1U3NG4reTlFMXYxUXRnajE5TkhaNHJ2dFpnUlVaandHQy9Cc3ZkcE1PazArZTJEMlgvRk9cblZnc29xS2tDaklWUzRMcG5YSEpZbU5oajZWNHRXUnZ1OW1NTXhTL3FBUUtCZ1FEb3hZenlsdEZKL242ZEQvUHlcbnZLOFRaTHd5dFdCcjBXU3ZHU2VzM0JYRGZoKzBFbU4rRHpnZGdUb0ovbkhpazM5R21QM0tLN2htOFVvaFFHRy9cbkh0SFRuS0lBQlhrSU8yd2Z3N0h0V2pPTXRocHp2dFQxcmVEVHVjVk0wc2lCMHpjTldCMjFUamdQL3JYY2Q3NWVcbklERmNBN0hTbUJDLzB4bzk3aC8wV2YvOW9RS0JnUURiTWtnbjlVR2Y2SVRMNmxTcDdrYWJGL0NuaE4yU2VTMVdcbnd3R21iRThxTTU0UitDcVRUeHk2UHBRaFVSczlHM1VpVmQ1SXZWVDhuT095ZVBZVFJEbnFCYjJ4S214SFRodlZcbnVQcTgwQXB3anBMbzh6VkFDSy9iVFVjSmlKVGFBdXFHaXI1Ykc4YUlldVpIc0pLeWJ1NmhoNkhXMldwWXVVV1BcbkZ3TTl4elpOZ1FLQmdRQzg5dHJVZVJFUVE3VGZwbnJBM09JNEdUZ2E1bG1QVFo2eDh2YmRncEY4Y2FBbEhDUitcbnlyWWdaYThMTysrU0kzRllpNHpFR2pnS0FlblBFcWdIY21xZW9uSjFGL3hJYll6NlFIRHFJYWJsblZQZUVOWnJcblY2dkQxZlRReC9FVVM3Wk9jL0V5Slh5bnAzeFZyVFB5ejZtaWJERm9xQ0E0eVpSdElDbjZ3VEZxNFFLQmdFWFJcbnAxQXErOE0rb2dYOTF3ZmxvTkhIOTF5MG9vc0VWQis5cjZuZDkvMWVRYXhCbXZZZkRleDVBRi80WUsrL0xqbElcbmxxd2V1cEpZT3VMZlNxcHFZZlFiN2djZmx5dkRRblI2SGt2RURIODd1cW0reGlobVcvV0RrT3dGZUR4VkQzVFpcbmZyYXdpelZ2eUNmdm8xcDRvVVFNV3MxL3BUTXJtRzl5aWhMRWdKU0JBb0dCQU1GWm50ZUtUUDZrVVdrVmpOcndcbmUvQzBDbjJ6dk1YNXVnZURkS1FWNkwrY25mRWlRSzdzZ3R5eFp5ek5kMC82QXJ0YnBrcS9wcVlaYXpwVzVFMkxcbkxVMUF3MmdHT25GRlh2ZXg4aXpOZXViMGdvUVE4d3BtL3lrMVNVekR6VTV1dCtPbVFFRmpsbUYrNDkza0ZYcC9cbnc1MWh2WjVVL2loL1NYbjN6cjdEWE5QYlxuLS0tLS1FTkQgUFJJVkFURSBLRVktLS0tLVxuIiwKICAiY2xpZW50X2VtYWlsIjogInplbi1jb21tdW5pdHktdGVsZW1ldHJ5QG5ldHJhLXRlbGVtZXRyeS1wdWJsaWMuaWFtLmdzZXJ2aWNlYWNjb3VudC5jb20iLAogICJjbGllbnRfaWQiOiAiMTE0NzAwMDA0NzA1MDUxODg5NTY4IiwKICAiYXV0aF91cmkiOiAiaHR0cHM6Ly9hY2NvdW50cy5nb29nbGUuY29tL28vb2F1dGgyL2F1dGgiLAogICJ0b2tlbl91cmkiOiAiaHR0cHM6Ly9vYXV0aDIuZ29vZ2xlYXBpcy5jb20vdG9rZW4iLAogICJhdXRoX3Byb3ZpZGVyX3g1MDlfY2VydF91cmwiOiAiaHR0cHM6Ly93d3cuZ29vZ2xlYXBpcy5jb20vb2F1dGgyL3YxL2NlcnRzIiwKICAiY2xpZW50X3g1MDlfY2VydF91cmwiOiAiaHR0cHM6Ly93d3cuZ29vZ2xlYXBpcy5jb20vcm9ib3QvdjEvbWV0YWRhdGEveDUwOS96ZW4tY29tbXVuaXR5LXRlbGVtZXRyeSU0MG5ldHJhLXRlbGVtZXRyeS1wdWJsaWMuaWFtLmdzZXJ2aWNlYWNjb3VudC5jb20iLAogICJ1bml2ZXJzZV9kb21haW4iOiAiZ29vZ2xlYXBpcy5jb20iCn0K'
|
8
|
+
_CREDENTIALS_DICT = json.loads(
|
9
|
+
base64.b64decode(_EMBEDDED_CREDENTIALS_B64.encode("utf-8"))
|
10
|
+
)
|
11
|
+
|
12
|
+
|
13
|
+
def get_embedded_credentials():
|
14
|
+
"""Return service account credentials."""
|
15
|
+
try:
|
16
|
+
return service_account.Credentials.from_service_account_info(
|
17
|
+
_CREDENTIALS_DICT,
|
18
|
+
scopes=["https://www.googleapis.com/auth/trace.append"],
|
19
|
+
)
|
20
|
+
except Exception:
|
21
|
+
return None
|
22
|
+
|
23
|
+
|
24
|
+
def get_project_id() -> str:
|
25
|
+
"""Return GCP project ID."""
|
26
|
+
return _CREDENTIALS_DICT.get("project_id", 'netra-telemetry-public')
|
@@ -0,0 +1,249 @@
|
|
1
|
+
"""Telemetry manager for Zen orchestrator.
|
2
|
+
|
3
|
+
Provides minimal OpenTelemetry integration that records anonymous spans with
|
4
|
+
token usage and cost metadata. If OpenTelemetry or Google Cloud libraries are
|
5
|
+
missing, the manager silently degrades to a no-op implementation.
|
6
|
+
"""
|
7
|
+
|
8
|
+
from __future__ import annotations
|
9
|
+
|
10
|
+
import hashlib
|
11
|
+
import logging
|
12
|
+
import os
|
13
|
+
import re
|
14
|
+
from dataclasses import asdict
|
15
|
+
from typing import Any, Dict, Optional
|
16
|
+
|
17
|
+
try:
|
18
|
+
from opentelemetry import trace
|
19
|
+
from opentelemetry.sdk.resources import Resource
|
20
|
+
from opentelemetry.sdk.trace import TracerProvider
|
21
|
+
from opentelemetry.sdk.trace.export import BatchSpanProcessor
|
22
|
+
from opentelemetry.trace import SpanKind
|
23
|
+
|
24
|
+
OPENTELEMETRY_AVAILABLE = True
|
25
|
+
except ImportError: # pragma: no cover - optional dependency
|
26
|
+
OPENTELEMETRY_AVAILABLE = False
|
27
|
+
|
28
|
+
try:
|
29
|
+
from google.cloud.trace_v2 import TraceServiceClient
|
30
|
+
from opentelemetry.exporter.cloud_trace import CloudTraceSpanExporter
|
31
|
+
from google.api_core.exceptions import GoogleAPICallError # type: ignore
|
32
|
+
|
33
|
+
GCP_EXPORT_AVAILABLE = True
|
34
|
+
except ImportError: # pragma: no cover - optional dependency
|
35
|
+
GCP_EXPORT_AVAILABLE = False
|
36
|
+
|
37
|
+
class GoogleAPICallError(Exception): # type: ignore
|
38
|
+
"""Fallback exception used when google-api-core is unavailable."""
|
39
|
+
|
40
|
+
pass
|
41
|
+
|
42
|
+
from .embedded_credentials import get_embedded_credentials, get_project_id
|
43
|
+
|
44
|
+
logger = logging.getLogger(__name__)
|
45
|
+
|
46
|
+
|
47
|
+
def _sanitize_tool_name(tool: str) -> str:
|
48
|
+
"""Convert tool names to telemetry-safe attribute suffixes."""
|
49
|
+
safe = re.sub(r"[^a-z0-9_]+", "_", tool.lower()).strip("_")
|
50
|
+
return safe or "tool"
|
51
|
+
|
52
|
+
|
53
|
+
class _NoOpTelemetryManager:
|
54
|
+
"""Fallback manager when telemetry dependencies are unavailable."""
|
55
|
+
|
56
|
+
def is_enabled(self) -> bool:
|
57
|
+
return False
|
58
|
+
|
59
|
+
def record_instance_span(self, *_, **__): # pragma: no cover - trivial
|
60
|
+
return
|
61
|
+
|
62
|
+
def shutdown(self) -> None: # pragma: no cover - trivial
|
63
|
+
return
|
64
|
+
|
65
|
+
|
66
|
+
class TelemetryManager:
|
67
|
+
"""Manage OpenTelemetry setup and span emission for Zen."""
|
68
|
+
|
69
|
+
def __init__(self) -> None:
|
70
|
+
self._enabled = False
|
71
|
+
self._provider: Optional[TracerProvider] = None
|
72
|
+
self._tracer = None
|
73
|
+
self._initialize()
|
74
|
+
|
75
|
+
def _initialize(self) -> None:
|
76
|
+
if os.getenv("ZEN_TELEMETRY_DISABLED", "").lower() in {"1", "true", "yes"}:
|
77
|
+
logger.debug("Telemetry disabled via ZEN_TELEMETRY_DISABLED")
|
78
|
+
return
|
79
|
+
|
80
|
+
if not (OPENTELEMETRY_AVAILABLE and GCP_EXPORT_AVAILABLE):
|
81
|
+
logger.debug("OpenTelemetry or Google Cloud exporter not available; telemetry disabled")
|
82
|
+
return
|
83
|
+
|
84
|
+
credentials = get_embedded_credentials()
|
85
|
+
if credentials is None:
|
86
|
+
logger.debug("No telemetry credentials detected; telemetry disabled")
|
87
|
+
return
|
88
|
+
|
89
|
+
try:
|
90
|
+
project_id = get_project_id()
|
91
|
+
client = TraceServiceClient(credentials=credentials)
|
92
|
+
exporter = CloudTraceSpanExporter(project_id=project_id, client=client)
|
93
|
+
|
94
|
+
resource_attrs = {
|
95
|
+
"service.name": "zen-orchestrator",
|
96
|
+
"service.version": os.getenv("ZEN_VERSION", "1.0.3"),
|
97
|
+
"telemetry.sdk.language": "python",
|
98
|
+
"telemetry.sdk.name": "opentelemetry",
|
99
|
+
"zen.analytics.type": "community",
|
100
|
+
}
|
101
|
+
|
102
|
+
resource = Resource.create(resource_attrs)
|
103
|
+
provider = TracerProvider(resource=resource)
|
104
|
+
provider.add_span_processor(BatchSpanProcessor(exporter))
|
105
|
+
|
106
|
+
trace.set_tracer_provider(provider)
|
107
|
+
self._provider = provider
|
108
|
+
self._tracer = trace.get_tracer("zen.telemetry")
|
109
|
+
self._enabled = True
|
110
|
+
logger.info("Telemetry initialized with community credentials")
|
111
|
+
except Exception as exc: # pragma: no cover - defensive guard
|
112
|
+
logger.warning(f"Failed to initialize telemetry: {exc}")
|
113
|
+
self._enabled = False
|
114
|
+
self._provider = None
|
115
|
+
self._tracer = None
|
116
|
+
|
117
|
+
# Public API -----------------------------------------------------
|
118
|
+
|
119
|
+
def is_enabled(self) -> bool:
|
120
|
+
return self._enabled and self._tracer is not None
|
121
|
+
|
122
|
+
def record_instance_span(
|
123
|
+
self,
|
124
|
+
batch_id: str,
|
125
|
+
instance_name: str,
|
126
|
+
status: Any,
|
127
|
+
config: Any,
|
128
|
+
cost_usd: Optional[float] = None,
|
129
|
+
workspace: Optional[str] = None,
|
130
|
+
) -> None:
|
131
|
+
if not self.is_enabled():
|
132
|
+
return
|
133
|
+
|
134
|
+
assert self._tracer is not None # mypy hint
|
135
|
+
|
136
|
+
attributes: Dict[str, Any] = {
|
137
|
+
"zen.batch.id": batch_id,
|
138
|
+
"zen.instance.name": instance_name,
|
139
|
+
"zen.instance.status": getattr(status, "status", "unknown"),
|
140
|
+
"zen.instance.success": getattr(status, "status", "") == "completed",
|
141
|
+
"zen.instance.permission_mode": getattr(config, "permission_mode", "unknown"),
|
142
|
+
"zen.instance.tool_calls": getattr(status, "tool_calls", 0),
|
143
|
+
"zen.tokens.total": getattr(status, "total_tokens", 0),
|
144
|
+
"zen.tokens.input": getattr(status, "input_tokens", 0),
|
145
|
+
"zen.tokens.output": getattr(status, "output_tokens", 0),
|
146
|
+
"zen.tokens.cache.read": getattr(status, "cache_read_tokens", 0),
|
147
|
+
"zen.tokens.cache.creation": getattr(status, "cache_creation_tokens", 0),
|
148
|
+
"zen.tokens.cached_total": getattr(status, "cached_tokens", 0),
|
149
|
+
}
|
150
|
+
|
151
|
+
start_time = getattr(status, "start_time", None)
|
152
|
+
end_time = getattr(status, "end_time", None)
|
153
|
+
if start_time and end_time:
|
154
|
+
attributes["zen.instance.duration_ms"] = int((end_time - start_time) * 1000)
|
155
|
+
|
156
|
+
command = getattr(config, "command", None) or getattr(config, "prompt", None)
|
157
|
+
if isinstance(command, str) and command.startswith("/"):
|
158
|
+
attributes["zen.instance.command_type"] = "slash"
|
159
|
+
attributes["zen.instance.command"] = command
|
160
|
+
elif isinstance(command, str):
|
161
|
+
attributes["zen.instance.command_type"] = "prompt"
|
162
|
+
else:
|
163
|
+
attributes["zen.instance.command_type"] = "unknown"
|
164
|
+
|
165
|
+
session_id = getattr(config, "session_id", None)
|
166
|
+
if session_id:
|
167
|
+
session_hash = hashlib.sha256(session_id.encode("utf-8")).hexdigest()[:16]
|
168
|
+
attributes["zen.session.hash"] = session_hash
|
169
|
+
|
170
|
+
if workspace:
|
171
|
+
workspace_hash = hashlib.sha256(workspace.encode("utf-8")).hexdigest()[:16]
|
172
|
+
attributes["zen.workspace.hash"] = workspace_hash
|
173
|
+
|
174
|
+
# Tool metadata
|
175
|
+
tool_tokens = getattr(status, "tool_tokens", {}) or {}
|
176
|
+
attributes["zen.tools.unique"] = len(tool_tokens)
|
177
|
+
total_tool_tokens = 0
|
178
|
+
for tool_name, tokens in tool_tokens.items():
|
179
|
+
sanitized = _sanitize_tool_name(tool_name)
|
180
|
+
attributes[f"zen.tools.tokens.{sanitized}"] = int(tokens)
|
181
|
+
total_tool_tokens += int(tokens)
|
182
|
+
attributes["zen.tokens.tools_total"] = total_tool_tokens
|
183
|
+
|
184
|
+
tool_details = getattr(status, "tool_details", {}) or {}
|
185
|
+
for tool_name, count in tool_details.items():
|
186
|
+
sanitized = _sanitize_tool_name(tool_name)
|
187
|
+
attributes[f"zen.tools.invocations.{sanitized}"] = int(count)
|
188
|
+
|
189
|
+
# Cost metadata
|
190
|
+
if cost_usd is not None:
|
191
|
+
attributes["zen.cost.usd_total"] = round(float(cost_usd), 6)
|
192
|
+
|
193
|
+
reported_cost = getattr(status, "total_cost_usd", None)
|
194
|
+
if reported_cost is not None:
|
195
|
+
attributes["zen.cost.usd_reported"] = round(float(reported_cost), 6)
|
196
|
+
|
197
|
+
# Derive cost components using fallback pricing (USD per million tokens)
|
198
|
+
input_tokens = getattr(status, "input_tokens", 0)
|
199
|
+
output_tokens = getattr(status, "output_tokens", 0)
|
200
|
+
cache_read_tokens = getattr(status, "cache_read_tokens", 0)
|
201
|
+
cache_creation_tokens = getattr(status, "cache_creation_tokens", 0)
|
202
|
+
|
203
|
+
input_cost = (input_tokens / 1_000_000) * 3.00
|
204
|
+
output_cost = (output_tokens / 1_000_000) * 15.00
|
205
|
+
cache_read_cost = (cache_read_tokens / 1_000_000) * (3.00 * 0.1)
|
206
|
+
cache_creation_cost = (cache_creation_tokens / 1_000_000) * (3.00 * 1.25)
|
207
|
+
tool_cost = (total_tool_tokens / 1_000_000) * 3.00
|
208
|
+
|
209
|
+
attributes.update(
|
210
|
+
{
|
211
|
+
"zen.cost.usd_input": round(input_cost, 6),
|
212
|
+
"zen.cost.usd_output": round(output_cost, 6),
|
213
|
+
"zen.cost.usd_cache_read": round(cache_read_cost, 6),
|
214
|
+
"zen.cost.usd_cache_creation": round(cache_creation_cost, 6),
|
215
|
+
"zen.cost.usd_tools": round(tool_cost, 6),
|
216
|
+
}
|
217
|
+
)
|
218
|
+
|
219
|
+
# Emit span
|
220
|
+
try:
|
221
|
+
with self._tracer.start_as_current_span(
|
222
|
+
"zen.instance", kind=SpanKind.INTERNAL
|
223
|
+
) as span:
|
224
|
+
for key, value in attributes.items():
|
225
|
+
span.set_attribute(key, value)
|
226
|
+
except GoogleAPICallError as exc: # pragma: no cover - network failure safety
|
227
|
+
logger.warning(f"Failed to export telemetry span: {exc}")
|
228
|
+
|
229
|
+
def shutdown(self) -> None:
|
230
|
+
if not self._provider:
|
231
|
+
return
|
232
|
+
try:
|
233
|
+
if hasattr(self._provider, "force_flush"):
|
234
|
+
self._provider.force_flush()
|
235
|
+
if hasattr(self._provider, "shutdown"):
|
236
|
+
self._provider.shutdown()
|
237
|
+
except Exception as exc: # pragma: no cover
|
238
|
+
logger.debug(f"Telemetry shutdown warning: {exc}")
|
239
|
+
|
240
|
+
|
241
|
+
def _build_manager() -> TelemetryManager | _NoOpTelemetryManager:
|
242
|
+
if not (OPENTELEMETRY_AVAILABLE and GCP_EXPORT_AVAILABLE):
|
243
|
+
return _NoOpTelemetryManager()
|
244
|
+
return TelemetryManager()
|
245
|
+
|
246
|
+
|
247
|
+
telemetry_manager = _build_manager()
|
248
|
+
|
249
|
+
__all__ = ["TelemetryManager", "telemetry_manager"]
|
@@ -2,41 +2,43 @@
|
|
2
2
|
"""
|
3
3
|
Usage Examples:
|
4
4
|
|
5
|
+
zen -h # Help
|
6
|
+
|
5
7
|
Direct Command Execution:
|
6
|
-
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
8
|
+
zen "/single-command-in-claude-commands" # Execute single command directly
|
9
|
+
zen "/analyze-code" --workspace ~/my-project
|
10
|
+
zen "/debug-issue" --instance-name "debug-session"
|
11
|
+
zen "/optimize-performance" --session-id "perf-1"
|
12
|
+
zen "/generate-docs" --clear-history --compact-history
|
11
13
|
|
12
14
|
Configuration File Mode:
|
13
|
-
|
14
|
-
|
15
|
+
zen --config config.json
|
16
|
+
zen --config config.json --workspace ~/my-project
|
15
17
|
|
16
18
|
Default Instances Mode:
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
19
|
+
zen --dry-run # Auto-detects workspace from project root
|
20
|
+
zen --workspace ~/my-project --dry-run # Override workspace
|
21
|
+
zen --startup-delay 2.0 # 2 second delay between launches
|
22
|
+
zen --startup-delay 0.5 # 0.5 second delay between launches
|
23
|
+
zen --max-line-length 1000 # Longer output lines
|
24
|
+
zen --status-report-interval 60 # Status reports every 60s
|
25
|
+
zen --quiet # Minimal output, errors only
|
24
26
|
|
25
27
|
Command Discovery:
|
26
|
-
|
27
|
-
|
28
|
+
zen --list-commands # Show all available commands
|
29
|
+
zen --inspect-command "/analyze-code" # Inspect specific command
|
28
30
|
|
29
31
|
Scheduling:
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
32
|
+
zen "/analyze-code" --start-at "2h" # Start 2 hours from now
|
33
|
+
zen "/debug-issue" --start-at "30m" # Start in 30 minutes
|
34
|
+
zen "/optimize" --start-at "1am" # Start at 1 AM (today or tomorrow)
|
35
|
+
zen "/review-code" --start-at "14:30" # Start at 2:30 PM (today or tomorrow)
|
36
|
+
zen "/generate-docs" --start-at "10:30pm" # Start at 10:30 PM (today or tomorrow)
|
35
37
|
|
36
38
|
Precedence Rules:
|
37
|
-
1. Direct command (highest) -
|
38
|
-
2. Config file (medium) -
|
39
|
-
3. Default instances (lowest) -
|
39
|
+
1. Direct command (highest) - zen "/command"
|
40
|
+
2. Config file (medium) - zen --config file.json # expected default usage pattern
|
41
|
+
3. Default instances (lowest) - zen
|
40
42
|
"""
|
41
43
|
|
42
44
|
import asyncio
|
@@ -58,6 +60,11 @@ import re
|
|
58
60
|
from uuid import uuid4, UUID
|
59
61
|
from enum import Enum
|
60
62
|
|
63
|
+
try:
|
64
|
+
from zen.telemetry import telemetry_manager
|
65
|
+
except Exception: # pragma: no cover - telemetry optional
|
66
|
+
telemetry_manager = None
|
67
|
+
|
61
68
|
# Add token budget imports with proper path handling
|
62
69
|
sys.path.insert(0, str(Path(__file__).parent))
|
63
70
|
try:
|
@@ -77,15 +84,6 @@ except ImportError as e:
|
|
77
84
|
ClaudePricingEngine = None
|
78
85
|
TokenUsageData = None
|
79
86
|
|
80
|
-
# Add CLI extensions imports
|
81
|
-
try:
|
82
|
-
from cli_extensions import handle_example_commands
|
83
|
-
except ImportError as e:
|
84
|
-
# Graceful fallback if CLI extensions are not available
|
85
|
-
handle_example_commands = None
|
86
|
-
|
87
|
-
# NetraOptimizer database functionality has been removed for security
|
88
|
-
# Local token metrics are preserved without database persistence
|
89
87
|
|
90
88
|
# Setup logging
|
91
89
|
logging.basicConfig(
|
@@ -197,6 +195,7 @@ class InstanceStatus:
|
|
197
195
|
tool_details: Dict[str, int] = None # Tool name -> usage count
|
198
196
|
tool_tokens: Dict[str, int] = None # Tool name -> token usage
|
199
197
|
tool_id_mapping: Dict[str, str] = field(default_factory=dict) # tool_use_id -> tool name mapping
|
198
|
+
telemetry_recorded: bool = False
|
200
199
|
|
201
200
|
def __post_init__(self):
|
202
201
|
"""Initialize fields that need special handling"""
|
@@ -214,7 +213,7 @@ class ClaudeInstanceOrchestrator:
|
|
214
213
|
|
215
214
|
def __init__(self, workspace_dir: Path, max_console_lines: int = 5, startup_delay: float = 1.0,
|
216
215
|
max_line_length: int = 500, status_report_interval: int = 30,
|
217
|
-
|
216
|
+
quiet: bool = False,
|
218
217
|
overall_token_budget: Optional[int] = None,
|
219
218
|
overall_cost_budget: Optional[float] = None,
|
220
219
|
budget_type: str = "tokens",
|
@@ -233,11 +232,10 @@ class ClaudeInstanceOrchestrator:
|
|
233
232
|
self.status_report_interval = status_report_interval # Seconds between status reports
|
234
233
|
self.last_status_report = time.time()
|
235
234
|
self.status_report_task = None # For the rolling status report task
|
236
|
-
self.use_cloud_sql = use_cloud_sql
|
237
235
|
self.quiet = quiet
|
238
236
|
self.log_level = log_level
|
239
237
|
self.batch_id = str(uuid4()) # Generate batch ID for this orchestration run
|
240
|
-
|
238
|
+
|
241
239
|
self.optimizer = None
|
242
240
|
|
243
241
|
# Initialize budget manager if any budget settings are provided
|
@@ -278,12 +276,6 @@ class ClaudeInstanceOrchestrator:
|
|
278
276
|
else:
|
279
277
|
logger.debug("Token budget tracking disabled (no budget specified)")
|
280
278
|
|
281
|
-
# Configure CloudSQL if requested
|
282
|
-
# CloudSQL functionality available with Netra Apex
|
283
|
-
# Token metrics are now handled locally without database persistence
|
284
|
-
if use_cloud_sql:
|
285
|
-
logger.warning("CloudSQL functionality has been disabled. Token metrics will be displayed locally only.")
|
286
|
-
logger.info("For data persistence, consider upgrading to Netra Apex.")
|
287
279
|
|
288
280
|
def log_at_level(self, level: LogLevel, message: str, log_func=None):
|
289
281
|
"""Log message only if current log level permits."""
|
@@ -338,36 +330,36 @@ class ClaudeInstanceOrchestrator:
|
|
338
330
|
command_string = "; ".join(full_command)
|
339
331
|
|
340
332
|
# Find the claude executable with Mac-specific paths
|
341
|
-
|
333
|
+
# IMPORTANT: Use direct paths to avoid shell functions that may have database dependencies
|
334
|
+
possible_paths = [
|
335
|
+
"/opt/homebrew/bin/claude", # Mac Homebrew ARM - prefer direct path
|
336
|
+
"/usr/local/bin/claude", # Mac Homebrew Intel
|
337
|
+
"~/.local/bin/claude", # User local install
|
338
|
+
"/usr/bin/claude", # System install
|
339
|
+
"claude.cmd", # Windows
|
340
|
+
"claude.exe", # Windows
|
341
|
+
]
|
342
|
+
|
343
|
+
claude_cmd = None
|
344
|
+
for path in possible_paths:
|
345
|
+
# Expand user path if needed
|
346
|
+
expanded_path = Path(path).expanduser()
|
347
|
+
if expanded_path.exists() and expanded_path.is_file():
|
348
|
+
claude_cmd = str(expanded_path)
|
349
|
+
logger.info(f"Found Claude executable at: {claude_cmd}")
|
350
|
+
break
|
351
|
+
|
352
|
+
# Only use shutil.which as fallback if no direct path found
|
342
353
|
if not claude_cmd:
|
343
|
-
|
344
|
-
|
345
|
-
|
346
|
-
"claude.exe", # Windows
|
347
|
-
"/opt/homebrew/bin/claude", # Mac Homebrew ARM
|
348
|
-
"/usr/local/bin/claude", # Mac Homebrew Intel
|
349
|
-
"~/.local/bin/claude", # User local install
|
350
|
-
"/usr/bin/claude", # System install
|
351
|
-
"claude" # Final fallback
|
352
|
-
]
|
353
|
-
|
354
|
-
for path in possible_paths:
|
355
|
-
# Expand user path if needed
|
356
|
-
expanded_path = Path(path).expanduser()
|
357
|
-
if expanded_path.exists():
|
358
|
-
claude_cmd = str(expanded_path)
|
359
|
-
logger.info(f"Found Claude executable at: {claude_cmd}")
|
360
|
-
break
|
361
|
-
elif shutil.which(path):
|
362
|
-
claude_cmd = path
|
363
|
-
logger.info(f"Found Claude executable via which: {claude_cmd}")
|
364
|
-
break
|
354
|
+
claude_cmd = shutil.which("claude")
|
355
|
+
if claude_cmd:
|
356
|
+
logger.info(f"Found Claude executable via which: {claude_cmd}")
|
365
357
|
|
366
|
-
|
367
|
-
|
368
|
-
|
369
|
-
|
370
|
-
|
358
|
+
if not claude_cmd:
|
359
|
+
logger.warning("Claude command not found in PATH or common locations")
|
360
|
+
logger.warning("Please ensure Claude Code is installed and in your PATH")
|
361
|
+
logger.warning("Install with: npm install -g @anthropic/claude-code")
|
362
|
+
claude_cmd = "/opt/homebrew/bin/claude" # Default fallback to most likely location
|
371
363
|
|
372
364
|
# New approach: slash commands can be included directly in prompt
|
373
365
|
cmd = [
|
@@ -494,6 +486,11 @@ class ClaudeInstanceOrchestrator:
|
|
494
486
|
logger.error(f"🚫 BLOCK MODE: {message}")
|
495
487
|
status.status = "failed"
|
496
488
|
status.error = f"Blocked by budget limit - {reason}"
|
489
|
+
timestamp = time.time()
|
490
|
+
if status.start_time is None:
|
491
|
+
status.start_time = timestamp
|
492
|
+
status.end_time = timestamp
|
493
|
+
self._emit_instance_telemetry(name, config, status)
|
497
494
|
return False
|
498
495
|
else: # warn mode
|
499
496
|
logger.warning(f"⚠️ WARN MODE: {message}")
|
@@ -571,18 +568,22 @@ class ClaudeInstanceOrchestrator:
|
|
571
568
|
if returncode == 0:
|
572
569
|
status.status = "completed"
|
573
570
|
logger.info(f"Instance {name} completed successfully")
|
571
|
+
self._emit_instance_telemetry(name, config, status)
|
574
572
|
return True
|
575
573
|
else:
|
576
574
|
status.status = "failed"
|
577
575
|
logger.error(f"Instance {name} failed with return code {returncode}")
|
578
576
|
if status.error:
|
579
577
|
logger.error(f"Error output: {status.error}")
|
578
|
+
self._emit_instance_telemetry(name, config, status)
|
580
579
|
return False
|
581
580
|
|
582
581
|
except Exception as e:
|
583
582
|
status.status = "failed"
|
584
583
|
status.error = str(e)
|
585
584
|
logger.error(f"Exception running instance {name}: {e}")
|
585
|
+
status.end_time = status.end_time or time.time()
|
586
|
+
self._emit_instance_telemetry(name, config, status)
|
586
587
|
return False
|
587
588
|
|
588
589
|
async def _save_metrics_to_database(self, name: str, config: InstanceConfig, status: InstanceStatus):
|
@@ -635,6 +636,38 @@ class ClaudeInstanceOrchestrator:
|
|
635
636
|
|
636
637
|
return input_cost + output_cost + cache_read_cost + cache_creation_cost + tool_cost
|
637
638
|
|
639
|
+
def _emit_instance_telemetry(self, name: str, config: InstanceConfig, status: InstanceStatus) -> None:
|
640
|
+
"""Send telemetry span with token usage and cost metadata."""
|
641
|
+
|
642
|
+
if telemetry_manager is None or not hasattr(telemetry_manager, "is_enabled"):
|
643
|
+
return
|
644
|
+
|
645
|
+
if getattr(status, "telemetry_recorded", False):
|
646
|
+
return
|
647
|
+
|
648
|
+
if not telemetry_manager.is_enabled():
|
649
|
+
return
|
650
|
+
|
651
|
+
cost_usd: Optional[float]
|
652
|
+
try:
|
653
|
+
cost_usd = self._calculate_cost(status)
|
654
|
+
except Exception as exc: # pragma: no cover - defensive guard
|
655
|
+
logger.debug(f"Cost calculation failed for telemetry span ({name}): {exc}")
|
656
|
+
cost_usd = None
|
657
|
+
|
658
|
+
try:
|
659
|
+
telemetry_manager.record_instance_span(
|
660
|
+
batch_id=self.batch_id,
|
661
|
+
instance_name=name,
|
662
|
+
status=status,
|
663
|
+
config=config,
|
664
|
+
cost_usd=cost_usd,
|
665
|
+
workspace=str(self.workspace_dir),
|
666
|
+
)
|
667
|
+
status.telemetry_recorded = True
|
668
|
+
except Exception as exc: # pragma: no cover - Network/export errors
|
669
|
+
logger.debug(f"Telemetry emission failed for {name}: {exc}")
|
670
|
+
|
638
671
|
async def _stream_output(self, name: str, process):
|
639
672
|
"""Stream output in real-time for stream-json format (DEPRECATED - use _stream_output_parallel)"""
|
640
673
|
status = self.statuses[name]
|
@@ -810,13 +843,19 @@ class ClaudeInstanceOrchestrator:
|
|
810
843
|
for name, result in zip(self.instances.keys(), results):
|
811
844
|
if isinstance(result, asyncio.TimeoutError):
|
812
845
|
logger.error(f"Instance {name} timed out after {timeout}s")
|
813
|
-
self.statuses[name]
|
814
|
-
|
846
|
+
status = self.statuses[name]
|
847
|
+
status.status = "failed"
|
848
|
+
status.error = f"Timeout after {timeout}s"
|
849
|
+
status.end_time = time.time()
|
850
|
+
self._emit_instance_telemetry(name, self.instances[name], status)
|
815
851
|
final_results[name] = False
|
816
852
|
elif isinstance(result, Exception):
|
817
853
|
logger.error(f"Instance {name} failed with exception: {result}")
|
818
|
-
self.statuses[name]
|
819
|
-
|
854
|
+
status = self.statuses[name]
|
855
|
+
status.status = "failed"
|
856
|
+
status.error = str(result)
|
857
|
+
status.end_time = time.time()
|
858
|
+
self._emit_instance_telemetry(name, self.instances[name], status)
|
820
859
|
final_results[name] = False
|
821
860
|
else:
|
822
861
|
final_results[name] = result
|
@@ -2370,8 +2409,6 @@ async def main():
|
|
2370
2409
|
help="Seconds between rolling status reports (default: 5)")
|
2371
2410
|
parser.add_argument("--start-at", type=str, default=None,
|
2372
2411
|
help="Schedule orchestration to start at specific time. Examples: '2h' (2 hours from now), '30m' (30 minutes), '14:30' (2:30 PM today), '1am' (1 AM today/tomorrow)")
|
2373
|
-
parser.add_argument("--use-cloud-sql", action="store_true",
|
2374
|
-
help="Save metrics to CloudSQL database (NetraOptimizer integration)")
|
2375
2412
|
|
2376
2413
|
# Direct command options
|
2377
2414
|
parser.add_argument("--instance-name", type=str, help="Instance name for direct command execution")
|
@@ -2454,10 +2491,6 @@ async def main():
|
|
2454
2491
|
|
2455
2492
|
logger.info(f"Using workspace: {workspace}")
|
2456
2493
|
|
2457
|
-
# Handle CLI extension commands
|
2458
|
-
if handle_example_commands and handle_example_commands(args):
|
2459
|
-
return
|
2460
|
-
|
2461
2494
|
|
2462
2495
|
# Load instance configurations with direct command precedence
|
2463
2496
|
direct_instance = create_direct_instance(args, workspace)
|
@@ -2547,7 +2580,6 @@ async def main():
|
|
2547
2580
|
startup_delay=args.startup_delay,
|
2548
2581
|
max_line_length=args.max_line_length,
|
2549
2582
|
status_report_interval=args.status_report_interval,
|
2550
|
-
use_cloud_sql=args.use_cloud_sql,
|
2551
2583
|
quiet=args.quiet,
|
2552
2584
|
overall_token_budget=final_overall_budget,
|
2553
2585
|
overall_cost_budget=final_overall_cost_budget,
|
@@ -2775,9 +2807,6 @@ async def main():
|
|
2775
2807
|
|
2776
2808
|
# Run all instances
|
2777
2809
|
logger.info("Starting Claude Code instance orchestration")
|
2778
|
-
if args.use_cloud_sql:
|
2779
|
-
logger.info(f"Batch ID: {orchestrator.batch_id}")
|
2780
|
-
logger.info("Metrics will be saved to CloudSQL")
|
2781
2810
|
start_time = time.time()
|
2782
2811
|
|
2783
2812
|
results = await orchestrator.run_all_instances(args.timeout)
|
@@ -2909,19 +2938,17 @@ async def main():
|
|
2909
2938
|
|
2910
2939
|
# For detailed data access
|
2911
2940
|
print("\n" + "="*80)
|
2912
|
-
print("🚀
|
2941
|
+
print("🚀 Looking for more?")
|
2913
2942
|
print("="*80)
|
2914
|
-
print("
|
2915
|
-
print("and advanced reporting features, upgrade to Netra Apex.")
|
2943
|
+
print("Explore Zen with Apex for the most effective AI Ops value for production AI.")
|
2916
2944
|
print("")
|
2917
2945
|
print("🌐 Learn more: https://netrasystems.ai/")
|
2918
2946
|
print("="*80)
|
2919
2947
|
|
2920
|
-
|
2921
|
-
|
2922
|
-
|
2923
|
-
|
2924
|
-
print(f" Database persistence disabled for security")
|
2948
|
+
|
2949
|
+
# Flush telemetry before exit
|
2950
|
+
if telemetry_manager is not None and hasattr(telemetry_manager, "shutdown"):
|
2951
|
+
telemetry_manager.shutdown()
|
2925
2952
|
|
2926
2953
|
# Exit with appropriate code
|
2927
2954
|
sys.exit(0 if summary['failed'] == 0 else 1)
|
@@ -2931,4 +2958,4 @@ def run():
|
|
2931
2958
|
asyncio.run(main())
|
2932
2959
|
|
2933
2960
|
if __name__ == "__main__":
|
2934
|
-
run()
|
2961
|
+
run()
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|