mcp-instana 0.1.0__py3-none-any.whl → 0.1.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.
Files changed (37) hide show
  1. mcp_instana-0.1.1.dist-info/METADATA +908 -0
  2. mcp_instana-0.1.1.dist-info/RECORD +30 -0
  3. {mcp_instana-0.1.0.dist-info → mcp_instana-0.1.1.dist-info}/WHEEL +1 -1
  4. mcp_instana-0.1.1.dist-info/entry_points.txt +4 -0
  5. mcp_instana-0.1.0.dist-info/LICENSE → mcp_instana-0.1.1.dist-info/licenses/LICENSE.md +3 -3
  6. src/application/__init__.py +1 -0
  7. src/{client/application_alert_config_mcp_tools.py → application/application_alert_config.py} +251 -273
  8. src/application/application_analyze.py +415 -0
  9. src/application/application_catalog.py +153 -0
  10. src/{client/application_metrics_mcp_tools.py → application/application_metrics.py} +107 -129
  11. src/{client/application_resources_mcp_tools.py → application/application_resources.py} +128 -150
  12. src/application/application_settings.py +1135 -0
  13. src/application/application_topology.py +107 -0
  14. src/core/__init__.py +1 -0
  15. src/core/server.py +436 -0
  16. src/core/utils.py +213 -0
  17. src/event/__init__.py +1 -0
  18. src/{client/events_mcp_tools.py → event/events_tools.py} +128 -136
  19. src/infrastructure/__init__.py +1 -0
  20. src/{client/infrastructure_analyze_mcp_tools.py → infrastructure/infrastructure_analyze.py} +200 -203
  21. src/{client/infrastructure_catalog_mcp_tools.py → infrastructure/infrastructure_catalog.py} +194 -264
  22. src/infrastructure/infrastructure_metrics.py +167 -0
  23. src/{client/infrastructure_resources_mcp_tools.py → infrastructure/infrastructure_resources.py} +192 -223
  24. src/{client/infrastructure_topology_mcp_tools.py → infrastructure/infrastructure_topology.py} +105 -106
  25. src/log/__init__.py +1 -0
  26. src/log/log_alert_configuration.py +331 -0
  27. src/prompts/mcp_prompts.py +900 -0
  28. src/prompts/prompt_loader.py +29 -0
  29. src/prompts/prompt_registry.json +21 -0
  30. mcp_instana-0.1.0.dist-info/METADATA +0 -649
  31. mcp_instana-0.1.0.dist-info/RECORD +0 -19
  32. mcp_instana-0.1.0.dist-info/entry_points.txt +0 -3
  33. src/client/What is the sum of queue depth for all q +0 -55
  34. src/client/instana_client_base.py +0 -93
  35. src/client/log_alert_configuration_mcp_tools.py +0 -316
  36. src/client/show the top 5 services with the highest +0 -28
  37. src/mcp_server.py +0 -343
