ergon-framework-python 0.1.0__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 (82) hide show
  1. ergon/__init__.py +13 -0
  2. ergon/bootstrap/src/__project__/__init__.py +0 -0
  3. ergon/bootstrap/src/__project__/_observability/docker-compose.telemetry.yml +124 -0
  4. ergon/bootstrap/src/__project__/_observability/grafana.yaml +17 -0
  5. ergon/bootstrap/src/__project__/_observability/loki.yaml +48 -0
  6. ergon/bootstrap/src/__project__/_observability/otel-collector-config.yaml +53 -0
  7. ergon/bootstrap/src/__project__/_observability/prometheus.yaml +11 -0
  8. ergon/bootstrap/src/__project__/_observability/tempo.yaml +24 -0
  9. ergon/bootstrap/src/__project__/connectors/__init__.py +0 -0
  10. ergon/bootstrap/src/__project__/main.py +9 -0
  11. ergon/bootstrap/src/__project__/tasks/__init__.py +0 -0
  12. ergon/bootstrap/src/__project__/tasks/constants.py +13 -0
  13. ergon/bootstrap/src/__project__/tasks/example_task/__init__.py +0 -0
  14. ergon/bootstrap/src/__project__/tasks/example_task/config.py +4 -0
  15. ergon/bootstrap/src/__project__/tasks/example_task/exceptions.py +4 -0
  16. ergon/bootstrap/src/__project__/tasks/example_task/helpers.py +4 -0
  17. ergon/bootstrap/src/__project__/tasks/example_task/schemas.py +5 -0
  18. ergon/bootstrap/src/__project__/tasks/example_task/task.py +1 -0
  19. ergon/bootstrap/src/__project__/tasks/exceptions.py +0 -0
  20. ergon/bootstrap/src/__project__/tasks/helpers.py +0 -0
  21. ergon/bootstrap/src/__project__/tasks/schemas.py +0 -0
  22. ergon/bootstrap/src/__project__/tasks/settings.py +5 -0
  23. ergon/cli.py +174 -0
  24. ergon/connector/__init__.py +64 -0
  25. ergon/connector/connector.py +97 -0
  26. ergon/connector/excel/__init__.py +18 -0
  27. ergon/connector/excel/connector.py +175 -0
  28. ergon/connector/excel/models.py +24 -0
  29. ergon/connector/excel/service.py +98 -0
  30. ergon/connector/pipefy/__init__.py +21 -0
  31. ergon/connector/pipefy/async_connector.py +48 -0
  32. ergon/connector/pipefy/async_service.py +907 -0
  33. ergon/connector/pipefy/connector.py +36 -0
  34. ergon/connector/pipefy/models.py +48 -0
  35. ergon/connector/pipefy/service.py +1016 -0
  36. ergon/connector/pipefy/version.py +1 -0
  37. ergon/connector/postgres/__init__.py +11 -0
  38. ergon/connector/postgres/async_connector.py +119 -0
  39. ergon/connector/postgres/async_service.py +116 -0
  40. ergon/connector/postgres/models.py +34 -0
  41. ergon/connector/rabbitmq/__init__.py +25 -0
  42. ergon/connector/rabbitmq/async_connector.py +120 -0
  43. ergon/connector/rabbitmq/async_service.py +417 -0
  44. ergon/connector/rabbitmq/connector.py +54 -0
  45. ergon/connector/rabbitmq/helper.py +14 -0
  46. ergon/connector/rabbitmq/models.py +92 -0
  47. ergon/connector/rabbitmq/service.py +199 -0
  48. ergon/connector/sqs/__init__.py +15 -0
  49. ergon/connector/sqs/async_connector.py +120 -0
  50. ergon/connector/sqs/async_service.py +246 -0
  51. ergon/connector/sqs/connector.py +120 -0
  52. ergon/connector/sqs/models.py +36 -0
  53. ergon/connector/sqs/service.py +219 -0
  54. ergon/connector/transaction.py +14 -0
  55. ergon/py.typed +0 -0
  56. ergon/service/__init__.py +5 -0
  57. ergon/service/service.py +17 -0
  58. ergon/task/__init__.py +13 -0
  59. ergon/task/base.py +222 -0
  60. ergon/task/exceptions.py +217 -0
  61. ergon/task/helpers.py +691 -0
  62. ergon/task/manager.py +85 -0
  63. ergon/task/mixins/__init__.py +13 -0
  64. ergon/task/mixins/consumer.py +858 -0
  65. ergon/task/mixins/metrics.py +457 -0
  66. ergon/task/mixins/producer.py +486 -0
  67. ergon/task/policies.py +229 -0
  68. ergon/task/runner.py +386 -0
  69. ergon/task/utils.py +64 -0
  70. ergon/telemetry/__init__.py +7 -0
  71. ergon/telemetry/_resource.py +13 -0
  72. ergon/telemetry/logging.py +370 -0
  73. ergon/telemetry/metrics.py +101 -0
  74. ergon/telemetry/tracing.py +152 -0
  75. ergon/utils/__init__.py +5 -0
  76. ergon/utils/env.py +26 -0
  77. ergon_framework_python-0.1.0.dist-info/METADATA +449 -0
  78. ergon_framework_python-0.1.0.dist-info/RECORD +82 -0
  79. ergon_framework_python-0.1.0.dist-info/WHEEL +5 -0
  80. ergon_framework_python-0.1.0.dist-info/entry_points.txt +2 -0
  81. ergon_framework_python-0.1.0.dist-info/licenses/LICENSE +21 -0
  82. ergon_framework_python-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,370 @@
