Skip to content

Commit ce08f48

Browse files
committed
Resolve #8: Add savant_rs based logging
* can't log configuration errors as the logger isn't configured yet, but if successful, the current config values are logged (api_key is scrapped) * an option for log spec to be YAML dict in addition to just string, there are tests for this * log to STDOUT as this is common for k8s * termination signal handler for asyncio to shut down gracefully * debug as a mininal logging level seems enough, no need for trace level right now
1 parent 20e2ea9 commit ce08f48

File tree

14 files changed

+271
-58
lines changed

14 files changed

+271
-58
lines changed

.gitignore

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -186,7 +186,7 @@ cython_debug/
186186
# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
187187
# and can be added to the global gitignore or merged into this file. However, if you prefer,
188188
# you could uncomment the following to ignore the entire vscode folder
189-
# .vscode/
189+
.vscode/
190190

191191
# Ruff stuff:
192192
.ruff_cache/

savant_cloudpin/__main__.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,25 @@
11
import asyncio
22

3-
from savant_cloudpin.cfg import load_config
3+
from savant_rs.py.log import get_logger, init_logging
4+
5+
from savant_cloudpin.cfg import SENSITIVE_KEYS, dump_to_yaml, load_config
46
from savant_cloudpin.services import create_service
57

68

79
async def serve() -> None:
810
config = load_config()
11+
12+
init_logging(config.observability.log_spec)
13+
logger = get_logger(__name__)
14+
15+
logger.info("Configuration loaded")
16+
config_yaml = dump_to_yaml(config, scrap_keys=SENSITIVE_KEYS)
17+
logger.debug(f"Configuration details:\n{config_yaml}")
18+
19+
logger.info("Running main loop ...")
920
async with create_service(config) as service:
1021
await service.run()
22+
logger.info("Main loop stopped")
1123

1224

1325
asyncio.run(serve())