@@ -0,0 +1,107 @@
1
+ """
2
+ Application Topology MCP Tools Module
3
+
4
+ This module provides application topology-specific MCP tools for Instana monitoring.
5
+ """
6
+
7
+ import logging
8
+ from datetime import datetime
9
+ from typing import Any, Dict, Optional
10
+
11
+ from src.core.utils import BaseInstanaClient, register_as_tool
12
+
13
+ try:
14
+ from instana_client.api.application_topology_api import ApplicationTopologyApi
15
+ from instana_client.api_client import ApiClient
16
+ from instana_client.configuration import Configuration
17
+ except ImportError as e:
18
+ import logging
19
+ logger = logging.getLogger(__name__)
20
+ logger.error(f"Error importing Instana SDK: {e}", exc_info=True)
21
+ raise
22
+
23
+ # Configure logger for this module
24
+ logger = logging.getLogger(__name__)
25
+
26
+ class ApplicationTopologyMCPTools(BaseInstanaClient):
27
+ """Tools for application topology in Instana MCP."""
28
+
29
+ def __init__(self, read_token: str, base_url: str):
30
+ """Initialize the Application Topology MCP tools client."""
31
+ super().__init__(read_token=read_token, base_url=base_url)
32
+
33
+ try:
34
+
35
+ # Configure the API client with the correct base URL and authentication
36
+ configuration = Configuration()
37
+ configuration.host = base_url
38
+ configuration.api_key['ApiKeyAuth'] = read_token
39
+ configuration.api_key_prefix['ApiKeyAuth'] = 'apiToken'
40
+
41
+ # Create an API client with this configuration
42
+ api_client = ApiClient(configuration=configuration)
43
+
44
+ # Initialize the Instana SDK's ApplicationTopologyMCPTools with our configured client
45
+ self.topology_api = ApplicationTopologyApi(api_client=api_client)
46
+
47
+ except Exception as e:
48
+ logger.error(f"Error initializing ApplicationTopologyMCPTools: {e}", exc_info=True)
49
+ raise
50
+
51
+ @register_as_tool
52
+ async def get_application_topology(self,
53
+ window_size: Optional[int] = None,
54
+ to_timestamp: Optional[int] = None,
55
+ application_id: Optional[str] = None,
56
+ application_boundary_scope: Optional[str] = None,
57
+ ctx = None) -> Dict[str, Any]:
58
+ """
59
+ Get the service topology from Instana Server.
60
+ This tool retrieves services and connections (call paths) between them for calls in the scope given by the parameters.
61
+
62
+ Args:
63
+ window_size: Size of time window in milliseconds
64
+ to_timestamp: Timestamp since Unix Epoch in milliseconds of the end of the time window
65
+ application_id: Filter by application ID
66
+ application_boundary_scope: Filter by application scope, i.e., INBOUND or ALL. The default value is INBOUND.
67
+ ctx: Context information
68
+
69
+ Returns:
70
+ A dictionary containing the service topology data
71
+ """
72
+
73
+ try:
74
+ logger.debug("Fetching service topology data")
75
+
76
+ # Set default values if not provided
77
+ if not to_timestamp:
78
+ to_timestamp = int(datetime.now().timestamp() * 1000)
79
+
80
+ if not window_size:
81
+ window_size = 3600000 # Default to 1 hour in milliseconds
82
+
83
+ # Call the API
84
+ # Note: The SDK expects parameters in camelCase, but we use snake_case in Python
85
+ # The SDK will handle the conversion
86
+ result = self.topology_api.get_services_map(
87
+ window_size=window_size,
88
+ to=to_timestamp,
89
+ application_id=application_id,
90
+ application_boundary_scope=application_boundary_scope
91
+ )
92
+
93
+ # Ensure we always return a dictionary
94
+ if hasattr(result, "to_dict"):
95
+ result_dict = result.to_dict()
96
+ elif isinstance(result, dict):
97
+ result_dict = result
98
+ else:
99
+ # Convert to dictionary using __dict__ or as a fallback, create a new dict with string representation
100
+ result_dict = getattr(result, "__dict__", {"data": str(result)})
101
+
102
+ logger.debug("Successfully retrieved service topology data")
103
+ return result_dict
104
+
105
+ except Exception as e:
106
+ logger.error(f"Error in get_application_topology: {e}", exc_info=True)
107
+ return {"error": f"Failed to get application topology: {e!s}"}
src/core/__init__.py ADDED
@@ -0,0 +1 @@
1
+ # Core module for MCP Instana
src/core/server.py ADDED
@@ -0,0 +1,436 @@
1
+ """
2
+ Standalone MCP Server for Instana Events and Infrastructure Resources
3
+
4
+ This module provides a dedicated MCP server that exposes Instana MCP Server.
5
+ Supports stdio and Streamable HTTP transports.
6
+ """
7
+
8
+ import argparse
9
+ import logging
10
+ import os
11
+ import sys
12
+ from collections.abc import AsyncIterator
13
+ from contextlib import asynccontextmanager
14
+ from dataclasses import dataclass, fields
15
+ from typing import Any
16
+
17
+ from dotenv import load_dotenv
18
+
19
+ from src.prompts.prompt_loader import register_prompts
20
+
21
+ load_dotenv()
22
+
23
+ # Configure logging
24
+ logging.basicConfig(
25
+ level=logging.INFO, # Default level, can be overridden
26
+ format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
27
+ handlers=[
28
+ logging.StreamHandler(sys.stderr)
29
+ ]
30
+ )
31
+ logger = logging.getLogger(__name__)
32
+
33
+ def set_log_level(level_name):
34
+ """Set the logging level based on the provided level name"""
35
+ level_map = {
36
+ "DEBUG": logging.DEBUG,
37
+ "INFO": logging.INFO,
38
+ "WARNING": logging.WARNING,
39
+ "ERROR": logging.ERROR,
40
+ "CRITICAL": logging.CRITICAL
41
+ }
42
+
43
+ level = level_map.get(level_name.upper(), logging.INFO)
44
+ logger.setLevel(level)
45
+ logging.getLogger().setLevel(level)
46
+ logger.info(f"Log level set to {level_name.upper()}")
47
+
48
+ # Add the project root to the Python path
49
+ current_path = os.path.abspath(__file__)
50
+ project_root = os.path.dirname(os.path.dirname(current_path))
51
+ if project_root not in sys.path:
52
+ sys.path.insert(0, project_root)
53
+
54
+ # Import the necessary modules
55
+ try:
56
+ from src.core.utils import MCP_TOOLS, register_as_tool
57
+ except ImportError:
58
+ logger.error("Failed to import required modules", exc_info=True)
59
+ sys.exit(1)
60
+
61
+ from fastmcp import FastMCP
62
+
63
+
64
+ @dataclass
65
+ class MCPState:
66
+ """State for the MCP server."""
67
+ events_client: Any = None
68
+ infra_client: Any = None
69
+ app_resource_client: Any = None
70
+ app_metrics_client: Any = None
71
+ app_alert_client: Any = None
72
+ infra_catalog_client: Any = None
73
+ infra_topo_client: Any = None
74
+ infra_analyze_client: Any = None
75
+ infra_metrics_client: Any = None
76
+ app_catalog_client: Any = None
77
+ app_topology_client: Any = None
78
+ app_analyze_client: Any = None
79
+ app_settings_client: Any = None
80
+
81
+ # Global variables to store credentials for lifespan
82
+ _global_token = None
83
+ _global_base_url = None
84
+
85
+ def get_instana_credentials():
86
+ """Get Instana credentials from environment variables for stdio mode."""
87
+ # For stdio mode, use INSTANA_API_TOKEN and INSTANA_BASE_URL
88
+ token = (os.getenv("INSTANA_API_TOKEN") or "")
89
+ base_url = (os.getenv("INSTANA_BASE_URL") or "")
90
+
91
+ return token, base_url
92
+
93
+ def validate_credentials(token: str, base_url: str) -> bool:
94
+ """Validate that Instana credentials are provided for stdio mode."""
95
+ # For stdio mode, validate INSTANA_API_TOKEN and INSTANA_BASE_URL
96
+ return not (not token or not base_url)
97
+
98
+ def create_clients(token: str, base_url: str, enabled_categories: str = "all") -> MCPState:
99
+ """Create only the enabled Instana clients"""
100
+ state = MCPState()
101
+
102
+ # Get enabled client configurations
103
+ enabled_client_configs = get_enabled_client_configs(enabled_categories)
104
+
105
+ for attr_name, client_class in enabled_client_configs:
106
+ try:
107
+ client = client_class(read_token=token, base_url=base_url)
108
+ setattr(state, attr_name, client)
109
+ except Exception as e:
110
+ logger.error(f"Failed to create {attr_name}: {e}", exc_info=True)
111
+ setattr(state, attr_name, None)
112
+
113
+ return state
114
+
115
+
116
+ @asynccontextmanager
117
+ async def lifespan(server: FastMCP) -> AsyncIterator[MCPState]:
118
+ """Set up and tear down the Instana clients."""
119
+ # Get credentials from environment variables
120
+ token, base_url = get_instana_credentials()
121
+
122
+ try:
123
+ # For lifespan, we'll create all clients since we don't have access to command line args here
124
+ state = create_clients(token, base_url, "all")
125
+
126
+ yield state
127
+ except Exception:
128
+ logger.error("Error during lifespan", exc_info=True)
129
+
130
+ # Yield empty state if client creation failed
131
+ yield MCPState()
132
+
133
+ def create_app(token: str, base_url: str, port: int = 8000, enabled_categories: str = "all") -> tuple[FastMCP, int]:
134
+ """Create and configure the MCP server with the given credentials."""
135
+ try:
136
+ server = FastMCP(name="Instana MCP Server", host="0.0.0.0", port=port)
137
+
138
+ # Only create and register enabled clients/tools
139
+ clients_state = create_clients(token, base_url, enabled_categories)
140
+
141
+ tools_registered = 0
142
+ for tool_name, _tool_func in MCP_TOOLS.items():
143
+ try:
144
+ client_attr_names = [field.name for field in fields(MCPState)]
145
+ for attr_name in client_attr_names:
146
+ client = getattr(clients_state, attr_name, None)
147
+ if client and hasattr(client, tool_name):
148
+ bound_method = getattr(client, tool_name)
149
+ server.tool()(bound_method)
150
+ tools_registered += 1
151
+ break
152
+ except Exception as e:
153
+ logger.error(f"Failed to register tool {tool_name}: {e}", exc_info=True)
154
+
155
+ # Register prompts from the prompt registry
156
+ register_prompts(server)
157
+
158
+ return server, tools_registered
159
+
160
+ except Exception:
161
+ logger.error("Error creating app", exc_info=True)
162
+ fallback_server = FastMCP("Instana Tools")
163
+ return fallback_server, 0 # Return a tuple with 0 tools registered
164
+
165
+ async def execute_tool(tool_name: str, arguments: dict, clients_state) -> str:
166
+ """Execute a tool and return result"""
167
+ try:
168
+ # Get all field names from MCPState dataclass
169
+ client_attr_names = [field.name for field in fields(MCPState)]
170
+
171
+ for attr_name in client_attr_names:
172
+ client = getattr(clients_state, attr_name, None)
173
+ if client and hasattr(client, tool_name):
174
+ method = getattr(client, tool_name)
175
+ result = await method(**arguments)
176
+ return str(result)
177
+
178
+ return f"Tool {tool_name} not found"
179
+ except Exception as e:
180
+ return f"Error executing tool {tool_name}: {e!s}"
181
+
182
+ def get_client_categories():
183
+ """Get client categories with lazy imports to avoid circular dependencies"""
184
+ try:
185
+ from src.application.application_alert_config import ApplicationAlertMCPTools
186
+ from src.application.application_analyze import ApplicationAnalyzeMCPTools
187
+ from src.application.application_catalog import ApplicationCatalogMCPTools
188
+ from src.application.application_metrics import ApplicationMetricsMCPTools
189
+ from src.application.application_resources import ApplicationResourcesMCPTools
190
+ from src.application.application_settings import ApplicationSettingsMCPTools
191
+ from src.application.application_topology import ApplicationTopologyMCPTools
192
+ from src.event.events_tools import AgentMonitoringEventsMCPTools
193
+ from src.infrastructure.infrastructure_analyze import (
194
+ InfrastructureAnalyzeMCPTools,
195
+ )
196
+ from src.infrastructure.infrastructure_catalog import (
197
+ InfrastructureCatalogMCPTools,
198
+ )
199
+ from src.infrastructure.infrastructure_metrics import (
200
+ InfrastructureMetricsMCPTools,
201
+ )
202
+ from src.infrastructure.infrastructure_resources import (
203
+ InfrastructureResourcesMCPTools,
204
+ )
205
+ from src.infrastructure.infrastructure_topology import (
206
+ InfrastructureTopologyMCPTools,
207
+ )
208
+ except ImportError as e:
209
+ logger.warning(f"Could not import client classes: {e}")
210
+ return {}
211
+
212
+ return {
213
+ "infra": [
214
+ ('infra_client', InfrastructureResourcesMCPTools),
215
+ ('infra_catalog_client', InfrastructureCatalogMCPTools),
216
+ ('infra_topo_client', InfrastructureTopologyMCPTools),
217
+ ('infra_analyze_client', InfrastructureAnalyzeMCPTools),
218
+ ('infra_metrics_client', InfrastructureMetricsMCPTools),
219
+ ],
220
+ "app": [
221
+ ('app_resource_client', ApplicationResourcesMCPTools),
222
+ ('app_metrics_client', ApplicationMetricsMCPTools),
223
+ ('app_alert_client', ApplicationAlertMCPTools),
224
+ ('app_catalog_client', ApplicationCatalogMCPTools),
225
+ ('app_topology_client', ApplicationTopologyMCPTools),
226
+ ('app_analyze_client', ApplicationAnalyzeMCPTools),
227
+ ('app_settings_client', ApplicationSettingsMCPTools),
228
+ ],
229
+ "events": [
230
+ ('events_client', AgentMonitoringEventsMCPTools),
231
+ ]
232
+ }
233
+
234
+ def get_enabled_client_configs(enabled_categories: str):
235
+ """Get client configurations based on enabled categories"""
236
+ # Get client categories with lazy imports
237
+ client_categories = get_client_categories()
238
+
239
+ if enabled_categories.lower() == "all":
240
+ all_configs = []
241
+ for category_clients in client_categories.values():
242
+ all_configs.extend(category_clients)
243
+ return all_configs
244
+ categories = [cat.strip() for cat in enabled_categories.split(",")]
245
+ enabled_configs = []
246
+ for category in categories:
247
+ if category in client_categories:
248
+ enabled_configs.extend(client_categories[category])
249
+ else:
250
+ logger.warning(f"Unknown category '{category}'")
251
+ return enabled_configs
252
+
253
+ def main():
254
+ """Main entry point for the MCP server."""
255
+ try:
256
+ # Create and configure the MCP server
257
+ parser = argparse.ArgumentParser(description="Instana MCP Server", add_help=False)
258
+ parser.add_argument(
259
+ "-h", "--help",
260
+ action="store_true",
261
+ dest="help",
262
+ help="show this help message and exit"
263
+ )
264
+ parser.add_argument(
265
+ "--transport",
266
+ type=str,
267
+ choices=["streamable-http","stdio"],
268
+ metavar='<mode>',
269
+ help="Transport mode. Choose from: streamable-http, stdio."
270
+ )
271
+ parser.add_argument(
272
+ "--log-level",
273
+ type=str,
274
+ choices=["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"],
275
+ default="INFO",
276
+ help="Set the logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL)"
277
+ )
278
+ parser.add_argument(
279
+ "--debug",
280
+ action="store_true",
281
+ help="Enable debug mode with additional logging (shortcut for --log-level DEBUG)"
282
+ )
283
+ parser.add_argument(
284
+ "--tools",
285
+ type=str,
286
+ metavar='<categories>',
287
+ help="Comma-separated list of tool categories to enable (--tools infra,app,events). If not provided, all tools are enabled."
288
+ )
289
+ parser.add_argument(
290
+ "--list-tools",
291
+ action="store_true",
292
+ help="List all available tool categories and exit."
293
+ )
294
+ parser.add_argument(
295
+ "--port",
296
+ type=int,
297
+ default=8000,
298
+ help="Port to listen on (default: 8000)"
299
+ )
300
+ # Check for help arguments before parsing
301
+ if len(sys.argv) > 1 and any(arg in ['-h','--h','--help','-help'] for arg in sys.argv[1:]):
302
+ # Check if help is combined with other arguments
303
+ help_args = ['-h','--h','--help','-help']
304
+ other_args = [arg for arg in sys.argv[1:] if arg not in help_args]
305
+
306
+ if other_args:
307
+ logger.error("Argument -h/--h/--help/-help: not allowed with other arguments")
308
+ sys.exit(2)
309
+
310
+ # Show help and exit
311
+ try:
312
+ logger.info("Available options:")
313
+ for action in parser._actions:
314
+ # Only print options that start with '--' and have a help string
315
+ if any(opt.startswith('--') for opt in action.option_strings) and action.help:
316
+ # Find the first long option
317
+ long_opt = next((opt for opt in action.option_strings if opt.startswith('--')), None)
318
+ metavar = action.metavar or ''
319
+ opt_str = f"{long_opt} {metavar}".strip()
320
+ logger.info(f"{opt_str:<24} {action.help}")
321
+ sys.exit(0)
322
+ except Exception as e:
323
+ logger.error(f"Error displaying help: {e}")
324
+ sys.exit(0) # Still exit with 0 for help
325
+
326
+ args = parser.parse_args()
327
+
328
+ # Set log level based on command line arguments
329
+ if args.debug:
330
+ set_log_level("DEBUG")
331
+ else:
332
+ set_log_level(args.log_level)
333
+
334
+ all_categories = {"infra", "app", "events"}
335
+
336
+ # Handle --list-tools option
337
+ if args.list_tools:
338
+ logger.info("Available tool categories:")
339
+ client_categories = get_client_categories()
340
+ for category, tools in client_categories.items():
341
+ tool_names = [cls.__name__ for _, cls in tools]
342
+ logger.info(f" {category}: {len(tool_names)} tools")
343
+ for tool_name in tool_names:
344
+ logger.info(f" - {tool_name}")
345
+ sys.exit(0)
346
+
347
+ # By default, enable all categories
348
+ enabled = set(all_categories)
349
+ invalid = set()
350
+
351
+ # Enable only specified categories if --tools is provided
352
+ if args.tools:
353
+ specified_tools = {cat.strip() for cat in args.tools.split(",")}
354
+ invalid = specified_tools - all_categories
355
+ enabled = specified_tools & all_categories
356
+
357
+ # If no valid tools specified, default to all
358
+ if not enabled:
359
+ enabled = set(all_categories)
360
+
361
+ if invalid:
362
+ logger.error(f"Error: Unknown category/categories: {', '.join(invalid)}. Available categories: infra, app, events")
363
+ sys.exit(2)
364
+
365
+ # Print enabled tools for user information
366
+ enabled_tool_classes = []
367
+ client_categories = get_client_categories()
368
+
369
+ # Log enabled categories and tools
370
+ logger.info(f"Enabled tool categories: {', '.join(enabled)}")
371
+
372
+ for category in enabled:
373
+ if category in client_categories:
374
+ category_tools = [cls.__name__ for _, cls in client_categories[category]]
375
+ enabled_tool_classes.extend(category_tools)
376
+ logger.info(f" - {category}: {len(category_tools)} tools")
377
+ for tool_name in category_tools:
378
+ logger.info(f" * {tool_name}")
379
+
380
+ if enabled_tool_classes:
381
+ logger.info(
382
+ f"Total enabled tools: {len(enabled_tool_classes)}"
383
+ )
384
+
385
+ # Get credentials from environment variables for stdio mode
386
+ INSTANA_API_TOKEN, INSTANA_BASE_URL = get_instana_credentials()
387
+
388
+ if args.transport == "stdio" or args.transport is None:
389
+ if not validate_credentials(INSTANA_API_TOKEN, INSTANA_BASE_URL):
390
+ logger.error("Error: Instana credentials are required for stdio mode but not provided. Please set INSTANA_API_TOKEN and INSTANA_BASE_URL environment variables.")
391
+ sys.exit(1)
392
+
393
+ # Create and configure the MCP server
394
+ try:
395
+ enabled_categories = ",".join(enabled)
396
+ app, registered_tool_count = create_app(INSTANA_API_TOKEN, INSTANA_BASE_URL, args.port, enabled_categories)
397
+ except Exception as e:
398
+ print(f"Failed to create MCP server: {e}", file=sys.stderr)
399
+ sys.exit(1)
400
+
401
+ # Run the server with the appropriate transport
402
+ if args.transport == "streamable-http":
403
+ if args.debug:
404
+ logger.info(f"FastMCP instance: {app}")
405
+ logger.info(f"Registered tools: {registered_tool_count}")
406
+ try:
407
+ app.run(transport="streamable-http")
408
+ except Exception as e:
409
+ logger.error(f"Failed to start HTTP server: {e}")
410
+ if args.debug:
411
+ logger.error("HTTP server error details", exc_info=True)
412
+ sys.exit(1)
413
+ else:
414
+ logger.info("Starting stdio transport")
415
+ try:
416
+ app.run(transport="stdio")
417
+ except AttributeError as e:
418
+ # Handle the case where sys.stdout is a StringIO object (in tests)
419
+ if "'_io.StringIO' object has no attribute 'buffer'" in str(e):
420
+ logger.info("Running in test mode, skipping stdio server")
421
+ else:
422
+ raise
423
+
424
+ except KeyboardInterrupt:
425
+ logger.info("Server stopped by user")
426
+ sys.exit(0)
427
+ except Exception as e:
428
+ logger.error(f"Server error: {e}", exc_info=True)
429
+ sys.exit(1)
430
+
431
+ if __name__ == "__main__":
432
+ try:
433
+ main()
434
+ except Exception:
435
+ logger.error("Unhandled exception in main", exc_info=True)
436
+ sys.exit(1)