1
+ import logging
2
+ import logging.config
3
+ from datetime import datetime
4
+ from pathlib import Path
5
+ from typing import Any, Callable, Dict, List, Literal, Optional, Union
6
+
7
+ from opentelemetry._logs import set_logger_provider
8
+ from opentelemetry.exporter.otlp.proto.grpc._log_exporter import OTLPLogExporter
9
+ from opentelemetry.sdk._logs import LoggerProvider
10
+ from opentelemetry.sdk._logs.export import (
11
+ BatchLogRecordProcessor,
12
+ ConsoleLogExporter,
13
+ SimpleLogRecordProcessor,
14
+ )
15
+
16
+ # ---------------------------
17
+ # OpenTelemetry imports
18
+ # ---------------------------
19
+ from opentelemetry.sdk.resources import Resource
20
+ from pydantic import BaseModel, Field, model_validator
21
+
22
+ from ._resource import _inject_otel_resource_attributes
23
+
24
+ # ============================================================
25
+ # Pydantic CONFIG OBJECTS
26
+ # ============================================================
27
+
28
+
29
+ class LogFormatter(BaseModel):
30
+ name: str = "default"
31
+ fmt: str = "[%(asctime)s] [%(levelname)s] [%(name)s] %(message)s"
32
+ datefmt: str = "%Y-%m-%d %H:%M:%S"
33
+
34
+
35
+ # ---------------------------
36
+ # Filter config
37
+ # ---------------------------
38
+ class LogFilter(BaseModel):
39
+ name: str # filter name in dictConfig
40
+ filter: Any # class inheriting from logging.Filter
41
+ config: Dict[str, Any] = Field(default_factory=dict)
42
+
43
+
44
+ # ---------------------------
45
+ # Base Handler config
46
+ # ---------------------------
47
+ class BaseLogHandler(BaseModel):
48
+ level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = "INFO"
49
+ formatter: str = "default"
50
+ filters: List[str] = Field(default_factory=list) # MUST be names
51
+
52
+
53
+ class ConsoleLogHandler(BaseLogHandler):
54
+ type: Literal["console"] = "console"
55
+ stream: Literal["stdout", "stderr"] = "stdout"
56
+
57
+
58
+ class FileLogHandler(BaseLogHandler):
59
+ type: Literal["file"] = "file"
60
+ filename: str
61
+ mode: Literal["a", "w"] = "a"
62
+
63
+
64
+ class JSONLogHandler(BaseLogHandler):
65
+ filename: str
66
+ type: Literal["json"] = "json"
67
+ formatter: str = "json"
68
+ encoding: str = "utf-8"
69
+ mode: Literal["a", "w"] = "a"
70
+
71
+
72
+ # ---------------------------
73
+ # EXPORTER CONFIG (for lazy instantiation)
74
+ # ---------------------------
75
+ class ExporterConfig(BaseModel):
76
+ """
77
+ Configuration for lazy exporter instantiation.
78
+ Required for multiprocessing support since gRPC channels cannot be pickled.
79
+ """
80
+
81
+ exporter: Any # Exporter class (e.g., OTLPLogExporter)
82
+ args: Dict[str, Any] = Field(default_factory=dict)
83
+
84
+
85
+ # ---------------------------
86
+ # PROCESSOR CONFIG
87
+ # ---------------------------
88
+ class LogProcessor(BaseModel):
89
+ processor: Any
90
+ config: Dict[str, Any] = Field(default_factory=dict)
91
+ # Exporters can be either instances or ExporterConfig for lazy instantiation
92
+ exporters: List[Any] = Field(default_factory=list)
93
+
94
+ @model_validator(mode="after")
95
+ def validate_exporters(self):
96
+ if not isinstance(self.exporters, list):
97
+ raise ValueError("exporters must be a list of exporter instances or ExporterConfig")
98
+ return self
99
+
100
+
101
+ # ---------------------------
102
+ # OTLP HANDLER CONFIG
103
+ # ---------------------------
104
+
105
+
106
+ class OTLPLogHandler(BaseLogHandler):
107
+ type: Literal["otlp"] = "otlp"
108
+ resource: Dict[str, Any] = Field(default_factory=dict)
109
+ processors: List[LogProcessor] = Field(default_factory=list)
110
+
111
+ @model_validator(mode="after")
112
+ def confirm_processors(self):
113
+ if not self.processors:
114
+ raise ValueError("OTLPHandlerConfig requires at least one processor.")
115
+ return self
116
+
117
+
118
+ class RotatingFileLogHandler(BaseLogHandler):
119
+ type: Literal["rotating_file"] = "rotating_file"
120
+ filename: str
121
+ max_bytes: int = 10_000_000
122
+ backup_count: int = 5
123
+ callback: Optional[Callable[[Any, str], str]] = None
124
+
125
+
126
+ class TimedRotatingFileLogHandler(BaseLogHandler):
127
+ type: Literal["timed_rotating_file"] = "timed_rotating_file"
128
+ filename: str
129
+ when: Literal["S", "M", "H", "D", "midnight"] = "midnight"
130
+ interval: int = 1
131
+ backupCount: int = 7
132
+ callback: Optional[Callable[[Any, str], str]] = None
133
+
134
+
135
+ # ---------------------------
136
+ # Handlers union
137
+ # ---------------------------
138
+ LogHandlers = Union[
139
+ ConsoleLogHandler,
140
+ FileLogHandler,
141
+ JSONLogHandler,
142
+ OTLPLogHandler,
143
+ RotatingFileLogHandler,
144
+ TimedRotatingFileLogHandler,
145
+ ]
146
+
147
+
148
+ # ---------------------------
149
+ # Main logging config object
150
+ # ---------------------------
151
+ class LoggingConfig(BaseModel):
152
+ level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = "INFO"
153
+ handlers: List[LogHandlers] = Field(default_factory=list)
154
+ filters: List[LogFilter] = Field(default_factory=list)
155
+ formatters: List[LogFormatter] = Field(default_factory=list)
156
+
157
+
158
+ # ============================================================
159
+ # Handler builders
160
+ # ============================================================
161
+
162
+
163
+ def _default_formatter_dict():
164
+ return {
165
+ "format": "[%(asctime)s] [%(levelname)s] [%(name)s] %(message)s",
166
+ "datefmt": "%Y-%m-%d %H:%M:%S",
167
+ }
168
+
169
+
170
+ def _build_console_handler_dict(cfg: ConsoleLogHandler, *args, **kwargs):
171
+ return {
172
+ "class": "logging.StreamHandler",
173
+ "level": cfg.level,
174
+ "formatter": cfg.formatter,
175
+ "stream": "ext://sys.stdout" if cfg.stream == "stdout" else "ext://sys.stderr",
176
+ "filters": cfg.filters,
177
+ }
178
+
179
+
180
+ def _resolve_filename(cfg, *args, **kwargs):
181
+ template = cfg.filename
182
+ task = kwargs["task"]
183
+ metadata = kwargs["metadata"]
184
+ task_name = metadata["task_name"]
185
+ timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
186
+
187
+ final = template.format(
188
+ pid=metadata["pid"],
189
+ task=task_name,
190
+ timestamp=timestamp,
191
+ )
192
+
193
+ if getattr(cfg, "callback", None):
194
+ return cfg.callback(task=task, filename=final)
195
+
196
+ return final
197
+
198
+
199
+ def _build_file_handler_dict(cfg: FileLogHandler, *args, **kwargs):
200
+ filename = _resolve_filename(cfg, *args, **kwargs)
201
+ Path(filename).parent.mkdir(parents=True, exist_ok=True)
202
+
203
+ return {
204
+ "class": "logging.FileHandler",
205
+ "level": cfg.level,
206
+ "formatter": cfg.formatter,
207
+ "filename": filename,
208
+ "mode": cfg.mode,
209
+ "encoding": "utf-8",
210
+ "filters": cfg.filters,
211
+ }
212
+
213
+
214
+ def _build_rotating_handler_dict(cfg: RotatingFileLogHandler, *args, **kwargs):
215
+ filename = _resolve_filename(cfg, *args, **kwargs)
216
+ Path(filename).parent.mkdir(parents=True, exist_ok=True)
217
+
218
+ return {
219
+ "class": "logging.handlers.RotatingFileHandler",
220
+ "level": cfg.level,
221
+ "formatter": cfg.formatter,
222
+ "filename": filename,
223
+ "maxBytes": cfg.max_bytes,
224
+ "backupCount": cfg.backup_count,
225
+ "encoding": "utf-8",
226
+ "filters": cfg.filters,
227
+ }
228
+
229
+
230
+ def _build_timed_rotating_handler_dict(cfg: TimedRotatingFileLogHandler, *args, **kwargs):
231
+ filename = _resolve_filename(cfg, *args, **kwargs)
232
+ Path(filename).parent.mkdir(parents=True, exist_ok=True)
233
+
234
+ return {
235
+ "class": "logging.handlers.TimedRotatingFileHandler",
236
+ "level": cfg.level,
237
+ "formatter": cfg.formatter,
238
+ "filename": filename,
239
+ "when": cfg.when,
240
+ "interval": cfg.interval,
241
+ "backupCount": cfg.backupCount,
242
+ "encoding": "utf-8",
243
+ "filters": cfg.filters,
244
+ }
245
+
246
+
247
+ def _build_json_handler_dict(cfg: JSONLogHandler, *args, **kwargs):
248
+ if cfg.filename:
249
+ filename = _resolve_filename(cfg, *args, **kwargs)
250
+ Path(filename).parent.mkdir(parents=True, exist_ok=True)
251
+ handler_class = "logging.FileHandler"
252
+ handler_args = {"filename": filename, **cfg.model_dump(exclude={"filename", "type", "formatter"})}
253
+ else:
254
+ handler_class = "logging.StreamHandler"
255
+ handler_args = {"stream": "ext://sys.stdout"}
256
+
257
+ return {
258
+ "class": handler_class,
259
+ "level": cfg.level,
260
+ "formatter": cfg.formatter,
261
+ "filters": cfg.filters,
262
+ **handler_args,
263
+ }
264
+
265
+
266
+ # ---------------------------
267
+ # REAL OTel logging pipeline
268
+ # ---------------------------
269
+
270
+
271
+ def _build_otlp_handler_dict(cfg: OTLPLogHandler, *args, **kwargs):
272
+ """Build an OTLP log handler dictionary."""
273
+
274
+ metadata = kwargs["metadata"]
275
+
276
+ cfg.resource = _inject_otel_resource_attributes(resource=cfg.resource, metadata=metadata)
277
+
278
+ resource = Resource(attributes=cfg.resource)
279
+ provider = LoggerProvider(resource=resource)
280
+
281
+ for p in cfg.processors:
282
+ for exporter in p.exporters:
283
+ # Handle lazy configuration for multiprocessing support
284
+ if isinstance(exporter, ExporterConfig):
285
+ exporter_instance = exporter.exporter(**exporter.args)
286
+ else:
287
+ exporter_instance = exporter
288
+
289
+ provider.add_log_record_processor(p.processor(**p.config, exporter=exporter_instance))
290
+
291
+ set_logger_provider(provider)
292
+
293
+ return {
294
+ "class": "opentelemetry.sdk._logs.LoggingHandler",
295
+ "level": cfg.level,
296
+ "formatter": cfg.formatter,
297
+ "filters": cfg.filters,
298
+ }
299
+
300
+
301
+ # Registry
302
+ _LOG_HANDLER_BUILDERS_DICT = {
303
+ "console": lambda cfg, *args, **kwargs: _build_console_handler_dict(cfg, *args, **kwargs),
304
+ "file": lambda cfg, *args, **kwargs: _build_file_handler_dict(cfg, *args, **kwargs),
305
+ "json": lambda cfg, *args, **kwargs: _build_json_handler_dict(cfg, *args, **kwargs),
306
+ "otlp": lambda cfg, *args, **kwargs: _build_otlp_handler_dict(cfg, *args, **kwargs),
307
+ "rotating_file": lambda cfg, *args, **kwargs: _build_rotating_handler_dict(cfg, *args, **kwargs),
308
+ "timed_rotating_file": lambda cfg, *args, **kwargs: _build_timed_rotating_handler_dict(cfg, *args, **kwargs),
309
+ }
310
+
311
+ # ============================================================
312
+ # Apply entire LoggingConfig to dictConfig
313
+ # ============================================================
314
+
315
+ _LOGGING_CONFIGURED = False
316
+
317
+
318
+ def _apply_logging_config(cfg: LoggingConfig, task: object, metadata: dict):
319
+ global _LOGGING_CONFIGURED
320
+ if _LOGGING_CONFIGURED:
321
+ return
322
+
323
+ formatters = {f.name: {"format": f.fmt, "datefmt": f.datefmt} for f in cfg.formatters}
324
+ formatters["default"] = _default_formatter_dict()
325
+ formatters["json"] = {
326
+ "()": "pythonjsonlogger.jsonlogger.JsonFormatter",
327
+ "fmt": "%(asctime)s %(levelname)s %(name)s %(message)s",
328
+ }
329
+ formatters["default"] = _default_formatter_dict()
330
+
331
+ # Build filters dictionary:
332
+ filters = {f.name: {"()": f.filter, **f.config} for f in cfg.filters}
333
+
334
+ handlers_dict = {}
335
+ handlers_order = []
336
+
337
+ for idx, handler_cfg in enumerate(cfg.handlers):
338
+ builder = _LOG_HANDLER_BUILDERS_DICT[handler_cfg.type]
339
+ handler_dict = builder(cfg=handler_cfg, task=task, metadata=metadata)
340
+
341
+ handler_name = f"handler_{idx}"
342
+ handlers_dict[handler_name] = handler_dict
343
+ handlers_order.append(handler_name)
344
+
345
+ config = {
346
+ "version": 1,
347
+ "disable_existing_loggers": False,
348
+ "formatters": formatters,
349
+ "filters": filters,
350
+ "handlers": handlers_dict,
351
+ "root": {"level": cfg.level, "handlers": handlers_order},
352
+ }
353
+
354
+ logging.config.dictConfig(config)
355
+ _LOGGING_CONFIGURED = True
356
+
357
+
358
+ def get_logger(name: str) -> logging.Logger:
359
+ return logging.getLogger(name)
360
+
361
+
362
+ __all__ = [
363
+ "OTLPLogExporter",
364
+ "BatchLogRecordProcessor",
365
+ "ConsoleLogExporter",
366
+ "SimpleLogRecordProcessor",
367
+ "ExporterConfig",
368
+ "set_logger_provider",
369
+ "LoggerProvider",
370
+ ]
@@ -0,0 +1,101 @@
1
+ # metrics.py
2
+
3
+ from typing import Any, Dict, List
4
+
5
+ from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import OTLPMetricExporter
6
+ from opentelemetry.metrics import get_meter_provider, set_meter_provider
7
+ from opentelemetry.sdk.metrics import MeterProvider
8
+ from opentelemetry.sdk.metrics.export import (
9
+ ConsoleMetricExporter,
10
+ PeriodicExportingMetricReader,
11
+ )
12
+ from opentelemetry.sdk.resources import Resource
13
+ from pydantic import BaseModel, Field
14
+
15
+ from ._resource import _inject_otel_resource_attributes
16
+
17
+ # ============================================================
18
+ # CONFIG OBJECTS
19
+ # ============================================================
20
+
21
+
22
+ class MetricReader(BaseModel):
23
+ """
24
+ Defines how a metric reader should be constructed.
25
+ Only push-based readers are supported (PeriodicExportingMetricReader).
26
+ """
27
+
28
+ reader: Any
29
+ config: Dict[str, Any] = Field(default_factory=dict)
30
+ exporters: List[Any] = Field(default_factory=list)
31
+
32
+
33
+ class MetricsConfig(BaseModel):
34
+ """
35
+ Root configuration object for the metrics subsystem.
36
+ """
37
+
38
+ resource: Dict[str, Any] = Field(default_factory=dict)
39
+ readers: List[MetricReader] = Field(default_factory=list)
40
+
41
+
42
+ # internal global flag
43
+ _CONFIGURED_METRICS = False
44
+
45
+
46
+ # ============================================================
47
+ # APPLY METRICS CONFIGURATION
48
+ # ============================================================
49
+
50
+
51
+ def _apply_metrics_config(cfg: MetricsConfig, metadata: dict):
52
+ global _CONFIGURED_METRICS
53
+ if _CONFIGURED_METRICS:
54
+ return
55
+
56
+ cfg.resource = _inject_otel_resource_attributes(resource=cfg.resource, metadata=metadata)
57
+
58
+ # 1. Create Resource
59
+ resource = Resource(attributes=cfg.resource)
60
+
61
+ # 2. Create MeterProvider
62
+ provider = MeterProvider(resource=resource)
63
+
64
+ # 3. Register metric readers (PUSH-BASED ONLY)
65
+ for r in cfg.readers:
66
+ reader_cls = r.reader
67
+
68
+ # Create reader instance
69
+ # Each reader may have multiple exporters
70
+ # For push-based metrics, exporters are passed inside PeriodicExportingMetricReader
71
+ for exporter in r.exporters:
72
+ reader = reader_cls(exporter=exporter, **r.config)
73
+ provider.add_metric_reader(reader) # type: ignore[attr-defined]
74
+
75
+ # 4. Register globally
76
+ set_meter_provider(provider)
77
+
78
+ _CONFIGURED_METRICS = True
79
+
80
+
81
+ # ============================================================
82
+ # ACCESSOR
83
+ # ============================================================
84
+
85
+
86
+ def get_metric_meter(name: str):
87
+ """
88
+ Returns a Meter, similar to get_logger for logging.
89
+ """
90
+ provider = get_meter_provider()
91
+ return provider.get_meter(name)
92
+
93
+
94
+ __all__ = [
95
+ "OTLPMetricExporter",
96
+ "ConsoleMetricExporter",
97
+ "PeriodicExportingMetricReader",
98
+ "MetricReader",
99
+ "MetricsConfig",
100
+ "get_metric_meter",
101
+ ]
@@ -0,0 +1,152 @@
1
+ import threading
2
+ from typing import Any, Dict, List, Optional
3
+
4
+ from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
5
+ from opentelemetry.sdk.resources import Resource
6
+ from opentelemetry.sdk.trace import TracerProvider
7
+ from opentelemetry.sdk.trace.export import BatchSpanProcessor, SimpleSpanProcessor
8
+ from opentelemetry.trace import set_tracer_provider
9
+ from pydantic import BaseModel, ConfigDict, Field
10
+
11
+ from ._resource import _inject_otel_resource_attributes
12
+
13
+ # ============================================================
14
+ # Pydantic CONFIG OBJECTS
15
+ # ============================================================
16
+
17
+
18
+ class SamplerConfig(BaseModel):
19
+ """
20
+ Configures sampling strategy such as:
21
+ - AlwaysOnSampler
22
+ - AlwaysOffSampler
23
+ - TraceIdRatioBased
24
+ """
25
+
26
+ sampler: Any # class or callable
27
+ args: Dict[str, Any] = Field(default_factory=dict)
28
+
29
+
30
+ class ExporterConfig(BaseModel):
31
+ """
32
+ Configuration for lazy exporter instantiation.
33
+ Required for multiprocessing support since gRPC channels cannot be pickled.
34
+ """
35
+
36
+ model_config = ConfigDict(arbitrary_types_allowed=True)
37
+
38
+ exporter: Any # Exporter class (e.g., OTLPSpanExporter)
39
+ args: Dict[str, Any] = Field(default_factory=dict)
40
+
41
+
42
+ class SpanProcessor(BaseModel):
43
+ """
44
+ Mirrors logging.ProcessorConfig.
45
+ Defines a span processor with one or multiple span exporters.
46
+ """
47
+
48
+ model_config = ConfigDict(arbitrary_types_allowed=True)
49
+
50
+ processor: Any # BatchSpanProcessor, SimpleSpanProcessor, custom
51
+ config: Dict[str, Any] = Field(default_factory=dict)
52
+ # Exporters can be either instances or ExporterConfig for lazy instantiation
53
+ exporters: List[Any] = Field(default_factory=list)
54
+
55
+
56
+ class TracingConfig(BaseModel):
57
+ """
58
+ Root configuration object for tracing.
59
+ Mirrors LoggingConfig structure.
60
+ """
61
+
62
+ resource: Dict[str, Any] = Field(default_factory=dict)
63
+ sampler: Optional[SamplerConfig] = None
64
+ processors: List[SpanProcessor] = Field(default_factory=list)
65
+
66
+
67
+ # ============================================================
68
+ # INTERNAL STATE
69
+ # ============================================================
70
+
71
+ _TRACING_CONFIGURED = False
72
+ _TRACING_LOCK = threading.Lock()
73
+
74
+
75
+ # ============================================================
76
+ # APPLY TRACING CONFIG
77
+ # ============================================================
78
+
79
+
80
+ def _apply_tracing_config(cfg: TracingConfig, metadata: dict):
81
+ """
82
+ Initializes global OpenTelemetry TracerProvider.
83
+ - Builds Resource
84
+ - Applies sampler
85
+ - Instantiates span processors
86
+ - Registers exporters
87
+ - Sets provider globally
88
+
89
+ Mirrors logging.apply_logger_config but adapted for tracing.
90
+ """
91
+
92
+ global _TRACING_CONFIGURED
93
+
94
+ with _TRACING_LOCK:
95
+ if _TRACING_CONFIGURED:
96
+ return
97
+
98
+ cfg.resource = _inject_otel_resource_attributes(resource=cfg.resource, metadata=metadata)
99
+
100
+ # Build Resource
101
+ resource = Resource(attributes=cfg.resource)
102
+
103
+ # Build Sampler
104
+ if cfg.sampler:
105
+ sampler_instance = cfg.sampler.sampler(**cfg.sampler.args)
106
+ provider = TracerProvider(resource=resource, sampler=sampler_instance)
107
+ else:
108
+ provider = TracerProvider(resource=resource)
109
+
110
+ # Build processors
111
+ for p in cfg.processors:
112
+ for exporter in p.exporters:
113
+ # Handle lazy configuration for multiprocessing support
114
+ if isinstance(exporter, ExporterConfig):
115
+ exporter_instance = exporter.exporter(**exporter.args)
116
+ else:
117
+ exporter_instance = exporter
118
+
119
+ processor = p.processor(exporter_instance, **p.config)
120
+ provider.add_span_processor(processor)
121
+
122
+ # Register provider globally
123
+ set_tracer_provider(provider)
124
+
125
+ _TRACING_CONFIGURED = True
126
+
127
+
128
+ # ============================================================
129
+ # GET TRACER
130
+ # ============================================================
131
+
132
+
133
+ def get_tracer(name: str = "ergon"):
134
+ """
135
+ Returns a tracer with the given name.
136
+ Mirrors get_logger(name).
137
+ """
138
+ from opentelemetry.trace import get_tracer
139
+
140
+ return get_tracer(name)
141
+
142
+
143
+ __all__ = [
144
+ "OTLPSpanExporter",
145
+ "BatchSpanProcessor",
146
+ "SimpleSpanProcessor",
147
+ "SamplerConfig",
148
+ "SpanProcessor",
149
+ "TracingConfig",
150
+ "ExporterConfig",
151
+ "get_tracer",
152
+ ]
@@ -0,0 +1,5 @@
1
+ from .env import load_env
2
+
3
+ __all__ = [
4
+ "load_env",
5
+ ]
ergon/utils/env.py ADDED
@@ -0,0 +1,26 @@
1
+ import os
2
+
3
+ from dotenv import load_dotenv
4
+
5
+ ENV_LOADED = False
6
+
7
+
8
+ def load_env():
9
+ global ENV_LOADED
10
+ if ENV_LOADED:
11
+ return
12
+
13
+ env_file = os.environ.get("ENV_FILE")
14
+
15
+ if not env_file:
16
+ raise ValueError(
17
+ "ENV_FILE is not set. You must set the ENV_FILE environment variable. "
18
+ "Example: ENV_FILE=.env.local | .env.dev | .env.prod | .env.staging"
19
+ )
20
+
21
+ # If env vars already exist, we are likely in Docker/K8s
22
+ # dotenv should NOT override them
23
+ if os.path.exists(env_file):
24
+ load_dotenv(env_file, encoding="utf-8", override=True)
25
+
26
+ ENV_LOADED = True