savant_cloudpin/cfg/__init__.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
1-
from savant_cloudpin.cfg._bootstrap import load_config
1+
from savant_cloudpin.cfg._bootstrap import dump_to_yaml, load_config
22
from savant_cloudpin.cfg._models import (
3+
SENSITIVE_KEYS,
34
ClientServiceConfig,
45
ClientSSLConfig,
56
ClientWSConfig,
7+
ObservabilityConfig,
68
ReaderConfig,
79
ServerServiceConfig,
810
ServerSSLConfig,
@@ -14,8 +16,11 @@
1416
"ClientServiceConfig",
1517
"ClientSSLConfig",
1618
"ClientWSConfig",
19+
"dump_to_yaml",
1720
"load_config",
21+
"ObservabilityConfig",
1822
"ReaderConfig",
23+
"SENSITIVE_KEYS",
1924
"ServerServiceConfig",
2025
"ServerSSLConfig",
2126
"ServerWSConfig",

savant_cloudpin/cfg/_bootstrap.py

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
1+
import copy
12
import os.path
23
import re
4+
from collections.abc import Sequence
35
from typing import Any
46

5-
from omegaconf import OmegaConf
6-
from omegaconf.base import SCMode
7-
from omegaconf.dictconfig import DictConfig
7+
from omegaconf import DictConfig, ListConfig, OmegaConf, SCMode
88

99
from savant_cloudpin.cfg import _utils as utils
1010
from savant_cloudpin.cfg._defaults import (
@@ -62,15 +62,28 @@ def merge_env_config(
6262
return cfg
6363

6464

65+
def join_log_spec(cfg: DictConfig | ListConfig) -> None:
66+
if not isinstance(cfg, DictConfig):
67+
return
68+
if "observability" not in cfg or "log_spec" not in cfg.observability:
69+
return
70+
if not isinstance(cfg.observability.log_spec, (DictConfig, dict)):
71+
return
72+
cfg.observability.log_spec = ",".join(
73+
f"{k}={v}" for k, v in cfg.observability.log_spec.items()
74+
)
75+
76+
6577
def load_config(
6678
args_list: list[str] | None = None,
6779
) -> ClientServiceConfig | ServerServiceConfig:
6880
cli_cfg = OmegaConf.from_cli(args_list)
69-
env_cfg = utils.as_value_dict(utils.env_override(DEFAULT_LOAD_CONFIG))
81+
env_cfg = utils.env_override(DEFAULT_LOAD_CONFIG)
7082
cfg = OmegaConf.merge(env_cfg, cli_cfg)
7183

7284
yml_exists = cfg.config and os.path.exists(cfg.config)
7385
yml_cfg = OmegaConf.load(cfg.config) if yml_exists else OmegaConf.create({})
86+
join_log_spec(yml_cfg)
7487

7588
cfg = OmegaConf.merge(yml_cfg, env_cfg, cli_cfg)
7689
assert isinstance(cfg, DictConfig) and isinstance(yml_cfg, DictConfig)
@@ -88,3 +101,18 @@ def load_config(
88101
return validated_dataclass(cfg, ClientServiceConfig)
89102
case _:
90103
raise ValueError("Invalid service mode")
104+
105+
106+
def dump_to_yaml(
107+
config: ClientServiceConfig | ServerServiceConfig, scrap_keys: Sequence[str] = ()
108+
) -> str:
109+
if scrap_keys:
110+
config = copy.deepcopy(config)
111+
utils.scrap_sensitive_keys(config, scrap_keys)
112+
113+
mode = "server" if isinstance(config, ServerServiceConfig) else "client"
114+
summary = OmegaConf.to_container(
115+
OmegaConf.structured(config), structured_config_mode=SCMode.DICT
116+
)
117+
summary = OmegaConf.merge(dict(mode=mode), summary)
118+
return OmegaConf.to_yaml(summary)

savant_cloudpin/cfg/_defaults.py

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,27 +2,28 @@
22
ClientServiceConfig,
33
ClientSSLConfig,
44
ClientWSConfig,
5-
LoadConfig,
5+
ObservabilityConfig,
66
ReaderConfig,
77
ServerServiceConfig,
88
ServerSSLConfig,
99
ServerWSConfig,
1010
WriterConfig,
1111
)
1212

13+
DEFAULT_LOAD_CONFIG = dict(
14+
config="./cloudpin.yml",
15+
mode="client",
16+
)
17+
1318
DEFAULT_SOURCE_CONFIG = ReaderConfig(
14-
results_queue_size=100,
1519
url="???",
1620
)
1721
DEFAULT_SINK_CONFIG = WriterConfig(
18-
max_inflight_messages=100,
1922
url="???",
2023
)
2124

22-
23-
DEFAULT_LOAD_CONFIG = LoadConfig(
24-
config="./cloudpin.yml",
25-
mode="client",
25+
DEFAULT_OBSERVABILITY_CONFIG = ObservabilityConfig(
26+
log_spec="${oc.env:LOGLEVEL,warning}"
2627
)
2728

2829
DEFAULT_CLIENT_CONFIG = ClientServiceConfig(
@@ -36,9 +37,9 @@
3637
check_hostname=False,
3738
),
3839
),
39-
io_timeout=0.1,
4040
source=DEFAULT_SOURCE_CONFIG,
4141
sink=DEFAULT_SINK_CONFIG,
42+
observability=DEFAULT_OBSERVABILITY_CONFIG,
4243
)
4344

4445
DEFAULT_SERVER_CONFIG = ServerServiceConfig(
@@ -52,7 +53,7 @@
5253
client_cert_required=True,
5354
),
5455
),
55-
io_timeout=0.1,
5656
source=DEFAULT_SOURCE_CONFIG,
5757
sink=DEFAULT_SINK_CONFIG,
58+
observability=DEFAULT_OBSERVABILITY_CONFIG,
5859
)

savant_cloudpin/cfg/_models.py

Lines changed: 21 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,17 @@
1-
from dataclasses import dataclass, replace
2-
from typing import Self
1+
from dataclasses import dataclass, field, replace
2+
from typing import Protocol, Self
33

44
from savant_rs import zmq
55

66
from savant_cloudpin.cfg._utils import to_map_config
77

8+
SENSITIVE_KEYS = ("api_key",)
9+
810

911
@dataclass
1012
class ReaderConfig:
11-
results_queue_size: int
1213
url: str
14+
results_queue_size: int = 100
1315
receive_timeout: int | None = None
1416
receive_hwm: int | None = None
1517
topic_prefix_spec: str | None = None
@@ -29,8 +31,8 @@ def to_args(self) -> tuple[zmq.ReaderConfig, int]:
2931

3032
@dataclass
3133
class WriterConfig:
32-
max_inflight_messages: int
3334
url: str
35+
max_inflight_messages: int = 100
3436
send_timeout: int | None = None
3537
send_retries: int | None = None
3638
send_hwm: int | None = None
@@ -81,23 +83,30 @@ class ClientWSConfig:
8183

8284

8385
@dataclass
84-
class BaseServiceConfig:
85-
io_timeout: float
86+
class ObservabilityConfig:
87+
log_spec: str | None = "warning"
88+
89+
90+
class BaseServiceConfig(Protocol):
8691
source: ReaderConfig
8792
sink: WriterConfig
93+
io_timeout: float
94+
observability: ObservabilityConfig
8895

