clarity-api-sdk-python 0.2.10__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.

Potentially problematic release.


This version of clarity-api-sdk-python might be problematic. Click here for more details.

@@ -0,0 +1,79 @@
1
+ Metadata-Version: 2.4
2
+ Name: clarity-api-sdk-python
3
+ Version: 0.2.10
4
+ Summary: A Python SDK to connect to the CTI Clarity API server.
5
+ Author-email: "Chesapeake Technology Inc." <support@chesapeaketech.com>
6
+ Project-URL: Homepage, https://github.com/chesapeake-tech/clarity-api-sdk-python
7
+ Classifier: Programming Language :: Python :: 3
8
+ Classifier: License :: OSI Approved :: MIT License
9
+ Classifier: Operating System :: OS Independent
10
+ Requires-Python: >=3.11
11
+ Description-Content-Type: text/markdown
12
+ Requires-Dist: httpx>=0.28.1
13
+ Requires-Dist: brotli
14
+ Requires-Dist: h2
15
+ Requires-Dist: httpx_auth>=0.23.1
16
+ Requires-Dist: httpx-retries>=0.4.5
17
+ Requires-Dist: structlog
18
+ Provides-Extra: brotli
19
+ Requires-Dist: httpx[brotli]>=0.28.1; extra == "brotli"
20
+ Provides-Extra: http2
21
+ Requires-Dist: httpx[http2]>=0.28.1; extra == "http2"
22
+
23
+ # Clarity API SDK for Python
24
+
25
+ [![PyPI - Downloads](https://badge.fury.io/py/clarity-api-sdk-python.svg)](https://pypi.org/project/clarity-api-sdk-python/)
26
+ [![Downloads](https://pepy.tech/badge/clarity-api-sdk-python)](ttps://test.pypi.org/project/clarity-api-sdk-python/)
27
+ ![python](https://img.shields.io/badge/python-3.11%2B-blue)
28
+
29
+ A Python SDK for connecting to the CTI API server, with structured logging included.
30
+
31
+ ## Installation
32
+
33
+ ```bash
34
+ pip install clarity-api-sdk-python
35
+ ```
36
+
37
+ ## Logging
38
+
39
+ Logging support is built with [structlog](https://pypi.org/project/structlog/).
40
+
41
+ Set the root logger by setting the environment variable `LOG_LEVEL`. Otherwise, the default root logging is set to `INFO`.
42
+
43
+ ```python
44
+ """Example"""
45
+
46
+ import logging
47
+
48
+ from cti.logger import initialize_logger, get_logger, ExternalLoggerConfig
49
+
50
+ initialize_logger(
51
+ external_logger_configurations=[
52
+ ExternalLoggerConfig(name="urllib3"),
53
+ ExternalLoggerConfig(name="httpcore"),
54
+ ExternalLoggerConfig(name="httpx"),
55
+ ExternalLoggerConfig(name="httpx_auth"),
56
+ ExternalLoggerConfig(name="httpx_retries"),
57
+ ],
58
+ handlers=[logging.FileHandler("app.log")]
59
+ )
60
+
61
+ logger_a = get_logger("logger_a")
62
+ logger_b = get_logger("logger_b", "WARNING")
63
+
64
+ # root_logger = logging.getLogger()
65
+ # root_logger.setLevel("DEBUG")
66
+
67
+ logger_a.info("This is info message from logger_a")
68
+ logger_a.critical("This is critical message from logger_a")
69
+
70
+ # Dynamically change the log level of logger_a to WARNING
71
+ print("\nChanging logger_a level to WARNING...\n")
72
+ logging.getLogger("logger_a").setLevel(logging.WARNING)
73
+
74
+ logger_a.info("This info message from logger_a should NOT be visible.")
75
+ logger_a.warning("This is a new warning message from logger_a.")
76
+
77
+ logger_b.info("This info message from logger_b should NOT be visible.")
78
+ logger_b.warning("This is warning message from logger_b")
79
+ ```
@@ -0,0 +1,57 @@
1
+ # Clarity API SDK for Python
2
+
3
+ [![PyPI - Downloads](https://badge.fury.io/py/clarity-api-sdk-python.svg)](https://pypi.org/project/clarity-api-sdk-python/)
4
+ [![Downloads](https://pepy.tech/badge/clarity-api-sdk-python)](ttps://test.pypi.org/project/clarity-api-sdk-python/)
5
+ ![python](https://img.shields.io/badge/python-3.11%2B-blue)
6
+
7
+ A Python SDK for connecting to the CTI API server, with structured logging included.
8
+
9
+ ## Installation
10
+
11
+ ```bash
12
+ pip install clarity-api-sdk-python
13
+ ```
14
+
15
+ ## Logging
16
+
17
+ Logging support is built with [structlog](https://pypi.org/project/structlog/).
18
+
19
+ Set the root logger by setting the environment variable `LOG_LEVEL`. Otherwise, the default root logging is set to `INFO`.
20
+
21
+ ```python
22
+ """Example"""
23
+
24
+ import logging
25
+
26
+ from cti.logger import initialize_logger, get_logger, ExternalLoggerConfig
27
+
28
+ initialize_logger(
29
+ external_logger_configurations=[
30
+ ExternalLoggerConfig(name="urllib3"),
31
+ ExternalLoggerConfig(name="httpcore"),
32
+ ExternalLoggerConfig(name="httpx"),
33
+ ExternalLoggerConfig(name="httpx_auth"),
34
+ ExternalLoggerConfig(name="httpx_retries"),
35
+ ],
36
+ handlers=[logging.FileHandler("app.log")]
37
+ )
38
+
39
+ logger_a = get_logger("logger_a")
40
+ logger_b = get_logger("logger_b", "WARNING")
41
+
42
+ # root_logger = logging.getLogger()
43
+ # root_logger.setLevel("DEBUG")
44
+
45
+ logger_a.info("This is info message from logger_a")
46
+ logger_a.critical("This is critical message from logger_a")
47
+
48
+ # Dynamically change the log level of logger_a to WARNING
49
+ print("\nChanging logger_a level to WARNING...\n")
50
+ logging.getLogger("logger_a").setLevel(logging.WARNING)
51
+
52
+ logger_a.info("This info message from logger_a should NOT be visible.")
53
+ logger_a.warning("This is a new warning message from logger_a.")
54
+
55
+ logger_b.info("This info message from logger_b should NOT be visible.")
56
+ logger_b.warning("This is warning message from logger_b")
57
+ ```
@@ -0,0 +1,37 @@
1
+
2
+ [build-system]
3
+ requires = ["setuptools>=61.0"]
4
+ build-backend = "setuptools.build_meta"
5
+
6
+ [project]
7
+ name = "clarity-api-sdk-python"
8
+ version = "0.2.10"
9
+ authors = [
10
+ { name="Chesapeake Technology Inc.", email="support@chesapeaketech.com" },
11
+ ]
12
+ description = "A Python SDK to connect to the CTI Clarity API server."
13
+ readme = "README.md"
14
+ requires-python = ">=3.11"
15
+ classifiers = [
16
+ "Programming Language :: Python :: 3",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Operating System :: OS Independent",
19
+ ]
20
+ dependencies = [
21
+ "httpx>=0.28.1",
22
+ "brotli",
23
+ "h2",
24
+ "httpx_auth>=0.23.1",
25
+ "httpx-retries>=0.4.5",
26
+ "structlog",
27
+ ]
28
+
29
+ [project.optional-dependencies]
30
+ brotli = ["httpx[brotli]>=0.28.1"]
31
+ http2 = ["httpx[http2]>=0.28.1"]
32
+
33
+ [project.urls]
34
+ "Homepage" = "https://github.com/chesapeake-tech/clarity-api-sdk-python"
35
+
36
+ [tool.setuptools.packages.find]
37
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,79 @@
1
+ Metadata-Version: 2.4
2
+ Name: clarity-api-sdk-python
3
+ Version: 0.2.10
4
+ Summary: A Python SDK to connect to the CTI Clarity API server.
5
+ Author-email: "Chesapeake Technology Inc." <support@chesapeaketech.com>
6
+ Project-URL: Homepage, https://github.com/chesapeake-tech/clarity-api-sdk-python
7
+ Classifier: Programming Language :: Python :: 3
8
+ Classifier: License :: OSI Approved :: MIT License
9
+ Classifier: Operating System :: OS Independent
10
+ Requires-Python: >=3.11
11
+ Description-Content-Type: text/markdown
12
+ Requires-Dist: httpx>=0.28.1
13
+ Requires-Dist: brotli
14
+ Requires-Dist: h2
15
+ Requires-Dist: httpx_auth>=0.23.1
16
+ Requires-Dist: httpx-retries>=0.4.5
17
+ Requires-Dist: structlog
18
+ Provides-Extra: brotli
19
+ Requires-Dist: httpx[brotli]>=0.28.1; extra == "brotli"
20
+ Provides-Extra: http2
21
+ Requires-Dist: httpx[http2]>=0.28.1; extra == "http2"
22
+
23
+ # Clarity API SDK for Python
24
+
25
+ [![PyPI - Downloads](https://badge.fury.io/py/clarity-api-sdk-python.svg)](https://pypi.org/project/clarity-api-sdk-python/)
26
+ [![Downloads](https://pepy.tech/badge/clarity-api-sdk-python)](ttps://test.pypi.org/project/clarity-api-sdk-python/)
27
+ ![python](https://img.shields.io/badge/python-3.11%2B-blue)
28
+
29
+ A Python SDK for connecting to the CTI API server, with structured logging included.
30
+
31
+ ## Installation
32
+
33
+ ```bash
34
+ pip install clarity-api-sdk-python
35
+ ```
36
+
37
+ ## Logging
38
+
39
+ Logging support is built with [structlog](https://pypi.org/project/structlog/).
40
+
41
+ Set the root logger by setting the environment variable `LOG_LEVEL`. Otherwise, the default root logging is set to `INFO`.
42
+
43
+ ```python
44
+ """Example"""
45
+
46
+ import logging
47
+
48
+ from cti.logger import initialize_logger, get_logger, ExternalLoggerConfig
49
+
50
+ initialize_logger(
51
+ external_logger_configurations=[
52
+ ExternalLoggerConfig(name="urllib3"),
53
+ ExternalLoggerConfig(name="httpcore"),
54
+ ExternalLoggerConfig(name="httpx"),
55
+ ExternalLoggerConfig(name="httpx_auth"),
56
+ ExternalLoggerConfig(name="httpx_retries"),
57
+ ],
58
+ handlers=[logging.FileHandler("app.log")]
59
+ )
60
+
61
+ logger_a = get_logger("logger_a")
62
+ logger_b = get_logger("logger_b", "WARNING")
63
+
64
+ # root_logger = logging.getLogger()
65
+ # root_logger.setLevel("DEBUG")
66
+
67
+ logger_a.info("This is info message from logger_a")
68
+ logger_a.critical("This is critical message from logger_a")
69
+
70
+ # Dynamically change the log level of logger_a to WARNING
71
+ print("\nChanging logger_a level to WARNING...\n")
72
+ logging.getLogger("logger_a").setLevel(logging.WARNING)
73
+
74
+ logger_a.info("This info message from logger_a should NOT be visible.")
75
+ logger_a.warning("This is a new warning message from logger_a.")
76
+
77
+ logger_b.info("This info message from logger_b should NOT be visible.")
78
+ logger_b.warning("This is warning message from logger_b")
79
+ ```
@@ -0,0 +1,14 @@
1
+ README.md
2
+ pyproject.toml
3
+ src/clarity_api_sdk_python.egg-info/PKG-INFO
4
+ src/clarity_api_sdk_python.egg-info/SOURCES.txt
5
+ src/clarity_api_sdk_python.egg-info/dependency_links.txt
6
+ src/clarity_api_sdk_python.egg-info/requires.txt
7
+ src/clarity_api_sdk_python.egg-info/top_level.txt
8
+ src/cti/__init__.py
9
+ src/cti/main.py
10
+ src/cti/api/__init__.py
11
+ src/cti/api/async_client.py
12
+ src/cti/api/client.py
13
+ src/cti/logger/__init__.py
14
+ src/cti/logger/logger.py
@@ -0,0 +1,12 @@
1
+ httpx>=0.28.1
2
+ brotli
3
+ h2
4
+ httpx_auth>=0.23.1
5
+ httpx-retries>=0.4.5
6
+ structlog
7
+
8
+ [brotli]
9
+ httpx[brotli]>=0.28.1
10
+
11
+ [http2]
12
+ httpx[http2]>=0.28.1
File without changes
@@ -0,0 +1,3 @@
1
+ """API client"""
2
+
3
+ from .client import ClarityApiClient
@@ -0,0 +1,127 @@
1
+ """Async client for clarity API"""
2
+
3
+ import os
4
+ import uuid
5
+
6
+ from httpx import AsyncClient, HTTPStatusError, RequestError, Response, URL
7
+ from httpx_auth import OAuth2ClientCredentials, OAuth2, TokenMemoryCache
8
+ from httpx_retries import Retry, RetryTransport
9
+
10
+ from cti.logger import get_logger
11
+
12
+ logger = get_logger(__name__)
13
+ OAuth2.token_cache = TokenMemoryCache()
14
+
15
+
16
+ class ClarityApiAsyncClient(AsyncClient):
17
+ """Async client for Clarity API configured with OAuth2 authentication, retry mechanism
18
+ and other defaults to connect to the clarity server.
19
+
20
+ Reuse this client instance for multiple requests for faster performance.
21
+
22
+ The authorization token is cached and shared between each client instance to minimize
23
+ calls to the https://auth.sonarwiz.io/ keycloak server.
24
+ """
25
+
26
+ def __init__(self):
27
+
28
+ # credentials for Clarity API
29
+ cti_credentials = OAuth2ClientCredentials(
30
+ token_url=(
31
+ f'{os.environ.get("KEYCLOAK_SERVER_URL", "missing KEYCLOAK_SERVER_URL")}/realms/'
32
+ f'{os.environ.get("KEYCLOAK_REALM", "missing KEYCLOAK_REALM")}'
33
+ "/protocol/openid-connect/token"
34
+ ),
35
+ client_id=os.environ.get(
36
+ "KEYCLOAK_CLIENT_ID", "missing KEYCLOAK_CLIENT_ID"
37
+ ),
38
+ client_secret=os.environ.get(
39
+ "KEYCLOAK_CLIENT_SECRET", "missing KEYCLOAK_CLIENT_SECRET"
40
+ ),
41
+ )
42
+
43
+ # retry mechanism for API requests
44
+ retry = Retry(total=12, backoff_factor=0.5)
45
+ transport = RetryTransport(retry=retry)
46
+
47
+ super().__init__(
48
+ base_url=os.environ.get("CLARITY_API_URL", "missing Clarity_API_URL"),
49
+ auth=cti_credentials,
50
+ timeout=60,
51
+ transport=transport,
52
+ http2=True,
53
+ headers={"Accept": "application/json"},
54
+ )
55
+
56
+ async def request(self, method: str, url: URL | str, **kwargs) -> Response:
57
+ """Make an async request to the Clarity API and handle exceptions. Using
58
+ this allows requests to be made concurrently and can provide significantly
59
+ improved performance. Use this to make multiple concurrent requests.
60
+
61
+ The exceptions are caught and logged, and then re-raised.
62
+
63
+ Args:
64
+ method: HTTP method (GET, POST, PUT, DELETE, etc.)
65
+ url: relative URL for the request, eg: "/api/v1/projects/12345"
66
+ kwargs: additional keyword arguments to be passed to the request
67
+
68
+ Returns:
69
+ httpx.Response: Response from the API
70
+
71
+ Raises:
72
+ RequestError: If there was an issue with the request
73
+ HTTPStatusError: If the response status code is not in the 2xx range
74
+ Exception: For any other uncaught exception
75
+ """
76
+ try:
77
+ request_id = str(uuid.uuid4())
78
+ if "headers" not in kwargs or kwargs["headers"] is None:
79
+ kwargs["headers"] = {}
80
+ # append x-request-id header to the kwargs "headers"
81
+ kwargs["headers"].update({"x-request-id": request_id})
82
+ logger.info(
83
+ "request",
84
+ extra={"url": url, "request_id": request_id},
85
+ )
86
+ # make the actual request and return the response
87
+ response = await super().request(method, url, **kwargs)
88
+ logger.info(
89
+ "response",
90
+ extra={
91
+ "request_id": request_id,
92
+ "response": {"status_code": response.status_code},
93
+ },
94
+ )
95
+ return response
96
+ except HTTPStatusError as e:
97
+ logger.error(
98
+ "http",
99
+ extra={
100
+ "request": {
101
+ "method": e.request.method,
102
+ "url": str(e.request.url),
103
+ "headers": dict(e.request.headers),
104
+ },
105
+ "error": {
106
+ "message": str(e.response.content),
107
+ "status_code": e.response.status_code,
108
+ "headers": dict(e.response.headers),
109
+ },
110
+ },
111
+ )
112
+ raise e
113
+ except RequestError as e:
114
+ logger.error(
115
+ "request",
116
+ extra={
117
+ "request": {
118
+ "method": e.request.method,
119
+ "url": str(e.request.url),
120
+ "headers": dict(e.request.headers),
121
+ },
122
+ "error": {
123
+ "message": str(e),
124
+ },
125
+ },
126
+ )
127
+ raise e
@@ -0,0 +1,124 @@
1
+ """client for clarity API"""
2
+
3
+ import os
4
+ import uuid
5
+
6
+ from httpx import Client, HTTPStatusError, RequestError, Response, URL
7
+ from httpx_auth import OAuth2ClientCredentials, OAuth2, TokenMemoryCache
8
+ from httpx_retries import Retry, RetryTransport
9
+
10
+ from cti.logger import get_logger
11
+
12
+ logger = get_logger(__name__)
13
+ OAuth2.token_cache = TokenMemoryCache()
14
+
15
+
16
+ class ClarityApiClient(Client):
17
+ """Client for Clarity API configured with OAuth2 authentication, retry mechanism
18
+ and other defaults to connect to the clarity server.
19
+
20
+ Reuse this client instance for multiple requests for faster performance.
21
+ """
22
+
23
+ def __init__(self):
24
+
25
+ # credentials for Clarity API
26
+ cti_credentials = OAuth2ClientCredentials(
27
+ token_url=(
28
+ f'{os.environ.get("KEYCLOAK_SERVER_URL", "missing KEYCLOAK_SERVER_URL")}/realms/'
29
+ f'{os.environ.get("KEYCLOAK_REALM", "missing KEYCLOAK_REALM")}'
30
+ "/protocol/openid-connect/token"
31
+ ),
32
+ client_id=os.environ.get(
33
+ "KEYCLOAK_CLIENT_ID", "missing KEYCLOAK_CLIENT_ID"
34
+ ),
35
+ client_secret=os.environ.get(
36
+ "KEYCLOAK_CLIENT_SECRET", "missing KEYCLOAK_CLIENT_SECRET"
37
+ ),
38
+ )
39
+
40
+ # retry mechanism for API requests
41
+ retry = Retry(total=12, backoff_factor=0.5)
42
+ transport = RetryTransport(retry=retry)
43
+
44
+ super().__init__(
45
+ base_url=os.environ.get("CLARITY_API_URL", "missing Clarity_API_URL"),
46
+ auth=cti_credentials,
47
+ timeout=60,
48
+ transport=transport,
49
+ http2=True,
50
+ headers={"Accept": "application/json"},
51
+ )
52
+
53
+ def request(self, method: str, url: URL | str, **kwargs) -> Response:
54
+ """Make a request to the Clarity API and handle exceptions.
55
+
56
+ The exceptions are caught and logged, and then re-raised.
57
+
58
+ Args:
59
+ method: HTTP method (GET, POST, PUT, DELETE, etc.)
60
+ url: relative URL for the request, eg: "/api/v1/projects/12345"
61
+ kwargs: additional keyword arguments to be passed to the request
62
+
63
+ Returns:
64
+ httpx.Response: Response from the API
65
+
66
+ Raises:
67
+ RequestError: If there was an issue with the request
68
+ HTTPStatusError: If the response status code is not in the 2xx range
69
+ Exception: For any other uncaught exception
70
+ """
71
+ try:
72
+ request_id = str(uuid.uuid4())
73
+ if "headers" not in kwargs or kwargs["headers"] is None:
74
+ kwargs["headers"] = {}
75
+ # append x-request-id header to the kwargs "headers"
76
+ kwargs["headers"].update({"x-request-id": request_id})
77
+ logger.info(
78
+ "request",
79
+ extra={"url": url, "request_id": request_id},
80
+ )
81
+ # make the actual request and return the response
82
+ response = super().request(method, url, **kwargs)
83
+ # raise an exception if the response status is not in the 2xx range
84
+ response.raise_for_status()
85
+ logger.info(
86
+ "response",
87
+ extra={
88
+ "request_id": request_id,
89
+ "response": {"status_code": response.status_code},
90
+ },
91
+ )
92
+ return response
93
+ except HTTPStatusError as e:
94
+ logger.error(
95
+ "http",
96
+ extra={
97
+ "request": {
98
+ "method": e.request.method,
99
+ "url": str(e.request.url),
100
+ "headers": dict(e.request.headers),
101
+ },
102
+ "error": {
103
+ "message": str(e.response.content),
104
+ "status_code": e.response.status_code,
105
+ "headers": dict(e.response.headers),
106
+ },
107
+ },
108
+ )
109
+ raise e
110
+ except RequestError as e:
111
+ logger.error(
112
+ "request",
113
+ extra={
114
+ "request": {
115
+ "method": e.request.method,
116
+ "url": str(e.request.url),
117
+ "headers": dict(e.request.headers),
118
+ },
119
+ "error": {
120
+ "message": str(e),
121
+ },
122
+ },
123
+ )
124
+ raise e
@@ -0,0 +1,3 @@
1
+ """import modules"""
2
+
3
+ from .logger import get_logger, initialize_logger, ExternalLoggerConfig # noqa
@@ -0,0 +1,292 @@
1
+ """Structured Logger configuration."""
2
+
3
+ from collections import OrderedDict
4
+ from dataclasses import dataclass
5
+ import logging
6
+ import os
7
+ import socket
8
+ import sys
9
+ import urllib.error
10
+ import urllib.request
11
+
12
+ import structlog
13
+
14
+
15
+ @dataclass
16
+ class ExternalLoggerConfig:
17
+ """External logger configuration parameters."""
18
+
19
+ def __init__(
20
+ self, name: str, level: int = logging.CRITICAL, disabled=False, propagate=False
21
+ ):
22
+ """Construct an external logger configuration to configure external logging.
23
+
24
+ Args:
25
+ name: The name of the external logger
26
+ level: The level to set for the logger
27
+ disabled: Whether or not to disable logging
28
+ propagate: Whether or not to propagate the log messages to the parent logger
29
+ """
30
+ self.name = name
31
+ self.level = level
32
+ self.disabled = disabled
33
+ self.propagate = propagate
34
+
35
+
36
+ def get_logger(
37
+ name: str, level: int | str | None = None
38
+ ) -> structlog.stdlib.BoundLogger:
39
+ """Creates a structlog logger with the specified name.
40
+
41
+ Args:
42
+ name (str): The logger name.
43
+ level (int | str | None, optional): The logging level for this logger.
44
+ If None, the root logger's level is used. Defaults to None.
45
+
46
+ Returns:
47
+ structlog.stdlib.BoundLogger: The structlog logger.
48
+ """
49
+ logger = structlog.get_logger(name)
50
+ if level:
51
+ # To set the level, we need to get the actual standard library logger instance.
52
+ stdlib_logger = logging.getLogger(name)
53
+ stdlib_logger.setLevel(level)
54
+ return logger
55
+
56
+
57
+ def _flatten_extra_processor(_, __, event_dict):
58
+ """A structlog processor to flatten the 'extra' dictionary into the top level.
59
+
60
+ Args:
61
+ event_dict (structlog.typing.EventDict): The log event dictionary to be processed.
62
+
63
+ Returns:
64
+ structlog.typing.EventDict: The processed event dictionary with extra flattened.
65
+ """
66
+ if "extra" in event_dict:
67
+ extra_data = event_dict.pop("extra")
68
+ if isinstance(extra_data, dict):
69
+ # Merge extra data into the main event_dict
70
+ event_dict.update(extra_data)
71
+ return event_dict
72
+
73
+
74
+ def _get_aws_metadata() -> dict:
75
+ """A structlog processor to add AWS instance metadata to the log entry.
76
+ This is an expensive call, so only call it when necessary.
77
+
78
+ This function attempts to retrieve the instance-id, instance-type, and
79
+ public-ipv4 from the AWS metadata service with a short timeout. If the
80
+ information cannot be retrieved, 'unknown' is used as a fallback value.
81
+
82
+ For more details, see https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/instancedata-data-retrieval.html
83
+
84
+ Returns:
85
+ dict: A dictionary with AWS metadata.
86
+ """
87
+
88
+ def _get_metadata(key: str, timeout: int = 1) -> str:
89
+ """Helper to fetch a metadata key with a timeout."""
90
+ try:
91
+ # yes, a hard coded IPv4 is best practice
92
+ url = f"http://169.254.169.245/latest/meta-data/{key}"
93
+ with urllib.request.urlopen(url, timeout=timeout) as response:
94
+ return response.read().decode("utf-8")
95
+ except (urllib.error.URLError, socket.timeout):
96
+ return "unknown"
97
+
98
+ return {
99
+ "aws_instance_id": _get_metadata("instance-id"),
100
+ "aws_instance_type": _get_metadata("instance-type"),
101
+ "aws_public_ipv4": _get_metadata("public-ipv4"),
102
+ }
103
+
104
+
105
+ def _order_event_dict(
106
+ _logger: structlog.typing.WrappedLogger,
107
+ _method_name: str,
108
+ event_dict: structlog.typing.EventDict,
109
+ ) -> structlog.typing.EventDict:
110
+ """A structlog processor to reorder the event dictionary.
111
+
112
+ This processor ensures that certain important keys ("timestamp", "job_id",
113
+ "level", "event") appear at the beginning of the log entry, making the
114
+ logs more readable and consistent. The specified keys are ordered first,
115
+ and any other keys in the event dictionary are appended afterwards in the
116
+ order they were originally.
117
+
118
+ Args:
119
+ event_dict (structlog.typing.EventDict): The log event dictionary to be reordered.
120
+
121
+ Returns:
122
+ collections.OrderedDict: The event dictionary with keys reordered.
123
+ """
124
+ key_order = ["timestamp", "jobId", "level", "event"]
125
+ # Use OrderedDict to preserve the order of the remaining keys.
126
+ ordered_event_dict = OrderedDict()
127
+ for key in key_order:
128
+ if key in event_dict:
129
+ ordered_event_dict[key] = event_dict.pop(key)
130
+
131
+ # Add the rest of the items, which will now be at the end.
132
+ ordered_event_dict.update(event_dict.items())
133
+
134
+ return ordered_event_dict
135
+
136
+
137
+ def _secret_redaction_processor(_, __, event_dict):
138
+ """A structlog processor to redact sensitive information in log entries.
139
+
140
+ Args:
141
+ event_dict (structlog.typing.EventDict): The log event dictionary to be processed.
142
+
143
+ Returns:
144
+ structlog.typing.EventDict: The processed event dictionary.
145
+ """
146
+ sensitive_keys = ["password", "api_key", "token", "SecretString"]
147
+
148
+ def redact_dict(data):
149
+ """Recursively redact sensitive keys in nested dictionaries.
150
+
151
+ Args:
152
+ data (dict): The dictionary to redact sensitive keys.
153
+
154
+ Returns:
155
+ dict: The redacted dictionary.
156
+ """
157
+ if isinstance(data, dict):
158
+ for key, value in data.items():
159
+ if key in sensitive_keys:
160
+ data[key] = "[REDACTED]"
161
+ elif isinstance(value, dict):
162
+ redact_dict(value)
163
+ return data
164
+
165
+ # Redact top-level sensitive keys
166
+ for key in sensitive_keys:
167
+ if key in event_dict:
168
+ event_dict[key] = "[REDACTED]"
169
+
170
+ # Check for nested dictionaries and redact them
171
+ for key, value in event_dict.items():
172
+ if isinstance(value, dict):
173
+ redact_dict(value)
174
+
175
+ return event_dict
176
+
177
+
178
+ def initialize_logger(
179
+ initial_context: dict | None = None,
180
+ external_logger_configurations: list[ExternalLoggerConfig] | None = None,
181
+ handlers: list[logging.Handler] | None = None,
182
+ ) -> None:
183
+ """Configures logging for the application using structlog.
184
+
185
+ This function sets up `structlog` to produce structured JSON logs. It
186
+ configures a chain of processors to enrich log entries with contextual
187
+ information such as timestamps, host details, and log levels. The standard
188
+ Python `logging` module is configured to act as the sink, directing the
189
+ formatted JSON logs to standard output in JSONL format.
190
+
191
+ The processor chain includes:
192
+ - Merging context variables.
193
+ - Filtering by log level.
194
+ - Adding logger name and log level.
195
+ - `add_host_info`: Custom processor to add hostname and IP.
196
+ - `TimeStamper`: Adds an ISO formatted timestamp.
197
+ - `PositionalArgumentsFormatter`: Formats positional arguments into the message.
198
+ - Exception and stack info renderers.
199
+ - `UnicodeDecoder`: Decodes unicode characters.
200
+ - `order_event_dict`: Custom processor to ensure a consistent key order.
201
+ - `JSONRenderer`: Renders the final log entry as a JSON string.
202
+
203
+ Args:
204
+ initial_context (dict, optional): A dictionary of key-value pairs to
205
+ bind to the context at the start of the application. These values
206
+ will be included in every log message. Defaults to None.
207
+ external_logger_configurations (list[ExternalLoggerConfig], optional): A list of configuration
208
+ handlers (list[logging.Handler], optional): A list of handlers to send log records to.
209
+ """
210
+ # Configure standard logging to be the sink for structlog.
211
+ # The format="%(message)s" is important because structlog will format the log record
212
+ # into a JSON string and pass it as the 'message'.
213
+ formatter = logging.Formatter("%(message)s")
214
+
215
+ # Create a handler for stdout
216
+ stdout_handler = logging.StreamHandler(sys.stdout)
217
+ stdout_handler.setFormatter(formatter)
218
+
219
+ # Get the root logger and add handlers
220
+ root_logger = logging.getLogger()
221
+ root_logger.addHandler(stdout_handler)
222
+ for handler in handlers or []:
223
+ handler.setFormatter(formatter)
224
+ root_logger.addHandler(handler)
225
+ root_logger.setLevel(_level())
226
+
227
+ # configure external loggers
228
+ if external_logger_configurations:
229
+ for config in external_logger_configurations:
230
+ logger = logging.getLogger(config.name)
231
+ logger.setLevel(config.level)
232
+ logger.disabled = config.disabled
233
+ logger.propagate = config.propagate
234
+
235
+ # Configure structlog to produce JSON logs.
236
+ structlog.configure(
237
+ processors=[
238
+ # Merge contextvars into the event dictionary.
239
+ structlog.contextvars.merge_contextvars,
240
+ # Filter logs by level.
241
+ structlog.stdlib.filter_by_level,
242
+ # Add logger name and log level to the event dict.
243
+ structlog.stdlib.add_logger_name,
244
+ structlog.stdlib.add_log_level,
245
+ # Add a timestamp in ISO format.
246
+ structlog.processors.TimeStamper(fmt="iso"),
247
+ # Render positional arguments into the message.
248
+ structlog.stdlib.PositionalArgumentsFormatter(),
249
+ # If the log record contains an exception, render it.
250
+ structlog.processors.StackInfoRenderer(),
251
+ structlog.processors.format_exc_info,
252
+ _flatten_extra_processor,
253
+ _secret_redaction_processor,
254
+ # Decode unicode characters.
255
+ structlog.processors.UnicodeDecoder(),
256
+ # partial sort of key values.
257
+ _order_event_dict,
258
+ # Render the final event dict as JSON.
259
+ structlog.processors.JSONRenderer(),
260
+ ],
261
+ # Use a standard library logger factory.
262
+ logger_factory=structlog.stdlib.LoggerFactory(),
263
+ # Use a wrapper class to provide standard logging methods.
264
+ wrapper_class=structlog.stdlib.BoundLogger,
265
+ # Cache the logger on first use.
266
+ cache_logger_on_first_use=True,
267
+ )
268
+
269
+ # Gather static context information at startup.
270
+ static_context = {}
271
+ static_context.update(_get_aws_metadata())
272
+
273
+ # Merge with any context provided at startup.
274
+ if initial_context:
275
+ static_context.update(initial_context)
276
+
277
+ # Bind the initial context if it's provided.
278
+ # This context will be included in all logs.
279
+ if initial_context:
280
+ structlog.contextvars.bind_contextvars(**static_context)
281
+
282
+
283
+ def _level() -> str:
284
+ """Get the log level for the logger.
285
+
286
+ The log level is determined by the `LOG_LEVEL` environment variable.
287
+ If the environment variable is not set, it defaults to "ERROR".
288
+
289
+ Returns:
290
+ str: The log level as a string (e.g., "DEBUG", "INFO", "ERROR").
291
+ """
292
+ return os.getenv("LOG_LEVEL", "INFO").upper()
@@ -0,0 +1,33 @@
1
+ """Example"""
2
+
3
+ import logging
4
+
5
+ from cti.logger import initialize_logger, get_logger, ExternalLoggerConfig
6
+
7
+ initialize_logger(
8
+ external_logger_configurations=[
9
+ ExternalLoggerConfig(name="urllib3"),
10
+ ExternalLoggerConfig(name="httpcore"),
11
+ ExternalLoggerConfig(name="httpx"),
12
+ ExternalLoggerConfig(name="httpx_auth"),
13
+ ExternalLoggerConfig(name="httpx_retries"),
14
+ ]
15
+ )
16
+
17
+ logger_a = get_logger("logger_a")
18
+ logger_b = get_logger("logger_b", "WARNING")
19
+
20
+ # root_logger = logging.getLogger()
21
+ # root_logger.setLevel("DEBUG")
22
+
23
+ logger_a.info("This is info message from logger_a")
24
+ logger_a.critical("This is critical message from logger_a")
25
+
26
+ # Dynamically change the log level of logger_a to WARNING
27
+ print("\nChanging logger_a level to WARNING...\n")
28
+ logging.getLogger("logger_a").setLevel(logging.WARNING)
29
+
30
+ logger_a.info("This info message from logger_a should NOT be visible.")
31
+ logger_a.warning("This is a new warning message from logger_a.")
32
+
33
+ logger_b.warning("This is warning message from logger_b")