haiway 0.17.0__py3-none-any.whl → 0.18.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 (47) hide show
  1. haiway/__init__.py +24 -18
  2. haiway/context/__init__.py +23 -13
  3. haiway/context/access.py +127 -91
  4. haiway/context/disposables.py +2 -2
  5. haiway/context/identifier.py +4 -5
  6. haiway/context/observability.py +526 -0
  7. haiway/context/state.py +2 -2
  8. haiway/context/tasks.py +1 -3
  9. haiway/context/types.py +2 -2
  10. haiway/helpers/__init__.py +5 -7
  11. haiway/helpers/asynchrony.py +2 -2
  12. haiway/helpers/caching.py +2 -2
  13. haiway/helpers/observability.py +244 -0
  14. haiway/helpers/retries.py +1 -3
  15. haiway/helpers/throttling.py +1 -3
  16. haiway/helpers/timeouted.py +1 -3
  17. haiway/helpers/tracing.py +21 -35
  18. haiway/opentelemetry/__init__.py +3 -0
  19. haiway/opentelemetry/observability.py +452 -0
  20. haiway/state/__init__.py +2 -2
  21. haiway/state/attributes.py +2 -2
  22. haiway/state/path.py +1 -3
  23. haiway/state/requirement.py +1 -3
  24. haiway/state/structure.py +161 -30
  25. haiway/state/validation.py +2 -2
  26. haiway/types/__init__.py +2 -2
  27. haiway/types/default.py +2 -2
  28. haiway/types/frozen.py +1 -3
  29. haiway/types/missing.py +2 -2
  30. haiway/utils/__init__.py +2 -2
  31. haiway/utils/always.py +2 -2
  32. haiway/utils/collections.py +2 -2
  33. haiway/utils/env.py +2 -2
  34. haiway/utils/freezing.py +1 -3
  35. haiway/utils/logs.py +1 -3
  36. haiway/utils/mimic.py +1 -3
  37. haiway/utils/noop.py +2 -2
  38. haiway/utils/queue.py +1 -3
  39. haiway/utils/stream.py +1 -3
  40. {haiway-0.17.0.dist-info → haiway-0.18.1.dist-info}/METADATA +9 -5
  41. haiway-0.18.1.dist-info/RECORD +44 -0
  42. haiway/context/logging.py +0 -242
  43. haiway/context/metrics.py +0 -176
  44. haiway/helpers/metrics.py +0 -465
  45. haiway-0.17.0.dist-info/RECORD +0 -43
  46. {haiway-0.17.0.dist-info → haiway-0.18.1.dist-info}/WHEEL +0 -0
  47. {haiway-0.17.0.dist-info → haiway-0.18.1.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,452 @@
1
+ import os
2
+ from collections.abc import Mapping
3
+ from typing import Any, Self, final
4
+
5
+ from opentelemetry import metrics, trace
6
+ from opentelemetry._logs import get_logger, set_logger_provider
7
+ from opentelemetry._logs._internal import Logger
8
+ from opentelemetry._logs.severity import SeverityNumber
9
+ from opentelemetry.context import Context
10
+ from opentelemetry.exporter.otlp.proto.grpc._log_exporter import OTLPLogExporter
11
+ from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import OTLPMetricExporter
12
+ from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
13
+ from opentelemetry.metrics._internal import Meter
14
+ from opentelemetry.metrics._internal.instrument import Counter, Histogram
15
+ from opentelemetry.sdk._logs import LoggerProvider
16
+ from opentelemetry.sdk._logs._internal import LogRecord
17
+ from opentelemetry.sdk._logs._internal.export import (
18
+ BatchLogRecordProcessor,
19
+ ConsoleLogExporter,
20
+ LogExporter,
21
+ )
22
+ from opentelemetry.sdk.metrics import MeterProvider as SdkMeterProvider
23
+ from opentelemetry.sdk.metrics._internal.export import MetricExporter
24
+ from opentelemetry.sdk.metrics.export import (
25
+ ConsoleMetricExporter,
26
+ PeriodicExportingMetricReader,
27
+ )
28
+ from opentelemetry.sdk.resources import Resource
29
+ from opentelemetry.sdk.trace import TracerProvider
30
+ from opentelemetry.sdk.trace.export import BatchSpanProcessor, ConsoleSpanExporter, SpanExporter
31
+ from opentelemetry.trace import Span, StatusCode, Tracer
32
+
33
+ from haiway.context import Observability, ObservabilityLevel, ScopeIdentifier
34
+
35
+ ###
36
+ from haiway.context.observability import ObservabilityAttribute
37
+ from haiway.state import State
38
+
39
+ __all__ = ("OpenTelemetry",)
40
+
41
+
42
+ class ScopeStore:
43
+ __slots__ = (
44
+ "_completed",
45
+ "_counters",
46
+ "_exited",
47
+ "_histograms",
48
+ "identifier",
49
+ "logger",
50
+ "logger_2",
51
+ "meter",
52
+ "nested",
53
+ "span",
54
+ )
55
+
56
+ def __init__(
57
+ self,
58
+ identifier: ScopeIdentifier,
59
+ /,
60
+ span: Span,
61
+ meter: Meter,
62
+ logger: Logger,
63
+ ) -> None:
64
+ self.identifier: ScopeIdentifier = identifier
65
+ self.nested: list[ScopeStore] = []
66
+ self._counters: dict[str, Counter] = {}
67
+ self._histograms: dict[str, Histogram] = {}
68
+ self._exited: bool = False
69
+ self._completed: bool = False
70
+ self.span: Span = span
71
+ self.meter: Meter = meter
72
+ self.logger: Logger = logger
73
+
74
+ @property
75
+ def exited(self) -> bool:
76
+ return self._exited
77
+
78
+ def exit(self) -> None:
79
+ assert not self._exited # nosec: B101
80
+ self._exited = True
81
+
82
+ @property
83
+ def completed(self) -> bool:
84
+ return self._completed and all(nested.completed for nested in self.nested)
85
+
86
+ def try_complete(self) -> bool:
87
+ if not self._exited:
88
+ return False # not elegible for completion yet
89
+
90
+ if self._completed:
91
+ return False # already completed
92
+
93
+ if not all(nested.completed for nested in self.nested):
94
+ return False # nested not completed
95
+
96
+ self._completed = True
97
+ self.span.end()
98
+ return True # successfully completed
99
+
100
+ def record_log(
101
+ self,
102
+ message: str,
103
+ /,
104
+ level: ObservabilityLevel,
105
+ ) -> None:
106
+ self.logger.emit(
107
+ LogRecord(
108
+ span_id=self.span.get_span_context().span_id,
109
+ trace_id=self.span.get_span_context().trace_id,
110
+ trace_flags=self.span.get_span_context().trace_flags,
111
+ body=message,
112
+ severity_text=level.name,
113
+ severity_number=SEVERITY_MAPPING[level],
114
+ attributes={
115
+ "trace_id": self.identifier.trace_id,
116
+ "scope_id": self.identifier.scope_id,
117
+ "parent_id": self.identifier.parent_id,
118
+ },
119
+ )
120
+ )
121
+
122
+ def record_exception(
123
+ self,
124
+ exception: BaseException,
125
+ /,
126
+ ) -> None:
127
+ self.span.record_exception(exception)
128
+
129
+ def record_event(
130
+ self,
131
+ event: State,
132
+ /,
133
+ ) -> None:
134
+ self.span.add_event(
135
+ str(type(event).__name__),
136
+ attributes=event.to_mapping(recursive=True),
137
+ )
138
+
139
+ def record_metric(
140
+ self,
141
+ name: str,
142
+ /,
143
+ *,
144
+ value: float | int,
145
+ unit: str | None,
146
+ ) -> None:
147
+ attributes: Mapping[str, Any] = {
148
+ "trace_id": self.identifier.trace_id,
149
+ "scope_id": self.identifier.scope_id,
150
+ "parent_id": self.identifier.parent_id,
151
+ }
152
+
153
+ if name not in self._counters:
154
+ self._counters[name] = self.meter.create_counter(
155
+ name=name,
156
+ unit=unit or "",
157
+ )
158
+
159
+ self._counters[name].add(
160
+ value,
161
+ attributes=attributes,
162
+ )
163
+
164
+ def record_attribute(
165
+ self,
166
+ name: str,
167
+ /,
168
+ *,
169
+ value: ObservabilityAttribute,
170
+ ) -> None:
171
+ self.span.set_attribute(
172
+ name,
173
+ value=value,
174
+ )
175
+
176
+
177
+ @final
178
+ class OpenTelemetry:
179
+ @classmethod
180
+ def configure(
181
+ cls,
182
+ *,
183
+ service: str,
184
+ version: str,
185
+ environment: str,
186
+ otlp_endpoint: str | None = None,
187
+ insecure: bool = True,
188
+ export_interval_millis: int = 10000,
189
+ attributes: Mapping[str, Any] | None = None,
190
+ ) -> type[Self]:
191
+ # Create shared resource for both metrics and traces
192
+ resource: Resource = Resource.create(
193
+ {
194
+ "service.name": service,
195
+ "service.version": version,
196
+ "deployment.environment": environment,
197
+ "service.pid": os.getpid(),
198
+ **(attributes if attributes is not None else {}),
199
+ },
200
+ )
201
+
202
+ logs_exporter: LogExporter
203
+ span_exporter: SpanExporter
204
+ metric_exporter: MetricExporter
205
+
206
+ if otlp_endpoint:
207
+ logs_exporter = OTLPLogExporter(
208
+ endpoint=otlp_endpoint,
209
+ insecure=insecure,
210
+ )
211
+ span_exporter = OTLPSpanExporter(
212
+ endpoint=otlp_endpoint,
213
+ insecure=insecure,
214
+ )
215
+ metric_exporter = OTLPMetricExporter(
216
+ endpoint=otlp_endpoint,
217
+ insecure=insecure,
218
+ )
219
+
220
+ else:
221
+ logs_exporter = ConsoleLogExporter()
222
+ span_exporter = ConsoleSpanExporter()
223
+ metric_exporter = ConsoleMetricExporter()
224
+
225
+ # Set up logger provider
226
+ logger_provider: LoggerProvider = LoggerProvider(
227
+ resource=resource,
228
+ shutdown_on_exit=True,
229
+ )
230
+ log_processor: BatchLogRecordProcessor = BatchLogRecordProcessor(logs_exporter)
231
+ logger_provider.add_log_record_processor(log_processor)
232
+ set_logger_provider(logger_provider)
233
+
234
+ # Set up metrics provider
235
+ meter_provider: SdkMeterProvider = SdkMeterProvider(
236
+ resource=resource,
237
+ metric_readers=[
238
+ PeriodicExportingMetricReader(
239
+ metric_exporter,
240
+ export_interval_millis=export_interval_millis,
241
+ )
242
+ ],
243
+ shutdown_on_exit=True,
244
+ )
245
+ metrics.set_meter_provider(meter_provider)
246
+
247
+ # Set up trace provider
248
+ tracer_provider: TracerProvider = TracerProvider(
249
+ resource=resource,
250
+ shutdown_on_exit=True,
251
+ )
252
+ span_processor: BatchSpanProcessor = BatchSpanProcessor(span_exporter)
253
+ tracer_provider.add_span_processor(span_processor)
254
+ trace.set_tracer_provider(tracer_provider)
255
+
256
+ return cls
257
+
258
+ @classmethod
259
+ def observability( # noqa: C901, PLR0915
260
+ cls,
261
+ level: ObservabilityLevel = ObservabilityLevel.INFO,
262
+ ) -> Observability:
263
+ tracer: Tracer | None = None
264
+ meter: Meter | None = None
265
+ root_scope: ScopeIdentifier | None = None
266
+ scopes: dict[str, ScopeStore] = {}
267
+ observed_level: ObservabilityLevel = level
268
+
269
+ def log_recording(
270
+ scope: ScopeIdentifier,
271
+ /,
272
+ level: ObservabilityLevel,
273
+ message: str,
274
+ *args: Any,
275
+ exception: BaseException | None,
276
+ **extra: Any,
277
+ ) -> None:
278
+ assert root_scope is not None # nosec: B101
279
+ assert scope.scope_id in scopes # nosec: B101
280
+
281
+ if level < observed_level:
282
+ return
283
+
284
+ scopes[scope.scope_id].record_log(
285
+ message % args,
286
+ level=level,
287
+ )
288
+ if exception is not None:
289
+ scopes[scope.scope_id].record_exception(exception)
290
+
291
+ def event_recording(
292
+ scope: ScopeIdentifier,
293
+ /,
294
+ *,
295
+ level: ObservabilityLevel,
296
+ event: State,
297
+ **extra: Any,
298
+ ) -> None:
299
+ assert root_scope is not None # nosec: B101
300
+ assert scope.scope_id in scopes # nosec: B101
301
+
302
+ if level < observed_level:
303
+ return
304
+
305
+ scopes[scope.scope_id].record_event(event)
306
+
307
+ def metric_recording(
308
+ scope: ScopeIdentifier,
309
+ /,
310
+ *,
311
+ metric: str,
312
+ value: float | int,
313
+ unit: str | None,
314
+ **extra: Any,
315
+ ) -> None:
316
+ assert root_scope is not None # nosec: B101
317
+ assert scope.scope_id in scopes # nosec: B101
318
+
319
+ if level < observed_level:
320
+ return
321
+
322
+ scopes[scope.scope_id].record_metric(
323
+ metric,
324
+ value=value,
325
+ unit=unit,
326
+ )
327
+
328
+ def attributes_recording(
329
+ scope: ScopeIdentifier,
330
+ /,
331
+ **attributes: ObservabilityAttribute,
332
+ ) -> None:
333
+ if not attributes:
334
+ return
335
+
336
+ for attribute, value in attributes.items():
337
+ scopes[scope.scope_id].record_attribute(
338
+ attribute,
339
+ value=value,
340
+ )
341
+
342
+ def scope_entering[Metric: State](
343
+ scope: ScopeIdentifier,
344
+ /,
345
+ ) -> None:
346
+ nonlocal tracer
347
+ assert scope.scope_id not in scopes # nosec: B101
348
+
349
+ nonlocal root_scope
350
+ nonlocal meter
351
+
352
+ scope_store: ScopeStore
353
+ if root_scope is None:
354
+ tracer = trace.get_tracer(scope.trace_id)
355
+ meter = metrics.get_meter(scope.trace_id)
356
+ scope_store = ScopeStore(
357
+ scope,
358
+ span=tracer.start_span(
359
+ name=scope.label,
360
+ context=Context(
361
+ trace_id=scope.trace_id,
362
+ scope_id=scope.scope_id,
363
+ parent_id=scope.parent_id,
364
+ ),
365
+ attributes={
366
+ "trace_id": scope.trace_id,
367
+ "scope_id": scope.scope_id,
368
+ "parent_id": scope.parent_id,
369
+ },
370
+ ),
371
+ meter=meter,
372
+ logger=get_logger(scope.label),
373
+ )
374
+ root_scope = scope
375
+
376
+ else:
377
+ assert tracer is not None # nosec: B101
378
+ assert meter is not None # nosec: B101
379
+
380
+ scope_store = ScopeStore(
381
+ scope,
382
+ span=tracer.start_span(
383
+ name=scope.label,
384
+ context=trace.set_span_in_context(
385
+ scopes[scope.parent_id].span,
386
+ Context(
387
+ trace_id=scope.trace_id,
388
+ scope_id=scope.scope_id,
389
+ parent_id=scope.parent_id,
390
+ ),
391
+ ),
392
+ attributes={
393
+ "trace_id": scope.trace_id,
394
+ "scope_id": scope.scope_id,
395
+ "parent_id": scope.parent_id,
396
+ },
397
+ ),
398
+ meter=meter,
399
+ logger=get_logger(scope.label),
400
+ )
401
+ scopes[scope.parent_id].nested.append(scope_store)
402
+
403
+ scopes[scope.scope_id] = scope_store
404
+
405
+ def scope_exiting[Metric: State](
406
+ scope: ScopeIdentifier,
407
+ /,
408
+ *,
409
+ exception: BaseException | None,
410
+ ) -> None:
411
+ nonlocal root_scope
412
+ nonlocal scopes
413
+ assert root_scope is not None # nosec: B101
414
+ assert scope.scope_id in scopes # nosec: B101
415
+
416
+ scopes[scope.scope_id].exit()
417
+ if exception is not None:
418
+ scopes[scope.scope_id].span.set_status(status=StatusCode.ERROR)
419
+
420
+ else:
421
+ scopes[scope.scope_id].span.set_status(status=StatusCode.OK)
422
+
423
+ if not scopes[scope.scope_id].try_complete():
424
+ return # not completed yet or already completed
425
+
426
+ # try complete parent scopes
427
+ parent_id: str = scope.parent_id
428
+ while scopes[parent_id].try_complete():
429
+ parent_id = scopes[parent_id].identifier.parent_id
430
+
431
+ # check for root completion
432
+ if scopes[root_scope.scope_id].completed:
433
+ # finished root - cleanup state
434
+ root_scope = None
435
+ scopes = {}
436
+
437
+ return Observability(
438
+ log_recording=log_recording,
439
+ event_recording=event_recording,
440
+ metric_recording=metric_recording,
441
+ attributes_recording=attributes_recording,
442
+ scope_entering=scope_entering,
443
+ scope_exiting=scope_exiting,
444
+ )
445
+
446
+
447
+ SEVERITY_MAPPING = {
448
+ ObservabilityLevel.DEBUG: SeverityNumber.DEBUG,
449
+ ObservabilityLevel.INFO: SeverityNumber.INFO,
450
+ ObservabilityLevel.WARNING: SeverityNumber.WARN,
451
+ ObservabilityLevel.ERROR: SeverityNumber.ERROR,
452
+ }
haiway/state/__init__.py CHANGED
@@ -3,10 +3,10 @@ from haiway.state.path import AttributePath
3
3
  from haiway.state.requirement import AttributeRequirement
4
4
  from haiway.state.structure import State
5
5
 
6
- __all__ = [
6
+ __all__ = (
7
7
  "AttributeAnnotation",
8
8
  "AttributePath",
9
9
  "AttributeRequirement",
10
10
  "State",
11
11
  "attribute_annotations",
12
- ]
12
+ )
@@ -25,11 +25,11 @@ from typing import (
25
25
  from haiway import types as haiway_types
26
26
  from haiway.types import MISSING, Missing
27
27
 
28
- __all__ = [
28
+ __all__ = (
29
29
  "AttributeAnnotation",
30
30
  "attribute_annotations",
31
31
  "resolve_attribute_annotation",
32
- ]
32
+ )
33
33
 
34
34
 
35
35
  @final
haiway/state/path.py CHANGED
@@ -10,9 +10,7 @@ from typing import Any, TypeAliasType, final, get_args, get_origin, overload
10
10
 
11
11
  from haiway.types import MISSING, Missing, not_missing
12
12
 
13
- __all__ = [
14
- "AttributePath",
15
- ]
13
+ __all__ = ("AttributePath",)
16
14
 
17
15
 
18
16
  class AttributePathComponent(ABC):
@@ -3,9 +3,7 @@ from typing import Any, Literal, Self, cast, final
3
3
 
4
4
  from haiway.state.path import AttributePath
5
5
 
6
- __all__ = [
7
- "AttributeRequirement",
8
- ]
6
+ __all__ = ("AttributeRequirement",)
9
7
 
10
8
 
11
9
  @final