8996

9097
@dataclass
9198
class ServerServiceConfig(BaseServiceConfig):
9299
websockets: ServerWSConfig
100+
source: ReaderConfig
101+
sink: WriterConfig
102+
io_timeout: float = 0.1
103+
observability: ObservabilityConfig = field(default_factory=ObservabilityConfig)
93104

94105

95106
@dataclass
96107
class ClientServiceConfig(BaseServiceConfig):
97108
websockets: ClientWSConfig
98-
99-
100-
@dataclass
101-
class LoadConfig:
102-
config: str | None
103-
mode: str | None
109+
source: ReaderConfig
110+
sink: WriterConfig
111+
io_timeout: float = 0.1
112+
observability: ObservabilityConfig = field(default_factory=ObservabilityConfig)

savant_cloudpin/cfg/_utils.py

Lines changed: 40 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
import dataclasses
2-
from collections.abc import Mapping
2+
import operator
3+
from collections.abc import Mapping, Sequence
34
from dataclasses import asdict
4-
from typing import Any
5+
from typing import Any, cast
6+
7+
from omegaconf import DictConfig
58

69
ENV_PREFIX = "CLOUDPIN"
710

@@ -32,16 +35,21 @@ def as_value_dict(obj: Any) -> dict[str, Any]:
3235
return drop_none_values(dct)
3336

3437

35-
def env_override(
36-
obj: Any,
38+
def env_override[T](
39+
obj: T,
3740
default: str | None = None,
3841
prefix: str = ENV_PREFIX,
39-
) -> Any:
42+
) -> T:
4043
if isinstance(obj, type):
4144
raise ValueError("Instance is expected")
4245
updates = dict[str, Any]()
43-
for field in dataclasses.fields(obj):
44-
name, val = field.name, getattr(obj, field.name)
46+
if dataclasses.is_dataclass(obj):
47+
items = ((f.name, getattr(obj, f.name)) for f in dataclasses.fields(obj))
48+
elif isinstance(obj, dict):
49+
items = obj.items()
50+
else:
51+
raise TypeError("Unsupported type")
52+
for name, val in items:
4553
env_name = f"{prefix}_{name.upper()}"
4654
match val:
4755
case str() | int() | float() if default is None:
@@ -57,4 +65,29 @@ def env_override(
5765
case _:
5866
updates[name] = env_override(val, default, env_name)
5967

68+
if isinstance(obj, dict):
69+
obj.update(**updates)
70+
return cast(T, obj)
6071
return dataclasses.replace(obj, **updates)
72+
73+
74+
def scrap_sensitive_keys(obj: Any, sensitive_keys: Sequence[str]) -> None:
75+
if dataclasses.is_dataclass(obj):
76+
items = ((f.name, getattr(obj, f.name)) for f in dataclasses.fields(obj))
77+
setkey = setattr
78+
elif isinstance(obj, (dict, DictConfig)):
79+
items = obj.items()
80+
setkey = operator.setitem
81+
else:
82+
raise TypeError(f"Unsupported type {type(obj)}")
83+
84+
for key, val in items:
85+
match val:
86+
case str() if isinstance(key, str) and key in sensitive_keys:
87+
setkey(obj, key, "*****")
88+
case int() if isinstance(key, str) and key in sensitive_keys:
89+
setkey(obj, key, 0)
90+
case str() | int() | float() | bool() | None:
91+
continue
92+
case _:
93+
scrap_sensitive_keys(val, sensitive_keys)
Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
from typing import overload
2+
3+
from savant_rs.py.log import get_logger
4+
15
from savant_cloudpin.cfg import ClientServiceConfig, ServerServiceConfig
26
from savant_cloudpin.services._client import ClientService
37
from savant_cloudpin.services._server import ServerService
@@ -8,11 +12,21 @@
812
"ServerService",
913
]
1014

15+
logger = get_logger(__package__ or __name__)
16+
1117

18+
@overload
19+
def create_service(config: ClientServiceConfig) -> ClientService: ...
20+
@overload
21+
def create_service(config: ServerServiceConfig) -> ServerService: ...
1222
def create_service(
1323
config: ClientServiceConfig | ServerServiceConfig,
1424
) -> ClientService | ServerService:
15-
if isinstance(config, ServerServiceConfig):
16-
return ServerService(config)
17-
else:
18-
return ClientService(config)
25+
try:
26+
if isinstance(config, ServerServiceConfig):
27+
return ServerService(config)
28+
else:
29+
return ClientService(config)
30+
except:
31+
logger.exception("Error service configuring")
32+
raise

0 commit comments

Comments
 (0)