nat-engine 1__py3-none-any.whl
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.
- mannf/__init__.py +33 -0
- mannf/__main__.py +10 -0
- mannf/_version.py +8 -0
- mannf/agents/__init__.py +7 -0
- mannf/agents/analyzer_agent.py +9 -0
- mannf/agents/base.py +9 -0
- mannf/agents/bdi_agent.py +9 -0
- mannf/agents/belief_state.py +9 -0
- mannf/agents/coordinator_agent.py +9 -0
- mannf/agents/executor_agent.py +9 -0
- mannf/agents/monitor_agent.py +9 -0
- mannf/agents/oracle_agent.py +9 -0
- mannf/agents/planner_agent.py +9 -0
- mannf/agents/test_agent.py +9 -0
- mannf/anomaly/__init__.py +7 -0
- mannf/anomaly/enhanced_detector.py +9 -0
- mannf/cli.py +9 -0
- mannf/core/__init__.py +26 -0
- mannf/core/agents/__init__.py +52 -0
- mannf/core/agents/accessibility_scanner_agent.py +245 -0
- mannf/core/agents/analyzer_agent.py +224 -0
- mannf/core/agents/autonomous_loop_agent.py +1086 -0
- mannf/core/agents/autonomous_loop_models.py +62 -0
- mannf/core/agents/autonomous_run_differ.py +427 -0
- mannf/core/agents/base.py +128 -0
- mannf/core/agents/bdi_agent.py +330 -0
- mannf/core/agents/belief_state.py +202 -0
- mannf/core/agents/browser_coordinator_agent.py +224 -0
- mannf/core/agents/browser_executor_agent.py +410 -0
- mannf/core/agents/coordinator_agent.py +262 -0
- mannf/core/agents/executor_agent.py +222 -0
- mannf/core/agents/monitor_agent.py +188 -0
- mannf/core/agents/oracle_agent.py +150 -0
- mannf/core/agents/performance_testing_agent.py +279 -0
- mannf/core/agents/planner_agent.py +128 -0
- mannf/core/agents/test_agent.py +249 -0
- mannf/core/agents/visual_regression_agent.py +311 -0
- mannf/core/agents/web_crawler_agent.py +510 -0
- mannf/core/agents/worker_pool.py +366 -0
- mannf/core/anomaly/__init__.py +14 -0
- mannf/core/anomaly/enhanced_detector.py +541 -0
- mannf/core/browser/__init__.py +63 -0
- mannf/core/browser/accessibility_scanner.py +424 -0
- mannf/core/browser/discovery_model.py +178 -0
- mannf/core/browser/dom_snapshot.py +349 -0
- mannf/core/browser/ingestor_bridge.py +371 -0
- mannf/core/browser/performance_metrics.py +217 -0
- mannf/core/browser/reflection_analyzer.py +442 -0
- mannf/core/browser/scenario_generator.py +1100 -0
- mannf/core/browser/security_scenario_generator.py +695 -0
- mannf/core/browser/visual_comparer.py +159 -0
- mannf/core/diagnostics/__init__.py +28 -0
- mannf/core/diagnostics/failure_clusterer.py +211 -0
- mannf/core/diagnostics/flake_detector.py +233 -0
- mannf/core/diagnostics/root_cause_analyzer.py +273 -0
- mannf/core/distributed/__init__.py +16 -0
- mannf/core/distributed/endpoint.py +139 -0
- mannf/core/distributed/system_under_test.py +207 -0
- mannf/core/functional_orchestrator.py +428 -0
- mannf/core/messaging/__init__.py +11 -0
- mannf/core/messaging/bus.py +113 -0
- mannf/core/messaging/messages.py +89 -0
- mannf/core/nat_orchestrator.py +342 -0
- mannf/core/neural/__init__.py +183 -0
- mannf/core/orchestrator.py +272 -0
- mannf/core/prioritization/__init__.py +17 -0
- mannf/core/prioritization/adaptive_controller.py +509 -0
- mannf/core/prioritization/belief_prioritizer.py +231 -0
- mannf/core/prioritization/risk_scorer.py +430 -0
- mannf/core/reporting/__init__.py +12 -0
- mannf/core/reporting/unified_report.py +664 -0
- mannf/core/testing/__init__.py +17 -0
- mannf/core/testing/adaptive_controller.py +149 -0
- mannf/core/testing/models.py +179 -0
- mannf/core/validation/__init__.py +10 -0
- mannf/core/validation/self_validation_runner.py +180 -0
- mannf/dashboard/__init__.py +7 -0
- mannf/dashboard/app.py +9 -0
- mannf/dashboard/models.py +9 -0
- mannf/dashboard/static/index.html +2538 -0
- mannf/dashboard/telemetry.py +9 -0
- mannf/distributed/__init__.py +7 -0
- mannf/distributed/endpoint.py +9 -0
- mannf/distributed/system_under_test.py +9 -0
- mannf/healing/__init__.py +7 -0
- mannf/healing/graphql_schema_diff.py +9 -0
- mannf/healing/healer.py +9 -0
- mannf/healing/models.py +9 -0
- mannf/healing/schema_diff.py +9 -0
- mannf/integrations/__init__.py +7 -0
- mannf/integrations/auth.py +9 -0
- mannf/integrations/graphql_parser.py +9 -0
- mannf/integrations/graphql_sut.py +9 -0
- mannf/integrations/http_sut.py +9 -0
- mannf/integrations/openapi_parser.py +9 -0
- mannf/integrations/postman_parser.py +9 -0
- mannf/llm/__init__.py +7 -0
- mannf/llm/anthropic_provider.py +9 -0
- mannf/llm/base.py +9 -0
- mannf/llm/config.py +9 -0
- mannf/llm/factory.py +9 -0
- mannf/llm/openai_provider.py +9 -0
- mannf/llm/prompts.py +9 -0
- mannf/messaging/__init__.py +7 -0
- mannf/messaging/bus.py +9 -0
- mannf/messaging/messages.py +9 -0
- mannf/nat_orchestrator.py +9 -0
- mannf/neural/__init__.py +7 -0
- mannf/orchestrator.py +9 -0
- mannf/prioritization/__init__.py +7 -0
- mannf/prioritization/adaptive_controller.py +9 -0
- mannf/prioritization/belief_prioritizer.py +9 -0
- mannf/prioritization/risk_scorer.py +9 -0
- mannf/product/__init__.py +29 -0
- mannf/product/admin/__init__.py +3 -0
- mannf/product/admin/routes.py +514 -0
- mannf/product/auth/__init__.py +5 -0
- mannf/product/auth/saml.py +212 -0
- mannf/product/billing/__init__.py +5 -0
- mannf/product/billing/audit.py +160 -0
- mannf/product/billing/feature_gates.py +180 -0
- mannf/product/billing/metering.py +179 -0
- mannf/product/billing/notifications.py +181 -0
- mannf/product/billing/plans.py +133 -0
- mannf/product/billing/rate_limits.py +35 -0
- mannf/product/billing/stripe_billing.py +906 -0
- mannf/product/billing/tenant_auth.py +233 -0
- mannf/product/billing/tenant_manager.py +873 -0
- mannf/product/cli.py +3900 -0
- mannf/product/cli_admin.py +408 -0
- mannf/product/dashboard/__init__.py +61 -0
- mannf/product/dashboard/app.py +3567 -0
- mannf/product/dashboard/models.py +460 -0
- mannf/product/dashboard/static/index.html +6347 -0
- mannf/product/dashboard/static/manifest.json +25 -0
- mannf/product/dashboard/static/pwa-icon-192.png +0 -0
- mannf/product/dashboard/static/pwa-icon-512.png +0 -0
- mannf/product/dashboard/static/sw.js +64 -0
- mannf/product/dashboard/telemetry.py +547 -0
- mannf/product/database.py +145 -0
- mannf/product/demo.py +844 -0
- mannf/product/doctor.py +509 -0
- mannf/product/exporters/__init__.py +65 -0
- mannf/product/exporters/azuredevops_exporter.py +257 -0
- mannf/product/exporters/base.py +307 -0
- mannf/product/exporters/bugzilla_exporter.py +200 -0
- mannf/product/exporters/dedup.py +275 -0
- mannf/product/exporters/finding_adapter.py +216 -0
- mannf/product/exporters/github_exporter.py +197 -0
- mannf/product/exporters/gitlab_exporter.py +215 -0
- mannf/product/exporters/jira_exporter.py +180 -0
- mannf/product/exporters/linear_exporter.py +195 -0
- mannf/product/exporters/loader.py +233 -0
- mannf/product/exporters/pagerduty_exporter.py +363 -0
- mannf/product/exporters/sentry_exporter.py +322 -0
- mannf/product/exporters/servicenow_exporter.py +240 -0
- mannf/product/exporters/shortcut_exporter.py +231 -0
- mannf/product/exporters/webhook_exporter.py +383 -0
- mannf/product/formatters/__init__.py +18 -0
- mannf/product/formatters/allure_formatter.py +161 -0
- mannf/product/formatters/ctrf_formatter.py +149 -0
- mannf/product/healing/__init__.py +30 -0
- mannf/product/healing/graphql_schema_diff.py +152 -0
- mannf/product/healing/healer.py +141 -0
- mannf/product/healing/models.py +175 -0
- mannf/product/healing/schema_diff.py +251 -0
- mannf/product/ingestors/__init__.py +77 -0
- mannf/product/ingestors/base.py +256 -0
- mannf/product/ingestors/bgstm_ingestor.py +764 -0
- mannf/product/ingestors/curl_ingestor.py +1019 -0
- mannf/product/ingestors/cypress_ingestor.py +487 -0
- mannf/product/ingestors/gherkin_ingestor.py +967 -0
- mannf/product/ingestors/graphql_ingestor.py +845 -0
- mannf/product/ingestors/grpc_ingestor.py +591 -0
- mannf/product/ingestors/har_ingestor.py +976 -0
- mannf/product/ingestors/loader.py +284 -0
- mannf/product/ingestors/models.py +146 -0
- mannf/product/ingestors/openapi_ingestor.py +606 -0
- mannf/product/ingestors/playwright_ingestor.py +449 -0
- mannf/product/ingestors/postman_ingestor.py +631 -0
- mannf/product/ingestors/traffic_ingestor.py +679 -0
- mannf/product/ingestors/websocket_ingestor.py +526 -0
- mannf/product/integrations/__init__.py +21 -0
- mannf/product/integrations/auth.py +190 -0
- mannf/product/integrations/graphql_parser.py +436 -0
- mannf/product/integrations/graphql_sut.py +247 -0
- mannf/product/integrations/grpc_sut.py +469 -0
- mannf/product/integrations/http_sut.py +237 -0
- mannf/product/integrations/kafka_adapter.py +342 -0
- mannf/product/integrations/openapi_parser.py +513 -0
- mannf/product/integrations/postman_parser.py +467 -0
- mannf/product/integrations/webhook_receiver.py +344 -0
- mannf/product/integrations/websocket_sut.py +434 -0
- mannf/product/llm/__init__.py +25 -0
- mannf/product/llm/anthropic_provider.py +94 -0
- mannf/product/llm/base.py +267 -0
- mannf/product/llm/config.py +48 -0
- mannf/product/llm/factory.py +42 -0
- mannf/product/llm/openai_provider.py +93 -0
- mannf/product/llm/prompts.py +403 -0
- mannf/product/llm/root_cause_service.py +311 -0
- mannf/product/llm/test_plan_models.py +78 -0
- mannf/product/metrics.py +149 -0
- mannf/product/middleware/__init__.py +3 -0
- mannf/product/middleware/audit_middleware.py +112 -0
- mannf/product/middleware/tenant_isolation.py +114 -0
- mannf/product/models.py +347 -0
- mannf/product/notifications/__init__.py +24 -0
- mannf/product/notifications/dispatcher.py +411 -0
- mannf/product/onboarding.py +190 -0
- mannf/product/orchestration/__init__.py +39 -0
- mannf/product/orchestration/ingest_scan_orchestrator.py +339 -0
- mannf/product/orchestration/pipeline.py +401 -0
- mannf/product/orchestrator.py +987 -0
- mannf/product/orchestrator_models.py +269 -0
- mannf/product/regression/__init__.py +36 -0
- mannf/product/regression/differ.py +172 -0
- mannf/product/regression/masking.py +100 -0
- mannf/product/regression/models.py +232 -0
- mannf/product/regression/recorder.py +124 -0
- mannf/product/regression/replayer.py +168 -0
- mannf/product/reports/__init__.py +10 -0
- mannf/product/reports/pdf.py +132 -0
- mannf/product/scheduling/__init__.py +57 -0
- mannf/product/scheduling/cron_utils.py +251 -0
- mannf/product/scheduling/engine.py +473 -0
- mannf/product/scheduling/models.py +86 -0
- mannf/product/scheduling/queue.py +894 -0
- mannf/product/scheduling/store.py +235 -0
- mannf/product/security/__init__.py +21 -0
- mannf/product/security/belief_guided.py +143 -0
- mannf/product/security/checks/__init__.py +55 -0
- mannf/product/security/checks/base.py +69 -0
- mannf/product/security/checks/bfla.py +77 -0
- mannf/product/security/checks/bola.py +77 -0
- mannf/product/security/checks/bopla.py +80 -0
- mannf/product/security/checks/broken_auth.py +86 -0
- mannf/product/security/checks/graphql_security.py +299 -0
- mannf/product/security/checks/inventory.py +70 -0
- mannf/product/security/checks/misconfig.py +158 -0
- mannf/product/security/checks/resource_consumption.py +70 -0
- mannf/product/security/checks/sensitive_flows.py +80 -0
- mannf/product/security/checks/ssrf.py +101 -0
- mannf/product/security/checks/unsafe_consumption.py +120 -0
- mannf/product/security/models.py +92 -0
- mannf/product/security/plugin_loader.py +182 -0
- mannf/product/security/reporter.py +92 -0
- mannf/product/security/scanner.py +183 -0
- mannf/product/server.py +6220 -0
- mannf/product/setup_wizard.py +873 -0
- mannf/product/status.py +404 -0
- mannf/product/storage/__init__.py +10 -0
- mannf/product/storage/artifact_store.py +343 -0
- mannf/product/telemetry.py +300 -0
- mannf/product/uninstall.py +169 -0
- mannf/product/upgrade.py +139 -0
- mannf/product/weights/__init__.py +13 -0
- mannf/product/weights/blob_store.py +299 -0
- mannf/product/weights/factory.py +42 -0
- mannf/product/weights/registry.py +159 -0
- mannf/product/weights/store.py +210 -0
- mannf/regression/__init__.py +7 -0
- mannf/regression/differ.py +9 -0
- mannf/regression/masking.py +9 -0
- mannf/regression/models.py +9 -0
- mannf/regression/recorder.py +9 -0
- mannf/regression/replayer.py +9 -0
- mannf/security/__init__.py +7 -0
- mannf/security/belief_guided.py +9 -0
- mannf/security/checks/__init__.py +7 -0
- mannf/security/checks/base.py +9 -0
- mannf/security/checks/bfla.py +9 -0
- mannf/security/checks/bola.py +9 -0
- mannf/security/checks/bopla.py +9 -0
- mannf/security/checks/broken_auth.py +9 -0
- mannf/security/checks/graphql_security.py +9 -0
- mannf/security/checks/inventory.py +9 -0
- mannf/security/checks/misconfig.py +9 -0
- mannf/security/checks/resource_consumption.py +9 -0
- mannf/security/checks/sensitive_flows.py +9 -0
- mannf/security/checks/ssrf.py +9 -0
- mannf/security/checks/unsafe_consumption.py +9 -0
- mannf/security/models.py +9 -0
- mannf/security/reporter.py +9 -0
- mannf/security/scanner.py +9 -0
- mannf/server.py +9 -0
- mannf/testing/__init__.py +7 -0
- mannf/testing/adaptive_controller.py +9 -0
- mannf/testing/models.py +9 -0
- mannf/weights/__init__.py +7 -0
- mannf/weights/registry.py +9 -0
- mannf/weights/store.py +9 -0
- nat_engine-1.dist-info/METADATA +555 -0
- nat_engine-1.dist-info/RECORD +299 -0
- nat_engine-1.dist-info/WHEEL +5 -0
- nat_engine-1.dist-info/entry_points.txt +4 -0
- nat_engine-1.dist-info/licenses/LICENSE +651 -0
- nat_engine-1.dist-info/licenses/NOTICE +178 -0
- nat_engine-1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
# Copyright (C) 2026 Brad Guider
|
|
2
|
+
# This file is part of NAT (Neural Agent Testing Framework).
|
|
3
|
+
# Licensed under the AGPL-3.0. See LICENSE for details.
|
|
4
|
+
# Commercial licensing available — see COMMERCIAL_LICENSE.md.
|
|
5
|
+
|
|
6
|
+
"""Azure Blob Storage backend for scan artifacts.
|
|
7
|
+
|
|
8
|
+
Blobs are organized as::
|
|
9
|
+
|
|
10
|
+
scans/{scan_id}/results.json
|
|
11
|
+
scans/{scan_id}/spec.yaml
|
|
12
|
+
scans/{scan_id}/<filename>
|
|
13
|
+
|
|
14
|
+
When Azure Blob Storage is not configured (neither
|
|
15
|
+
``AZURE_STORAGE_CONNECTION_STRING`` nor ``AZURE_STORAGE_ACCOUNT_NAME`` is
|
|
16
|
+
set), all methods become no-ops that return ``None`` so the rest of the
|
|
17
|
+
application is unaffected.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import json
|
|
23
|
+
import logging
|
|
24
|
+
import os
|
|
25
|
+
from typing import Any, Dict, Optional
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
_SCANS_CONTAINER = "nat-scans"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ArtifactStore:
|
|
33
|
+
"""Store scan artifacts in Azure Blob Storage.
|
|
34
|
+
|
|
35
|
+
Blobs are organized as: ``scans/{scan_id}/results.json``,
|
|
36
|
+
``scans/{scan_id}/spec.yaml``, etc.
|
|
37
|
+
|
|
38
|
+
When Azure Blob Storage is not configured, all methods return ``None``
|
|
39
|
+
without raising exceptions so that the application operates normally in
|
|
40
|
+
local / development mode.
|
|
41
|
+
|
|
42
|
+
Parameters
|
|
43
|
+
----------
|
|
44
|
+
container_name:
|
|
45
|
+
Name of the Azure Blob Storage container. Defaults to
|
|
46
|
+
``"nat-scans"``.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
def __init__(self, container_name: str = _SCANS_CONTAINER) -> None:
|
|
50
|
+
self._container_name = container_name
|
|
51
|
+
self._client: Any = None
|
|
52
|
+
if self.is_configured():
|
|
53
|
+
self._client = self._build_client()
|
|
54
|
+
|
|
55
|
+
# ------------------------------------------------------------------
|
|
56
|
+
# Public async API
|
|
57
|
+
# ------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
async def store_scan_results(self, scan_id: str, results: Dict[str, Any]) -> Optional[str]:
|
|
60
|
+
"""Store scan results blob.
|
|
61
|
+
|
|
62
|
+
Parameters
|
|
63
|
+
----------
|
|
64
|
+
scan_id:
|
|
65
|
+
Unique scan identifier. Used as part of the blob path.
|
|
66
|
+
results:
|
|
67
|
+
Scan results dict to serialise as JSON.
|
|
68
|
+
|
|
69
|
+
Returns
|
|
70
|
+
-------
|
|
71
|
+
str | None
|
|
72
|
+
The URL of the uploaded blob, or ``None`` if Azure is not
|
|
73
|
+
configured.
|
|
74
|
+
"""
|
|
75
|
+
if not self._client:
|
|
76
|
+
return None
|
|
77
|
+
|
|
78
|
+
blob_path = f"scans/{scan_id}/results.json"
|
|
79
|
+
data = json.dumps(results, indent=2, default=str).encode("utf-8")
|
|
80
|
+
return await self._upload(blob_path, data, content_type="application/json")
|
|
81
|
+
|
|
82
|
+
async def retrieve_scan_results(self, scan_id: str) -> Optional[Dict[str, Any]]:
|
|
83
|
+
"""Retrieve scan results from blob storage.
|
|
84
|
+
|
|
85
|
+
Parameters
|
|
86
|
+
----------
|
|
87
|
+
scan_id:
|
|
88
|
+
Unique scan identifier.
|
|
89
|
+
|
|
90
|
+
Returns
|
|
91
|
+
-------
|
|
92
|
+
dict | None
|
|
93
|
+
Parsed scan results, or ``None`` if Azure is not configured or
|
|
94
|
+
the blob does not exist.
|
|
95
|
+
"""
|
|
96
|
+
if not self._client:
|
|
97
|
+
return None
|
|
98
|
+
|
|
99
|
+
blob_path = f"scans/{scan_id}/results.json"
|
|
100
|
+
raw = await self._download(blob_path)
|
|
101
|
+
if raw is None:
|
|
102
|
+
return None
|
|
103
|
+
try:
|
|
104
|
+
return json.loads(raw)
|
|
105
|
+
except json.JSONDecodeError:
|
|
106
|
+
logger.warning("ArtifactStore: corrupt results blob for scan %s", scan_id)
|
|
107
|
+
return None
|
|
108
|
+
|
|
109
|
+
async def store_spec(
|
|
110
|
+
self,
|
|
111
|
+
scan_id: str,
|
|
112
|
+
spec_content: bytes,
|
|
113
|
+
filename: str,
|
|
114
|
+
) -> Optional[str]:
|
|
115
|
+
"""Store the uploaded spec file blob.
|
|
116
|
+
|
|
117
|
+
Parameters
|
|
118
|
+
----------
|
|
119
|
+
scan_id:
|
|
120
|
+
Unique scan identifier.
|
|
121
|
+
spec_content:
|
|
122
|
+
Raw bytes of the spec file.
|
|
123
|
+
filename:
|
|
124
|
+
Original filename (used as the blob name suffix, e.g.
|
|
125
|
+
``"openapi.yaml"``).
|
|
126
|
+
|
|
127
|
+
Returns
|
|
128
|
+
-------
|
|
129
|
+
str | None
|
|
130
|
+
The URL of the uploaded blob, or ``None`` if Azure is not
|
|
131
|
+
configured.
|
|
132
|
+
"""
|
|
133
|
+
if not self._client:
|
|
134
|
+
return None
|
|
135
|
+
|
|
136
|
+
blob_path = f"scans/{scan_id}/{filename}"
|
|
137
|
+
return await self._upload(blob_path, spec_content)
|
|
138
|
+
|
|
139
|
+
async def store_scan_spec(self, scan_id: str, content: bytes, filename: str) -> Optional[str]:
|
|
140
|
+
"""Store the OpenAPI spec file used for a scan.
|
|
141
|
+
|
|
142
|
+
Parameters
|
|
143
|
+
----------
|
|
144
|
+
scan_id:
|
|
145
|
+
Unique scan identifier.
|
|
146
|
+
content:
|
|
147
|
+
Raw bytes of the spec file.
|
|
148
|
+
filename:
|
|
149
|
+
Original filename (e.g. ``"openapi.yaml"``).
|
|
150
|
+
|
|
151
|
+
Returns
|
|
152
|
+
-------
|
|
153
|
+
str | None
|
|
154
|
+
The URL of the uploaded blob, or ``None`` if Azure is not
|
|
155
|
+
configured.
|
|
156
|
+
"""
|
|
157
|
+
return await self.store_spec(scan_id, content, filename)
|
|
158
|
+
|
|
159
|
+
async def store_scan_weights(self, scan_id: str, data: Dict[str, Any]) -> Optional[str]:
|
|
160
|
+
"""Store belief-state / weight snapshot for a scan.
|
|
161
|
+
|
|
162
|
+
The data is serialised as JSON and stored under
|
|
163
|
+
``weights/{scan_id}/belief_states.json`` to enable cross-session
|
|
164
|
+
transfer learning when running in cloud mode.
|
|
165
|
+
|
|
166
|
+
Parameters
|
|
167
|
+
----------
|
|
168
|
+
scan_id:
|
|
169
|
+
Unique scan identifier.
|
|
170
|
+
data:
|
|
171
|
+
Belief state or weight data to persist.
|
|
172
|
+
|
|
173
|
+
Returns
|
|
174
|
+
-------
|
|
175
|
+
str | None
|
|
176
|
+
The URL of the uploaded blob, or ``None`` if Azure is not
|
|
177
|
+
configured.
|
|
178
|
+
"""
|
|
179
|
+
if not self._client:
|
|
180
|
+
return None
|
|
181
|
+
|
|
182
|
+
blob_path = f"weights/{scan_id}/belief_states.json"
|
|
183
|
+
raw = json.dumps(data, indent=2, default=str).encode("utf-8")
|
|
184
|
+
return await self._upload(blob_path, raw, content_type="application/json")
|
|
185
|
+
|
|
186
|
+
async def list_scan_artifacts(self, scan_id: str) -> list:
|
|
187
|
+
"""List available artifact blob names for a scan.
|
|
188
|
+
|
|
189
|
+
Returns a list of blob names stored under ``scans/{scan_id}/`` and
|
|
190
|
+
``weights/{scan_id}/``. Returns an empty list when Azure is not
|
|
191
|
+
configured or if no artifacts exist.
|
|
192
|
+
|
|
193
|
+
Parameters
|
|
194
|
+
----------
|
|
195
|
+
scan_id:
|
|
196
|
+
Unique scan identifier.
|
|
197
|
+
|
|
198
|
+
Returns
|
|
199
|
+
-------
|
|
200
|
+
list[str]
|
|
201
|
+
Blob names relative to the container root.
|
|
202
|
+
"""
|
|
203
|
+
if not self._client:
|
|
204
|
+
return []
|
|
205
|
+
|
|
206
|
+
from azure.core.exceptions import AzureError
|
|
207
|
+
|
|
208
|
+
prefixes = [f"scans/{scan_id}/", f"weights/{scan_id}/"]
|
|
209
|
+
names: list = []
|
|
210
|
+
try:
|
|
211
|
+
await self._ensure_container()
|
|
212
|
+
container_client = self._client.get_container_client(self._container_name)
|
|
213
|
+
for prefix in prefixes:
|
|
214
|
+
async for blob in container_client.list_blobs(name_starts_with=prefix):
|
|
215
|
+
names.append(blob.name)
|
|
216
|
+
except AzureError:
|
|
217
|
+
logger.exception("ArtifactStore: failed to list artifacts for scan %s", scan_id)
|
|
218
|
+
return names
|
|
219
|
+
|
|
220
|
+
async def download_artifact(self, blob_name: str) -> Optional[bytes]:
|
|
221
|
+
"""Download a specific artifact by its full blob name.
|
|
222
|
+
|
|
223
|
+
Parameters
|
|
224
|
+
----------
|
|
225
|
+
blob_name:
|
|
226
|
+
Full blob name (as returned by :meth:`list_scan_artifacts`).
|
|
227
|
+
|
|
228
|
+
Returns
|
|
229
|
+
-------
|
|
230
|
+
bytes | None
|
|
231
|
+
Raw bytes of the artifact, or ``None`` if not found / not
|
|
232
|
+
configured.
|
|
233
|
+
"""
|
|
234
|
+
if not self._client:
|
|
235
|
+
return None
|
|
236
|
+
return await self._download(blob_name)
|
|
237
|
+
|
|
238
|
+
# ------------------------------------------------------------------
|
|
239
|
+
# Class-level helpers
|
|
240
|
+
# ------------------------------------------------------------------
|
|
241
|
+
|
|
242
|
+
@staticmethod
|
|
243
|
+
def is_configured() -> bool:
|
|
244
|
+
"""Return ``True`` when Azure Blob Storage environment variables are set."""
|
|
245
|
+
return bool(
|
|
246
|
+
os.environ.get("AZURE_STORAGE_CONNECTION_STRING")
|
|
247
|
+
or os.environ.get("AZURE_STORAGE_ACCOUNT_NAME")
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
# ------------------------------------------------------------------
|
|
251
|
+
# Internal helpers
|
|
252
|
+
# ------------------------------------------------------------------
|
|
253
|
+
|
|
254
|
+
def _build_client(self): # type: ignore[return]
|
|
255
|
+
"""Construct an ``AsyncBlobServiceClient`` from environment variables."""
|
|
256
|
+
try:
|
|
257
|
+
from azure.storage.blob.aio import BlobServiceClient
|
|
258
|
+
except ImportError as exc:
|
|
259
|
+
raise ImportError(
|
|
260
|
+
"azure-storage-blob is required for ArtifactStore. "
|
|
261
|
+
"Install it with: pip install 'mannf[azure-storage]'"
|
|
262
|
+
) from exc
|
|
263
|
+
|
|
264
|
+
conn_str = os.environ.get("AZURE_STORAGE_CONNECTION_STRING")
|
|
265
|
+
if conn_str:
|
|
266
|
+
return BlobServiceClient.from_connection_string(conn_str)
|
|
267
|
+
|
|
268
|
+
account_name = os.environ.get("AZURE_STORAGE_ACCOUNT_NAME")
|
|
269
|
+
if account_name:
|
|
270
|
+
account_key = os.environ.get("AZURE_STORAGE_ACCOUNT_KEY", "")
|
|
271
|
+
if account_key:
|
|
272
|
+
conn_str = (
|
|
273
|
+
f"DefaultEndpointsProtocol=https;"
|
|
274
|
+
f"AccountName={account_name};"
|
|
275
|
+
f"AccountKey={account_key};"
|
|
276
|
+
f"EndpointSuffix=core.windows.net"
|
|
277
|
+
)
|
|
278
|
+
return BlobServiceClient.from_connection_string(conn_str)
|
|
279
|
+
from azure.identity.aio import DefaultAzureCredential
|
|
280
|
+
credential = DefaultAzureCredential()
|
|
281
|
+
return BlobServiceClient(
|
|
282
|
+
account_url=f"https://{account_name}.blob.core.windows.net",
|
|
283
|
+
credential=credential,
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
raise EnvironmentError(
|
|
287
|
+
"Neither AZURE_STORAGE_CONNECTION_STRING nor "
|
|
288
|
+
"AZURE_STORAGE_ACCOUNT_NAME is set."
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
async def _ensure_container(self) -> None:
|
|
292
|
+
"""Create the container if it does not already exist."""
|
|
293
|
+
from azure.core.exceptions import ResourceExistsError
|
|
294
|
+
|
|
295
|
+
container_client = self._client.get_container_client(self._container_name)
|
|
296
|
+
try:
|
|
297
|
+
await container_client.create_container()
|
|
298
|
+
logger.debug("ArtifactStore: created container %r", self._container_name)
|
|
299
|
+
except ResourceExistsError:
|
|
300
|
+
pass
|
|
301
|
+
|
|
302
|
+
async def _upload(
|
|
303
|
+
self,
|
|
304
|
+
blob_path: str,
|
|
305
|
+
data: bytes,
|
|
306
|
+
content_type: str = "application/octet-stream",
|
|
307
|
+
) -> Optional[str]:
|
|
308
|
+
"""Upload *data* to *blob_path*, returning the blob URL."""
|
|
309
|
+
from azure.core.exceptions import AzureError
|
|
310
|
+
from azure.storage.blob import ContentSettings
|
|
311
|
+
|
|
312
|
+
try:
|
|
313
|
+
await self._ensure_container()
|
|
314
|
+
blob_client = self._client.get_blob_client(
|
|
315
|
+
container=self._container_name, blob=blob_path
|
|
316
|
+
)
|
|
317
|
+
await blob_client.upload_blob(
|
|
318
|
+
data,
|
|
319
|
+
overwrite=True,
|
|
320
|
+
content_settings=ContentSettings(content_type=content_type),
|
|
321
|
+
)
|
|
322
|
+
url = blob_client.url
|
|
323
|
+
logger.info("ArtifactStore: uploaded %s", blob_path)
|
|
324
|
+
return url
|
|
325
|
+
except AzureError:
|
|
326
|
+
logger.exception("ArtifactStore: failed to upload %s", blob_path)
|
|
327
|
+
return None
|
|
328
|
+
|
|
329
|
+
async def _download(self, blob_path: str) -> Optional[bytes]:
|
|
330
|
+
"""Download *blob_path*, returning raw bytes or ``None`` if not found."""
|
|
331
|
+
from azure.core.exceptions import AzureError, ResourceNotFoundError
|
|
332
|
+
|
|
333
|
+
try:
|
|
334
|
+
blob_client = self._client.get_blob_client(
|
|
335
|
+
container=self._container_name, blob=blob_path
|
|
336
|
+
)
|
|
337
|
+
stream = await blob_client.download_blob()
|
|
338
|
+
return await stream.readall()
|
|
339
|
+
except ResourceNotFoundError:
|
|
340
|
+
return None
|
|
341
|
+
except AzureError:
|
|
342
|
+
logger.exception("ArtifactStore: failed to download %s", blob_path)
|
|
343
|
+
return None
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
# Copyright (C) 2026 Brad Guider
|
|
2
|
+
# This file is part of NAT (Neural Agent Testing Framework).
|
|
3
|
+
# Licensed under the AGPL-3.0. See LICENSE for details.
|
|
4
|
+
# Commercial licensing available — see COMMERCIAL_LICENSE.md.
|
|
5
|
+
|
|
6
|
+
"""Application telemetry: Azure Monitor OpenTelemetry + structured JSON logging.
|
|
7
|
+
|
|
8
|
+
Call :func:`setup_telemetry` once at startup (before the FastAPI app is
|
|
9
|
+
created). The function is always safe to call:
|
|
10
|
+
|
|
11
|
+
- When ``APPLICATIONINSIGHTS_CONNECTION_STRING`` is **not** set, it configures
|
|
12
|
+
a standard human-readable log format suitable for local development and
|
|
13
|
+
returns immediately.
|
|
14
|
+
- When the connection string **is** set, it additionally:
|
|
15
|
+
1. Configures Python's root logger to emit structured JSON (useful for any
|
|
16
|
+
cloud log aggregator, not just Azure).
|
|
17
|
+
2. Calls ``configure_azure_monitor()`` to enable OpenTelemetry
|
|
18
|
+
auto-instrumentation (traces, metrics, logs) and ship telemetry to
|
|
19
|
+
Application Insights.
|
|
20
|
+
|
|
21
|
+
The Azure SDK dependency (``azure-monitor-opentelemetry``) is **optional at
|
|
22
|
+
runtime** — if it is not installed the module falls back gracefully to JSON
|
|
23
|
+
logging only.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import contextvars
|
|
29
|
+
import json
|
|
30
|
+
import logging
|
|
31
|
+
import os
|
|
32
|
+
import traceback
|
|
33
|
+
from datetime import datetime, timezone
|
|
34
|
+
from typing import Any, Dict, Optional
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# ---------------------------------------------------------------------------
|
|
38
|
+
# Per-request log context (tenant / scan)
|
|
39
|
+
# ---------------------------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
_log_context: contextvars.ContextVar[Dict[str, Any]] = contextvars.ContextVar(
|
|
42
|
+
"_log_context", default={}
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def set_log_context(**kwargs: Any) -> None:
|
|
47
|
+
"""Merge *kwargs* into the current log context for this coroutine/thread.
|
|
48
|
+
|
|
49
|
+
Typical usage::
|
|
50
|
+
|
|
51
|
+
set_log_context(tenant_id="t-123", scan_id="s-456")
|
|
52
|
+
|
|
53
|
+
Call :func:`clear_log_context` when the context is no longer needed (e.g.
|
|
54
|
+
at the end of an HTTP request).
|
|
55
|
+
"""
|
|
56
|
+
current = dict(_log_context.get())
|
|
57
|
+
current.update({k: v for k, v in kwargs.items() if v is not None})
|
|
58
|
+
_log_context.set(current)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def clear_log_context() -> None:
|
|
62
|
+
"""Reset the log context for this coroutine/thread to an empty dict."""
|
|
63
|
+
_log_context.set({})
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class TenantContextFilter(logging.Filter):
|
|
67
|
+
"""Inject tenant/scan context fields into every :class:`logging.LogRecord`.
|
|
68
|
+
|
|
69
|
+
Fields are drawn from the :data:`_log_context` ContextVar, which is
|
|
70
|
+
populated by :func:`set_log_context`. If no context has been set the
|
|
71
|
+
fields are omitted so that log entries from background tasks or startup
|
|
72
|
+
code are not polluted with empty values.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
def filter(self, record: logging.LogRecord) -> bool: # noqa: A003
|
|
76
|
+
ctx = _log_context.get()
|
|
77
|
+
for key, value in ctx.items():
|
|
78
|
+
if not hasattr(record, key):
|
|
79
|
+
setattr(record, key, value)
|
|
80
|
+
return True
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# ---------------------------------------------------------------------------
|
|
84
|
+
# Custom OpenTelemetry metric instruments
|
|
85
|
+
# ---------------------------------------------------------------------------
|
|
86
|
+
# These are initialised lazily in setup_telemetry() and fall back to no-op
|
|
87
|
+
# stubs when OpenTelemetry is not configured so that callers can always use
|
|
88
|
+
# them without checking for None.
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class _NoOpCounter:
|
|
92
|
+
"""No-op stand-in for opentelemetry.metrics.Counter."""
|
|
93
|
+
|
|
94
|
+
def add(self, amount: float, attributes: Optional[Dict[str, Any]] = None) -> None:
|
|
95
|
+
pass
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class _NoOpHistogram:
|
|
99
|
+
"""No-op stand-in for opentelemetry.metrics.Histogram."""
|
|
100
|
+
|
|
101
|
+
def record(self, amount: float, attributes: Optional[Dict[str, Any]] = None) -> None:
|
|
102
|
+
pass
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class _NoOpGauge:
|
|
106
|
+
"""No-op stand-in for an observable gauge (callback-based metric)."""
|
|
107
|
+
|
|
108
|
+
def __init__(self) -> None:
|
|
109
|
+
self._value: float = 0.0
|
|
110
|
+
|
|
111
|
+
def set(self, value: float, attributes: Optional[Dict[str, Any]] = None) -> None:
|
|
112
|
+
self._value = value
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
# Public metric handles — replaced with real instruments when OTel is available
|
|
116
|
+
scans_started_counter: Any = _NoOpCounter()
|
|
117
|
+
scans_completed_counter: Any = _NoOpCounter()
|
|
118
|
+
scans_duration_histogram: Any = _NoOpHistogram()
|
|
119
|
+
scan_queue_depth_gauge: Any = _NoOpGauge()
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
# ---------------------------------------------------------------------------
|
|
123
|
+
# JSON formatter
|
|
124
|
+
# ---------------------------------------------------------------------------
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class _JsonFormatter(logging.Formatter):
|
|
128
|
+
"""Emit each log record as a single-line JSON object.
|
|
129
|
+
|
|
130
|
+
Fields included in every record:
|
|
131
|
+
- ``timestamp`` — ISO-8601 UTC
|
|
132
|
+
- ``level`` — e.g. ``"INFO"``
|
|
133
|
+
- ``message`` — formatted log message
|
|
134
|
+
- ``logger`` — logger name
|
|
135
|
+
- ``module`` — Python module (``record.module``)
|
|
136
|
+
- ``funcName`` — calling function name
|
|
137
|
+
- Any *extra* fields attached to the record that are not standard
|
|
138
|
+
:class:`logging.LogRecord` attributes.
|
|
139
|
+
"""
|
|
140
|
+
|
|
141
|
+
# Standard LogRecord attributes we do NOT want to re-emit as extra fields.
|
|
142
|
+
_RESERVED: frozenset = frozenset({
|
|
143
|
+
"name", "msg", "args", "created", "filename", "funcName", "levelname",
|
|
144
|
+
"levelno", "lineno", "message", "module", "msecs", "pathname",
|
|
145
|
+
"process", "processName", "relativeCreated", "stack_info", "thread",
|
|
146
|
+
"threadName", "exc_info", "exc_text",
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
def format(self, record: logging.LogRecord) -> str:
|
|
150
|
+
record.message = record.getMessage()
|
|
151
|
+
|
|
152
|
+
payload: Dict[str, Any] = {
|
|
153
|
+
"timestamp": datetime.fromtimestamp(record.created, tz=timezone.utc).isoformat(),
|
|
154
|
+
"level": record.levelname,
|
|
155
|
+
"message": record.message,
|
|
156
|
+
"logger": record.name,
|
|
157
|
+
"module": record.module,
|
|
158
|
+
"funcName": record.funcName,
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
# Attach any extra fields the caller passed via logging.extra
|
|
162
|
+
for key, value in record.__dict__.items():
|
|
163
|
+
if key not in self._RESERVED and not key.startswith("_"):
|
|
164
|
+
payload[key] = value
|
|
165
|
+
|
|
166
|
+
if record.exc_info:
|
|
167
|
+
payload["exception"] = self.formatException(record.exc_info)
|
|
168
|
+
elif record.exc_text:
|
|
169
|
+
payload["exception"] = record.exc_text
|
|
170
|
+
|
|
171
|
+
return json.dumps(payload, default=str)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
# ---------------------------------------------------------------------------
|
|
175
|
+
# Public API
|
|
176
|
+
# ---------------------------------------------------------------------------
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def setup_telemetry() -> None:
|
|
180
|
+
"""Configure logging and (optionally) Azure Monitor OpenTelemetry.
|
|
181
|
+
|
|
182
|
+
This function is **idempotent** — calling it multiple times has no
|
|
183
|
+
additional effect beyond the first call.
|
|
184
|
+
|
|
185
|
+
Behaviour
|
|
186
|
+
---------
|
|
187
|
+
- **Always**: configure the root logger's handler.
|
|
188
|
+
- **When** ``APPLICATIONINSIGHTS_CONNECTION_STRING`` is set:
|
|
189
|
+
- Switch the root logger to :class:`_JsonFormatter` (structured JSON).
|
|
190
|
+
- Attempt to call ``configure_azure_monitor()`` from
|
|
191
|
+
``azure.monitor.opentelemetry``. If the package is not installed
|
|
192
|
+
or the call fails, log a warning and continue — the app will still
|
|
193
|
+
run with JSON logging.
|
|
194
|
+
- Attempt to initialise custom OpenTelemetry metric instruments
|
|
195
|
+
(``nat.scans.started``, ``nat.scans.completed``,
|
|
196
|
+
``nat.scans.duration_seconds``, ``nat.scan_queue.depth``). Falls
|
|
197
|
+
back to no-op stubs when the OTel SDK is not available.
|
|
198
|
+
- **Otherwise**: use a standard human-readable format.
|
|
199
|
+
"""
|
|
200
|
+
global scans_started_counter, scans_completed_counter
|
|
201
|
+
global scans_duration_histogram, scan_queue_depth_gauge
|
|
202
|
+
|
|
203
|
+
connection_string = os.environ.get("APPLICATIONINSIGHTS_CONNECTION_STRING", "").strip()
|
|
204
|
+
|
|
205
|
+
root_logger = logging.getLogger()
|
|
206
|
+
|
|
207
|
+
# Only add a handler if none exist yet (avoid duplicate output on repeated calls).
|
|
208
|
+
if not root_logger.handlers:
|
|
209
|
+
handler = logging.StreamHandler()
|
|
210
|
+
root_logger.addHandler(handler)
|
|
211
|
+
root_logger.setLevel(logging.INFO)
|
|
212
|
+
|
|
213
|
+
# Register the tenant/scan context filter once (idempotent check).
|
|
214
|
+
_filter_type = TenantContextFilter
|
|
215
|
+
if not any(isinstance(f, _filter_type) for f in root_logger.filters):
|
|
216
|
+
root_logger.addFilter(TenantContextFilter())
|
|
217
|
+
|
|
218
|
+
if connection_string:
|
|
219
|
+
# ── Structured JSON logging ─────────────────────────────────────────
|
|
220
|
+
for handler in root_logger.handlers:
|
|
221
|
+
handler.setFormatter(_JsonFormatter())
|
|
222
|
+
|
|
223
|
+
# ── Azure Monitor OpenTelemetry ─────────────────────────────────────
|
|
224
|
+
try:
|
|
225
|
+
from azure.monitor.opentelemetry import configure_azure_monitor # type: ignore[import]
|
|
226
|
+
|
|
227
|
+
configure_azure_monitor(connection_string=connection_string)
|
|
228
|
+
logging.getLogger(__name__).info(
|
|
229
|
+
"Azure Monitor OpenTelemetry configured",
|
|
230
|
+
extra={"connection_string_present": True},
|
|
231
|
+
)
|
|
232
|
+
except ImportError:
|
|
233
|
+
logging.getLogger(__name__).warning(
|
|
234
|
+
"azure-monitor-opentelemetry is not installed; "
|
|
235
|
+
"Application Insights telemetry will not be sent. "
|
|
236
|
+
"Install it with: pip install azure-monitor-opentelemetry>=1.6"
|
|
237
|
+
)
|
|
238
|
+
except Exception: # noqa: BLE001
|
|
239
|
+
logging.getLogger(__name__).warning(
|
|
240
|
+
"Failed to configure Azure Monitor OpenTelemetry:\n%s",
|
|
241
|
+
traceback.format_exc(),
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
# ── Custom OpenTelemetry metric instruments ─────────────────────────
|
|
245
|
+
try:
|
|
246
|
+
from opentelemetry import metrics as _otel_metrics # type: ignore[import]
|
|
247
|
+
|
|
248
|
+
_meter = _otel_metrics.get_meter("mannf.nat", version="1.0")
|
|
249
|
+
scans_started_counter = _meter.create_counter(
|
|
250
|
+
name="nat.scans.started",
|
|
251
|
+
description="Number of scans started",
|
|
252
|
+
unit="1",
|
|
253
|
+
)
|
|
254
|
+
scans_completed_counter = _meter.create_counter(
|
|
255
|
+
name="nat.scans.completed",
|
|
256
|
+
description="Number of scans completed (use 'status' attribute for outcome)",
|
|
257
|
+
unit="1",
|
|
258
|
+
)
|
|
259
|
+
scans_duration_histogram = _meter.create_histogram(
|
|
260
|
+
name="nat.scans.duration_seconds",
|
|
261
|
+
description="Scan execution duration in seconds",
|
|
262
|
+
unit="s",
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
# Observable gauge for queue depth — use an up-down counter as a
|
|
266
|
+
# simple synchronous proxy; the value is updated externally via the
|
|
267
|
+
# _NoOpGauge.set() interface replaced by a thin wrapper here.
|
|
268
|
+
class _GaugeProxy(_NoOpGauge):
|
|
269
|
+
"""Wraps an UpDownCounter to simulate a synchronous gauge."""
|
|
270
|
+
|
|
271
|
+
def __init__(self, counter: Any) -> None:
|
|
272
|
+
super().__init__()
|
|
273
|
+
self._counter = counter
|
|
274
|
+
self._last_value: float = 0.0
|
|
275
|
+
|
|
276
|
+
def set(self, value: float, attributes: Optional[Dict[str, Any]] = None) -> None:
|
|
277
|
+
delta = value - self._last_value
|
|
278
|
+
self._last_value = value
|
|
279
|
+
self._counter.add(delta, attributes or {})
|
|
280
|
+
|
|
281
|
+
_queue_depth_counter = _meter.create_up_down_counter(
|
|
282
|
+
name="nat.scan_queue.depth",
|
|
283
|
+
description="Current scan queue depth (pending + running)",
|
|
284
|
+
unit="1",
|
|
285
|
+
)
|
|
286
|
+
scan_queue_depth_gauge = _GaugeProxy(_queue_depth_counter)
|
|
287
|
+
|
|
288
|
+
except ImportError:
|
|
289
|
+
# OTel SDK not installed — no-op stubs remain
|
|
290
|
+
pass
|
|
291
|
+
except Exception: # noqa: BLE001
|
|
292
|
+
logging.getLogger(__name__).warning(
|
|
293
|
+
"Failed to initialise OpenTelemetry metric instruments:\n%s",
|
|
294
|
+
traceback.format_exc(),
|
|
295
|
+
)
|
|
296
|
+
else:
|
|
297
|
+
# ── Human-readable format for local development ────────────────────
|
|
298
|
+
fmt = "%(asctime)s %(levelname)-8s %(name)s — %(message)s"
|
|
299
|
+
for handler in root_logger.handlers:
|
|
300
|
+
handler.setFormatter(logging.Formatter(fmt))
|